From a832c55d4fe7016448beb953c7c9a3ce2064f11e Mon Sep 17 00:00:00 2001 From: sidmohanty11 Date: Thu, 18 Jun 2026 15:02:08 +0200 Subject: [PATCH] attempt to fix route readiness issue --- .changeset/actions-cold-start-404.md | 14 +++ .../server/framework-request-handler.spec.ts | 99 +++++++++++++++++++ .../src/server/framework-request-handler.ts | 16 +++ 3 files changed, 129 insertions(+) create mode 100644 .changeset/actions-cold-start-404.md diff --git a/.changeset/actions-cold-start-404.md b/.changeset/actions-cold-start-404.md new file mode 100644 index 0000000000..2a18469c75 --- /dev/null +++ b/.changeset/actions-cold-start-404.md @@ -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. diff --git a/packages/core/src/server/framework-request-handler.spec.ts b/packages/core/src/server/framework-request-handler.spec.ts index f728760872..e695b60702 100644 --- a/packages/core/src/server/framework-request-handler.spec.ts +++ b/packages/core/src/server/framework-request-handler.spec.ts @@ -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 => { + 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((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((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(); diff --git a/packages/core/src/server/framework-request-handler.ts b/packages/core/src/server/framework-request-handler.ts index c5f866393e..d1aec372e0 100644 --- a/packages/core/src/server/framework-request-handler.ts +++ b/packages/core/src/server/framework-request-handler.ts @@ -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;