From de5341ff71727373467808dd27edd0c4b1ede7f4 Mon Sep 17 00:00:00 2001 From: Xavier Saliniere Date: Fri, 11 Jul 2025 18:04:28 -0400 Subject: [PATCH 1/2] feat: implement chunk loading with retry logic for IPFS deployments --- next.config.ts | 14 +++ src/app/layout.tsx | 3 + src/utils/__tests__/chunk-retry.test.ts | 109 +++++++++++++++++++ src/utils/chunk-retry.ts | 136 ++++++++++++++++++++++++ 4 files changed, 262 insertions(+) create mode 100644 src/utils/__tests__/chunk-retry.test.ts create mode 100644 src/utils/chunk-retry.ts diff --git a/next.config.ts b/next.config.ts index 8827145..52fa916 100644 --- a/next.config.ts +++ b/next.config.ts @@ -18,6 +18,20 @@ const nextConfig: NextConfig = { process.env.IPNS_HASH || 'k2k4r8ng8uzrtqb5ham8kao889m8qezu96z4w3lpinyqghum43veb6n3', }, + webpack: (config, { isServer }) => { + if (!isServer) { + // Optimize for IPFS deployment + config.output = { + ...config.output, + // Enable CORS for IPFS gateways - safe for public content + crossOriginLoading: 'anonymous', + // Custom chunk loading for retry logic + chunkLoadingGlobal: 'webpackChunkRetry', + }; + } + + return config; + }, }; const withNextIntl = createNextIntlPlugin(); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ef5f0a9..f623256 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,9 @@ import { getTranslations } from 'next-intl/server'; import { AppStateProvider } from '@/contexts/AppContext'; import 'tailwindcss/index.css'; +// Initialize chunk retry manager for IPFS deployment +import '@/utils/chunk-retry'; + export async function generateMetadata() { const tMeta = await getTranslations({ locale: 'en', namespace: 'Metadata' }); diff --git a/src/utils/__tests__/chunk-retry.test.ts b/src/utils/__tests__/chunk-retry.test.ts new file mode 100644 index 0000000..6c80502 --- /dev/null +++ b/src/utils/__tests__/chunk-retry.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ChunkRetryManager } from '../chunk-retry'; + +describe('ChunkRetryManager', () => { + let manager: ChunkRetryManager; + + beforeEach(() => { + manager = new ChunkRetryManager(); + }); + + it('should retry chunk loading on failure', async () => { + let attempts = 0; + const mockLoadFn = vi.fn().mockImplementation(() => { + attempts++; + if (attempts < 3) { + const error = new Error('Loading chunk failed'); + error.name = 'ChunkLoadError'; + throw error; + } + return Promise.resolve('success'); + }); + + const result = await manager.retryChunkLoad('test-chunk', mockLoadFn); + + expect(result).toBe('success'); + expect(mockLoadFn).toHaveBeenCalledTimes(3); + }); + + it('should not retry on non-retryable errors', async () => { + const mockLoadFn = vi.fn().mockImplementation(() => { + const error = new Error('Syntax error'); + error.name = 'SyntaxError'; + throw error; + }); + + await expect( + manager.retryChunkLoad('test-chunk', mockLoadFn), + ).rejects.toThrow('Syntax error'); + expect(mockLoadFn).toHaveBeenCalledTimes(1); + }); + + it('should track retry statistics', async () => { + const mockLoadFn = vi.fn().mockImplementation(() => { + const error = new Error('Network timeout'); + error.name = 'NetworkError'; + throw error; + }); + + await expect( + manager.retryChunkLoad('test-chunk', mockLoadFn), + ).rejects.toThrow(); + + const stats = manager.getRetryStats(); + expect(stats['test-chunk']).toBeDefined(); + expect(stats['test-chunk']).toBeGreaterThan(0); + }, 10000); // 10 second timeout + + it('should use exponential backoff', async () => { + const startTime = Date.now(); + const mockLoadFn = vi.fn().mockImplementation(() => { + const error = new Error('Loading chunk failed'); + error.name = 'ChunkLoadError'; + throw error; + }); + + await expect( + manager.retryChunkLoad('test-chunk', mockLoadFn), + ).rejects.toThrow(); + + const duration = Date.now() - startTime; + // Should take at least 1000ms (first retry) + 2000ms (second retry) = 3000ms + expect(duration).toBeGreaterThan(3000); + }, 15000); // 15 second timeout + + it('should succeed on first try without retries', async () => { + const mockLoadFn = vi.fn().mockResolvedValue('immediate success'); + + const result = await manager.retryChunkLoad('test-chunk', mockLoadFn); + + expect(result).toBe('immediate success'); + expect(mockLoadFn).toHaveBeenCalledTimes(1); + expect(manager.getRetryStats()).toEqual({}); + }); + + it('should handle chunk loading errors correctly', async () => { + const mockLoadFn = vi.fn().mockImplementation(() => { + const error = new Error('Loading chunk 123 failed'); + error.name = 'ChunkLoadError'; + throw error; + }); + + await expect( + manager.retryChunkLoad('test-chunk', mockLoadFn), + ).rejects.toThrow('Loading chunk 123 failed'); + expect(mockLoadFn).toHaveBeenCalledTimes(4); // 1 initial + 3 retries + }, 15000); + + it('should handle network timeouts correctly', async () => { + const mockLoadFn = vi.fn().mockImplementation(() => { + const error = new Error('Failed to fetch'); + throw error; + }); + + await expect( + manager.retryChunkLoad('test-chunk', mockLoadFn), + ).rejects.toThrow('Failed to fetch'); + expect(mockLoadFn).toHaveBeenCalledTimes(4); // 1 initial + 3 retries + }, 15000); +}); diff --git a/src/utils/chunk-retry.ts b/src/utils/chunk-retry.ts new file mode 100644 index 0000000..d77c394 --- /dev/null +++ b/src/utils/chunk-retry.ts @@ -0,0 +1,136 @@ +/** + * Enhanced chunk loading with retry logic for IPFS deployments + * This utility handles failed chunk loads and retries with exponential backoff + */ + +interface ChunkRetryConfig { + maxRetries: number; + baseDelay: number; + maxDelay: number; + retryOn: (error: Error) => boolean; +} + +interface WindowWithWebpack extends Window { + __webpack_require__?: { + e?: (chunkId: string) => Promise; + }; +} + +const DEFAULT_CONFIG: ChunkRetryConfig = { + maxRetries: 3, + baseDelay: 1000, + maxDelay: 10000, + retryOn: (error: Error) => { + // Retry on network errors, timeouts, and chunk loading failures + return ( + error.name === 'ChunkLoadError' || + error.name === 'NetworkError' || + error.message.includes('Loading chunk') || + error.message.includes('timeout') || + error.message.includes('Failed to fetch') + ); + }, +}; + +class ChunkRetryManager { + private config: ChunkRetryConfig; + private retryAttempts: Map = new Map(); + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + /** + * Retry chunk loading with exponential backoff + */ + async retryChunkLoad( + chunkId: string, + loadFn: () => Promise, + attempt: number = 0, + ): Promise { + try { + const result = await loadFn(); + this.retryAttempts.delete(chunkId); + return result; + } catch (error) { + const err = error as Error; + + if (!this.config.retryOn(err) || attempt >= this.config.maxRetries) { + throw error; + } + + const currentAttempts = this.retryAttempts.get(chunkId) || 0; + this.retryAttempts.set(chunkId, currentAttempts + 1); + + const delay = Math.min( + this.config.baseDelay * 2 ** attempt, + this.config.maxDelay, + ); + + console.warn( + `Chunk loading failed for ${chunkId}, retrying in ${delay}ms (attempt ${ + attempt + 1 + }/${this.config.maxRetries})`, + err, + ); + + await new Promise((resolve) => setTimeout(resolve, delay)); + return this.retryChunkLoad(chunkId, loadFn, attempt + 1); + } + } + + /** + * Get retry statistics for monitoring + */ + getRetryStats(): Record { + return Object.fromEntries(this.retryAttempts.entries()); + } +} + +// Global instance +const chunkRetryManager = new ChunkRetryManager(); + +// Override the default chunk loading behavior +if (typeof window !== 'undefined') { + const webpackWindow = window as WindowWithWebpack; + + // Override dynamic imports + const originalDynamicImport = webpackWindow.__webpack_require__?.e; + + if (originalDynamicImport && webpackWindow.__webpack_require__) { + webpackWindow.__webpack_require__.e = function (chunkId: string) { + const loadChunk = () => originalDynamicImport.call(this, chunkId); + + return chunkRetryManager.retryChunkLoad(`chunk-${chunkId}`, loadChunk); + }; + } + + // Handle script loading errors + window.addEventListener('error', (event) => { + const target = event.target as HTMLScriptElement | null; + if (target?.src) { + const src = target.src; + if (src.includes('_next/static/chunks/')) { + console.error('Chunk loading error detected:', src, event.error); + // The retry logic will be handled by the chunk loader + } + } + }); + + // Handle unhandled promise rejections from chunk loading + window.addEventListener('unhandledrejection', (event) => { + if (event.reason?.message) { + const message = event.reason.message; + if ( + message.includes('Loading chunk') || + message.includes('ChunkLoadError') + ) { + console.error('Chunk loading rejection:', event.reason); + // The retry logic will be handled by the chunk loader + } + } + }); +} + +export { ChunkRetryManager, chunkRetryManager }; +export type { ChunkRetryConfig }; From 9370b006dbf466aee53ecd7bff75c16949654527 Mon Sep 17 00:00:00 2001 From: Xavier Saliniere Date: Fri, 11 Jul 2025 18:12:58 -0400 Subject: [PATCH 2/2] refactor: extract error patterns into constants and enhance retry statistics tracking in ChunkRetryManager --- src/utils/chunk-retry.ts | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/src/utils/chunk-retry.ts b/src/utils/chunk-retry.ts index d77c394..8fa4d4b 100644 --- a/src/utils/chunk-retry.ts +++ b/src/utils/chunk-retry.ts @@ -16,6 +16,10 @@ interface WindowWithWebpack extends Window { }; } +// Error patterns that should trigger retries +const RETRY_ERROR_NAMES = ['ChunkLoadError', 'NetworkError']; +const RETRY_ERROR_MESSAGES = ['Loading chunk', 'timeout', 'Failed to fetch']; + const DEFAULT_CONFIG: ChunkRetryConfig = { maxRetries: 3, baseDelay: 1000, @@ -23,11 +27,8 @@ const DEFAULT_CONFIG: ChunkRetryConfig = { retryOn: (error: Error) => { // Retry on network errors, timeouts, and chunk loading failures return ( - error.name === 'ChunkLoadError' || - error.name === 'NetworkError' || - error.message.includes('Loading chunk') || - error.message.includes('timeout') || - error.message.includes('Failed to fetch') + RETRY_ERROR_NAMES.includes(error.name) || + RETRY_ERROR_MESSAGES.some((msg) => error.message.includes(msg)) ); }, }; @@ -35,6 +36,7 @@ const DEFAULT_CONFIG: ChunkRetryConfig = { class ChunkRetryManager { private config: ChunkRetryConfig; private retryAttempts: Map = new Map(); + private finalStats: Map = new Map(); constructor(config: Partial = {}) { this.config = { ...DEFAULT_CONFIG, ...config }; @@ -50,12 +52,19 @@ class ChunkRetryManager { ): Promise { try { const result = await loadFn(); + // Clear successful attempts from tracking this.retryAttempts.delete(chunkId); return result; } catch (error) { const err = error as Error; if (!this.config.retryOn(err) || attempt >= this.config.maxRetries) { + // Store final stats before clearing from active tracking + const finalAttempts = this.retryAttempts.get(chunkId) || 0; + if (finalAttempts > 0) { + this.finalStats.set(chunkId, finalAttempts); + } + this.retryAttempts.delete(chunkId); throw error; } @@ -83,7 +92,17 @@ class ChunkRetryManager { * Get retry statistics for monitoring */ getRetryStats(): Record { - return Object.fromEntries(this.retryAttempts.entries()); + return { + ...Object.fromEntries(this.retryAttempts.entries()), + ...Object.fromEntries(this.finalStats.entries()), + }; + } + + /** + * Clear old statistics to prevent memory leaks + */ + clearOldStats(): void { + this.finalStats.clear(); } } @@ -98,8 +117,9 @@ if (typeof window !== 'undefined') { const originalDynamicImport = webpackWindow.__webpack_require__?.e; if (originalDynamicImport && webpackWindow.__webpack_require__) { - webpackWindow.__webpack_require__.e = function (chunkId: string) { - const loadChunk = () => originalDynamicImport.call(this, chunkId); + const webpackReq = webpackWindow.__webpack_require__; + webpackWindow.__webpack_require__.e = (chunkId: string) => { + const loadChunk = () => originalDynamicImport.call(webpackReq, chunkId); return chunkRetryManager.retryChunkLoad(`chunk-${chunkId}`, loadChunk); };