Skip to content

Commit 9b00720

Browse files
kdaviduikclaude
andcommitted
fix(mini-oxygen): add retry and prebundle recovery to module fetch transport
MiniOxygen's module fetch transport (workerd → Vite) had no error handling or retry logic. When Vite's fetchModule failed transiently under parallel load (multiple dev servers in e2e tests), the transport called res.json() on a plain-text 500 response, producing a confusing SyntaxError that surfaced as "MiniOxygen couldn't load your app's entry point." The root cause was twofold: 1. server-middleware.ts returned plain text on errors but the client assumed JSON — now returns JSON errors for protocol consistency. 2. Vite's dependency optimizer can invalidate pre-bundled deps mid-request ("There is a new version of the pre-bundle"). In a browser, Vite triggers a full page reload via HMR. But MiniOxygen runs with hmr:false, so there was no recovery path. This adds: - res.ok check before res.json() with 3-attempt retry for transient 5xx - ModuleRunner destruction + recreation on prebundle version mismatch, emulating the browser's full-page-reload recovery behavior - 500ms delay before retry to let the optimizer finish re-bundling Eliminates the "MiniOxygen couldn't load entry point" flakiness that appeared after parallel Playwright workers were enabled in #3509. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 5606796 commit 9b00720

File tree

3 files changed

+94
-18
lines changed

3 files changed

+94
-18
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@shopify/mini-oxygen": patch
3+
---
4+
5+
Fix intermittent "MiniOxygen couldn't load your app's entry point" errors during development by adding retry logic and recovery from Vite's dependency optimizer cache invalidation in the module fetch transport

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,8 +200,8 @@ export function setupOxygenMiddleware(
200200
.then((ssrModule) => res.end(JSON.stringify(ssrModule)))
201201
.catch((error) => {
202202
console.error('Error during module fetch:', error);
203-
res.writeHead(500, {'Content-Type': 'text/plain'});
204-
res.end('Internal server error');
203+
res.writeHead(500, {'Content-Type': 'application/json'});
204+
res.end(JSON.stringify({error: String(error?.message ?? error)}));
205205
});
206206
} else {
207207
res.writeHead(400, {'Content-Type': 'text/plain'});

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

Lines changed: 87 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,41 @@ export interface ViteEnv {
3333

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

36+
const MODULE_FETCH_MAX_ATTEMPTS = 3;
37+
const MODULE_FETCH_BASE_DELAY_MS = 200;
38+
39+
/**
40+
* Fetches a module from Vite's dev server with retry logic.
41+
* Vite's fetchModule can fail transiently under parallel load
42+
* (multiple dev servers running concurrently in e2e tests).
43+
*/
44+
async function fetchModuleWithRetry(url: URL) {
45+
for (let attempt = 1; attempt <= MODULE_FETCH_MAX_ATTEMPTS; attempt++) {
46+
const res = await fetch(url);
47+
48+
if (res.ok) {
49+
return {result: res.json()};
50+
}
51+
52+
// On the last attempt, report the failure with context
53+
if (attempt === MODULE_FETCH_MAX_ATTEMPTS) {
54+
const body = await res.text();
55+
throw new Error(
56+
`${O2_PREFIX} Module fetch failed after ${MODULE_FETCH_MAX_ATTEMPTS} attempts ` +
57+
`(${res.status}): ${body}`,
58+
);
59+
}
60+
61+
// Retry with linear backoff for transient server errors
62+
await new Promise((resolve) =>
63+
setTimeout(resolve, attempt * MODULE_FETCH_BASE_DELAY_MS),
64+
);
65+
}
66+
67+
// Unreachable, but satisfies TypeScript
68+
throw new Error(`${O2_PREFIX} Module fetch exhausted retries`);
69+
}
70+
3671
export default {
3772
/**
3873
* Worker entry module that wraps the user app's entry module.
@@ -90,6 +125,42 @@ let runtime: ModuleRunner;
90125
* @returns The app's entry module.
91126
*/
92127
function fetchEntryModule(publicUrl: URL, env: ViteEnv) {
128+
return importEntryModule(publicUrl, env)
129+
.catch(async (error: Error) => {
130+
// Vite's optimizer can invalidate pre-bundled deps mid-request
131+
// (e.g. "There is a new version of the pre-bundle"). In a browser
132+
// Vite would trigger a full-page reload via HMR, but MiniOxygen
133+
// runs with hmr:false. Recreate the ModuleRunner and retry once
134+
// so the current request picks up the updated version hashes.
135+
if (isPrebundleVersionMismatch(error)) {
136+
resetRuntime();
137+
// Give Vite's optimizer time to finish re-bundling before retrying
138+
await new Promise((resolve) => setTimeout(resolve, 500));
139+
return importEntryModule(publicUrl, env);
140+
}
141+
142+
throw error;
143+
})
144+
.catch((error: Error) => {
145+
// If retry also failed (or error was not a version mismatch),
146+
// reset runtime for the next request and return error page.
147+
if (isPrebundleVersionMismatch(error)) {
148+
resetRuntime();
149+
}
150+
151+
return {
152+
errorResponse: new globalThis.Response(
153+
error?.stack ?? error?.message ?? 'Internal error',
154+
{
155+
status: 503,
156+
statusText: 'executeEntrypoint error',
157+
},
158+
),
159+
};
160+
});
161+
}
162+
163+
function importEntryModule(publicUrl: URL, env: ViteEnv) {
93164
if (!runtime) {
94165
runtime = new ModuleRunner(
95166
{
@@ -106,7 +177,7 @@ function fetchEntryModule(publicUrl: URL, env: ViteEnv) {
106177
if (customData.data)
107178
url.searchParams.set('importer', customData.name);
108179

109-
return fetch(url).then((res) => ({result: res.json()}));
180+
return fetchModuleWithRetry(url);
110181
}
111182
return Promise.resolve({
112183
error: `Error - invoke: ${JSON.stringify(data)}`,
@@ -164,19 +235,19 @@ function fetchEntryModule(publicUrl: URL, env: ViteEnv) {
164235
);
165236
}
166237

167-
return (
168-
runtime.import(env.__VITE_RUNTIME_EXECUTE_URL) as Promise<{
169-
default: {fetch: ExportedHandlerFetchHandler};
170-
}>
171-
).catch((error: Error) => {
172-
return {
173-
errorResponse: new globalThis.Response(
174-
error?.stack ?? error?.message ?? 'Internal error',
175-
{
176-
status: 503,
177-
statusText: 'executeEntrypoint error',
178-
},
179-
),
180-
};
181-
});
238+
return runtime.import(env.__VITE_RUNTIME_EXECUTE_URL) as Promise<{
239+
default: {fetch: ExportedHandlerFetchHandler};
240+
}>;
241+
}
242+
243+
function resetRuntime() {
244+
if (runtime) {
245+
runtime.close().catch(() => {});
246+
runtime = undefined!;
247+
}
248+
}
249+
250+
function isPrebundleVersionMismatch(error: Error): boolean {
251+
const message = error?.message ?? error?.stack ?? '';
252+
return message.includes('new version of the pre-bundle');
182253
}

0 commit comments

Comments
 (0)