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 .changeset/actions-cold-start-404.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@agent-native/core": patch
---

Fix intermittent 404s on `/_agent-native/actions/*` (and other framework routes)
on serverless deploys. Routes are registered inside an async plugin init that
Nitro v3 does not await, and the production Nitro dispatcher snapshots its
middleware list once at the start of h3's `handler()` — so the readiness-gate
middleware, which runs inside that snapshot, could await init yet still fall
through to a bare 404 (surfaced in the client as a `true` error toast) for a
request that arrived on a cold isolate. The readiness wait now also runs as a
Nitro `request` hook, which h3 awaits before route + middleware resolution, so
late-registered routes exist by the time routing happens. The existing
middleware gate is retained as a fallback.
99 changes: 99 additions & 0 deletions packages/core/src/server/framework-request-handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,105 @@ describe("framework request handler", () => {
expect(JSON.stringify(result)).toContain("initializing or unavailable");
});

// Models production-dispatcher ordering: h3 snapshots middleware once at the
// start of `handler()`, but awaits the `request` hook (onRequest) before that.
// The default `dispatch` helper re-reads `~middleware` per step, so only this
// harness can expose the snapshot race.
function createHookableNitroApp() {
const requestHooks: Array<(event: any) => unknown> = [];
return {
h3: { "~middleware": [] as any[] },
hooks: {
hook: (name: string, fn: (event: any) => unknown) => {
if (name === "request") requestHooks.push(fn);
},
},
__requestHooks: requestHooks,
};
}

async function dispatchProductionOrder(
nitroApp: any,
pathname: string,
opts: { runRequestHooks: boolean },
) {
const event = {
method: "GET",
url: new URL(`http://example.test${pathname}`),
path: pathname,
context: {},
res: { status: 200, headers: new Headers() },
};
// Nitro bridges the `request` hook to h3's `config.onRequest`, which h3
// awaits before `handler()`. When disabled we model the broken path: no
// pre-routing wait, so the snapshot is taken with whatever exists now.
if (opts.runRequestHooks) {
for (const fn of nitroApp.__requestHooks) await fn(event);
}
// handler(): snapshot the middleware list ONCE, then run that snapshot.
const snapshot = [...nitroApp.h3["~middleware"]];
let index = 0;
const next = async (): Promise<unknown> => {
const mw = snapshot[index++];
if (!mw) return { fellThrough: true };
return mw(event, next);
};
return next();
}

it("(bug) middleware-only gate falls through to 404 when the route registers after the snapshot", async () => {
const nitroApp = createHookableNitroApp();
let registerRoute!: () => void;
const ready = new Promise<void>((resolve) => {
registerRoute = () => {
getH3App(nitroApp).use(
"/_agent-native/actions/update-visual-plan",
() => ({ ok: true }),
);
resolve();
};
});
trackPluginInit(nitroApp, ready, { paths: ["/_agent-native/actions"] });

// Snapshot is taken before the route exists; init completes mid-flight.
const pending = dispatchProductionOrder(
nitroApp,
"/_agent-native/actions/update-visual-plan",
{ runRequestHooks: false },
);
await Promise.resolve();
registerRoute();

await expect(pending).resolves.toEqual({ fellThrough: true });
});

it("delivers a route registered during async init by waiting in the request hook (before the snapshot)", async () => {
const nitroApp = createHookableNitroApp();
let registerRoute!: () => void;
const ready = new Promise<void>((resolve) => {
registerRoute = () => {
getH3App(nitroApp).use(
"/_agent-native/actions/update-visual-plan",
() => ({ ok: true }),
);
resolve();
};
});
trackPluginInit(nitroApp, ready, { paths: ["/_agent-native/actions"] });

const pending = dispatchProductionOrder(
nitroApp,
"/_agent-native/actions/update-visual-plan",
{ runRequestHooks: true },
);
// Init completes while the request hook is awaiting readiness, before the
// middleware snapshot is taken.
await Promise.resolve();
registerRoute();

await expect(pending).resolves.toEqual({ ok: true });
});

it("does not treat similar non-prefixed paths as framework routes", async () => {
process.env.APP_BASE_PATH = "/docs";
const nitroApp = createNitroApp();
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/server/framework-request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,22 @@ export function getH3App(nitroApp: any): H3AppShim {
registerMiddleware(nitroApp, WELL_KNOWN_PREFIX, readinessGate, {
prepend: true,
});

// Primary gate: Nitro bridges this `request` hook to h3's `config.onRequest`,
// which h3 awaits BEFORE `handler()` snapshots middleware and resolves the
// route. The middleware gate above runs too late on production dispatchers —
// its await finishes after the snapshot, so a route registered during async
// init is missing from the request and 404s. The middleware gate stays as a
// fallback for runtimes where `onRequest` isn't wired.
nitroApp.hooks?.hook?.("request", async (event: H3Event) => {
const reqPath = event.url?.pathname ?? "";
if (
resolveMountMatch(reqPath, FRAMEWORK_PREFIX) ||
resolveMountMatch(reqPath, WELL_KNOWN_PREFIX)
) {
await awaitFrameworkRoutesReadyForRequest(nitroApp, reqPath);
}
});
}

return shim;
Expand Down
Loading