Skip to content

Commit 8ecbf62

Browse files
kdaviduikclaude
andcommitted
feat: replace string matching with error code propagation for prebundle detection
Eliminates the fragile dependency on Vite's internal error message text ("new version of the pre-bundle") by propagating the structured error code through the HTTP boundary between server-middleware and worker-entry. Vite's throwOutdatedRequest sets error.code = 'ERR_OUTDATED_OPTIMIZED_DEP' (packages/vite/src/shared/constants.ts), but this code was being dropped when server-middleware serialized only error.message. Now: 1. server-middleware.ts — includes error.code in the JSON error response 2. worker-entry.ts fetchModuleWithRetry — parses and propagates the code from the JSON body onto the thrown Error, including through the final "all attempts exhausted" wrapper error 3. isPrebundleVersionMismatch — checks error.code instead of string matching The error code 'ERR_OUTDATED_OPTIMIZED_DEP' is defined as a constant in our code (not imported from Vite), is stable since Vite 2.9, and is used by Vite's own transform middleware — changing it would break Vite itself. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 14d2f44 commit 8ecbf62

File tree

3 files changed

+87
-27
lines changed

3 files changed

+87
-27
lines changed

packages/mini-oxygen/src/vite/server-middleware.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,12 @@ export function setupOxygenMiddleware(
201201
.catch((error) => {
202202
console.error('Error during module fetch:', error);
203203
res.writeHead(500, {'Content-Type': 'application/json'});
204-
res.end(JSON.stringify({error: String(error?.message ?? error)}));
204+
res.end(
205+
JSON.stringify({
206+
error: String(error?.message ?? error),
207+
...(error?.code && {code: error.code}),
208+
}),
209+
);
205210
});
206211
} else {
207212
res.writeHead(400, {'Content-Type': 'text/plain'});

packages/mini-oxygen/src/vite/worker-entry.test.ts

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,33 +20,28 @@ import {
2020
// isPrebundleVersionMismatch
2121
// ---------------------------------------------------------------------------
2222
describe('isPrebundleVersionMismatch', () => {
23-
it('detects the Vite prebundle invalidation message', () => {
24-
const error = new Error(
25-
'There is a new version of the pre-bundle for "react"',
26-
);
23+
it('detects errors with the Vite outdated dep error code', () => {
24+
const error = new Error('anything') as any;
25+
error.code = 'ERR_OUTDATED_OPTIMIZED_DEP';
2726
expect(isPrebundleVersionMismatch(error)).toBe(true);
2827
});
2928

30-
it('returns false for unrelated errors', () => {
29+
it('returns false for errors without the code', () => {
3130
expect(isPrebundleVersionMismatch(new Error('Module not found'))).toBe(
3231
false,
3332
);
3433
expect(isPrebundleVersionMismatch(new Error('syntax error'))).toBe(false);
3534
});
3635

37-
it('falls back to error.stack when message is nullish', () => {
38-
// The function uses `??` (nullish coalescing) — only null/undefined
39-
// triggers the stack fallback, not empty string.
40-
const error = {
41-
message: undefined,
42-
stack: 'Error: new version of the pre-bundle detected\n at foo.ts:1',
43-
} as unknown as Error;
44-
expect(isPrebundleVersionMismatch(error)).toBe(true);
36+
it('returns false for errors with a different code', () => {
37+
const error = new Error('some error') as any;
38+
error.code = 'ERR_SOMETHING_ELSE';
39+
expect(isPrebundleVersionMismatch(error)).toBe(false);
4540
});
4641

47-
it('handles fully nullish error properties gracefully', () => {
48-
const error = {message: undefined, stack: undefined} as unknown as Error;
49-
expect(isPrebundleVersionMismatch(error)).toBe(false);
42+
it('handles nullish error gracefully', () => {
43+
expect(isPrebundleVersionMismatch(null as any)).toBe(false);
44+
expect(isPrebundleVersionMismatch(undefined as any)).toBe(false);
5045
});
5146
});
5247

@@ -211,6 +206,46 @@ describe('fetchModuleWithRetry', () => {
211206
).rejects.toThrow(/connection refused/);
212207
});
213208

209+
it('propagates error code from JSON error response', async () => {
210+
const errorBody = JSON.stringify({
211+
error: 'There is a new version of the pre-bundle for "react"',
212+
code: 'ERR_OUTDATED_OPTIMIZED_DEP',
213+
});
214+
mockFetch.mockResolvedValueOnce(
215+
new Response(errorBody, {
216+
status: 500,
217+
headers: {'Content-Type': 'application/json'},
218+
}),
219+
);
220+
// All attempts fail with the same error
221+
mockFetch.mockResolvedValueOnce(new Response(errorBody, {status: 500}));
222+
mockFetch.mockResolvedValueOnce(new Response(errorBody, {status: 500}));
223+
224+
try {
225+
await fetchModuleWithRetry(
226+
new URL('http://localhost:5173/__vite_fetch_module?id=test'),
227+
);
228+
expect.unreachable('should have thrown');
229+
} catch (error: any) {
230+
expect(error.code).toBe('ERR_OUTDATED_OPTIMIZED_DEP');
231+
}
232+
});
233+
234+
it('handles non-JSON error bodies without propagating code', async () => {
235+
mockFetch.mockResolvedValueOnce(textResponse('plain text error', 500));
236+
mockFetch.mockResolvedValueOnce(textResponse('plain text error', 500));
237+
mockFetch.mockResolvedValueOnce(textResponse('plain text error', 500));
238+
239+
try {
240+
await fetchModuleWithRetry(
241+
new URL('http://localhost:5173/__vite_fetch_module?id=test'),
242+
);
243+
expect.unreachable('should have thrown');
244+
} catch (error: any) {
245+
expect(error.code).toBeUndefined();
246+
}
247+
});
248+
214249
it('delegates to fetchWithTimeout which wires AbortSignal', async () => {
215250
mockFetch.mockResolvedValueOnce(jsonResponse({code: 'ok'}));
216251

packages/mini-oxygen/src/vite/worker-entry.ts

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ export interface ViteEnv {
3333

3434
const O2_PREFIX = '[o2:runtime]';
3535

36+
// Vite's dep optimizer sets this code on errors thrown when a prebundled
37+
// dependency is invalidated mid-request. Defined in Vite's source at
38+
// packages/vite/src/shared/constants.ts — not part of Vite's public API,
39+
// but stable since Vite 2.9 and used by Vite's own transform middleware.
40+
const VITE_ERR_OUTDATED_OPTIMIZED_DEP = 'ERR_OUTDATED_OPTIMIZED_DEP';
41+
3642
const MODULE_FETCH_MAX_ATTEMPTS = 3;
3743
const MODULE_FETCH_BASE_DELAY_MS = 200;
3844
const MODULE_FETCH_TIMEOUT_MS = 10_000;
@@ -88,6 +94,16 @@ export async function fetchModuleWithRetry(url: URL) {
8894
`${O2_PREFIX} Module fetch failed (${res.status}): ${body}`,
8995
);
9096

97+
// Propagate error code from the server response (e.g.,
98+
// Vite's ERR_OUTDATED_OPTIMIZED_DEP) so callers can identify
99+
// specific error types without string matching on the message.
100+
try {
101+
const parsed = JSON.parse(body);
102+
if (parsed?.code) (error as any).code = parsed.code;
103+
} catch {
104+
// Body wasn't JSON — no code to propagate
105+
}
106+
91107
// Client errors (4xx) are deterministic — retrying won't help
92108
if (res.status < 500) throw error;
93109

@@ -106,10 +122,17 @@ export async function fetchModuleWithRetry(url: URL) {
106122
}
107123
}
108124

109-
throw new Error(
125+
const finalError = new Error(
110126
`${O2_PREFIX} Module fetch failed after ${MODULE_FETCH_MAX_ATTEMPTS} attempts: ` +
111127
(lastError?.message ?? 'unknown error'),
112128
);
129+
130+
// Preserve error code from the last attempt so callers can identify
131+
// specific error types (e.g., prebundle invalidation) after retries.
132+
const lastErrorCode = (lastError as any)?.code;
133+
if (lastErrorCode) (finalError as any).code = lastErrorCode;
134+
135+
throw finalError;
113136
}
114137

115138
export default {
@@ -314,16 +337,13 @@ function resetRuntime() {
314337
}
315338

316339
/**
317-
* Detects Vite's dep optimizer prebundle invalidation error.
318-
* This string comes from Vite's optimizer — see:
319-
* https://github.com/vitejs/vite/blob/v6.4.1/packages/vite/src/node/plugins/optimizedDeps.ts
320-
* (throwOutdatedRequest function).
321-
* If Vite changes this message, the recovery path silently stops working
322-
* and falls back to returning a 503 error page (same as before this fix).
323-
* Verify this string still exists after Vite upgrades.
340+
* Detects Vite's dep optimizer prebundle invalidation error by checking
341+
* the error code propagated from the server middleware. Vite's
342+
* `throwOutdatedRequest` sets `error.code = 'ERR_OUTDATED_OPTIMIZED_DEP'`
343+
* (see packages/vite/src/node/plugins/optimizedDeps.ts), and our
344+
* server-middleware preserves it in the JSON error response.
324345
* @internal Exported for unit testing — not part of the public API.
325346
*/
326347
export function isPrebundleVersionMismatch(error: Error): boolean {
327-
const message = error?.message ?? error?.stack ?? '';
328-
return message.includes('new version of the pre-bundle');
348+
return (error as any)?.code === VITE_ERR_OUTDATED_OPTIMIZED_DEP;
329349
}

0 commit comments

Comments
 (0)