Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' });

Expand Down
109 changes: 109 additions & 0 deletions src/utils/__tests__/chunk-retry.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
156 changes: 156 additions & 0 deletions src/utils/chunk-retry.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;
};
}

// 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<string, number> = new Map();
private finalStats: Map<string, number> = new Map();

constructor(config: Partial<ChunkRetryConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
}

/**
* Retry chunk loading with exponential backoff
*/
async retryChunkLoad(
chunkId: string,
loadFn: () => Promise<unknown>,
attempt: number = 0,
): Promise<unknown> {
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<string, number> {
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 };