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..8fa4d4b --- /dev/null +++ b/src/utils/chunk-retry.ts @@ -0,0 +1,156 @@ +/** + * 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; + }; +} + +// 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, + maxDelay: 10000, + retryOn: (error: Error) => { + // Retry on network errors, timeouts, and chunk loading failures + return ( + RETRY_ERROR_NAMES.includes(error.name) || + RETRY_ERROR_MESSAGES.some((msg) => error.message.includes(msg)) + ); + }, +}; + +class ChunkRetryManager { + private config: ChunkRetryConfig; + private retryAttempts: Map = new Map(); + private finalStats: 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(); + // 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; + } + + 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()), + ...Object.fromEntries(this.finalStats.entries()), + }; + } + + /** + * Clear old statistics to prevent memory leaks + */ + clearOldStats(): void { + this.finalStats.clear(); + } +} + +// 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__) { + const webpackReq = webpackWindow.__webpack_require__; + webpackWindow.__webpack_require__.e = (chunkId: string) => { + const loadChunk = () => originalDynamicImport.call(webpackReq, 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 };