Skip to content
Open
28 changes: 28 additions & 0 deletions .changeset/bump-nitro-beta.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"@agent-native/core": patch
---

Bump nitro to 3.0.260610-beta to address a dev-server cold-start race where the
Nitro Vite worker could be hit before its entry module finished importing,
surfacing as `Vite environment "nitro" is unavailable` / `UND_ERR_SOCKET`.

Also raises the `jiti` dependency floor to `^2.7.0` to satisfy the new Nitro
beta's peer requirement for downstream consumers of the published package.

Fixes a dev-only 404 for extension-bearing framework endpoints such as
`/_agent-native/speculation-rules.json` and `/.well-known/agent-card.json`.
Nitro's Vite dev middleware classifies any request whose path has an asset-like
extension as a static asset (handing it to Vite) unless a Nitro _route_ matches
it. Framework endpoints are registered as h3 middleware, invisible to Nitro's
route table, so their `.json`/`.png` URLs were misrouted to Vite and 404'd
before reaching the server (extensionless routes like `/ping` were unaffected).
The framework now adds a dev Vite middleware that marks `/_agent-native/*` and
framework `/.well-known/*` requests as dynamic so Nitro's dev handler serves
them. Production builds don't run this heuristic and were never affected.

Also hardens the async-plugin cold-start path: h3 snapshots its middleware list
once per request (inside `handler()`), so a route registered by an async plugin
after that snapshot can 404 on the first request. The framework patches
`h3.config.onRequest` to await default-plugin bootstrap and tracked plugin inits
before the snapshot, so late-registered framework routes dispatch naturally on
every runtime (the dev stub wires neither Nitro hooks nor `onRequest`).
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@
"react-dom": "19.2.7",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"rollup": ">=4.59.0",
"rollup": ">=4.61.1",
"jiti": ">=2.7.0",
"multer": ">=2.1.1",
"minimatch": ">=9.0.7",
"undici": ">=7.24.0",
Expand Down
4 changes: 2 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -222,15 +222,15 @@
"h3": "^2.0.1-rc.20",
"highlight.js": "^11.11.1",
"isbot": "^5",
"jiti": "^2.6.1",
"jiti": "^2.7.0",
"jose": "^6.2.2",
"kiwi-schema": "^0.5.0",
"linkedom": "0.18.12",
"lowlight": "^3.3.0",
"minimatch": "^10.0.0",
"nanoid": "^5.1.9",
"next-themes": "^0.4.6",
"nitro": "3.0.260415-beta",
"nitro": "3.0.260610-beta",
Comment thread
builder-io-integration[bot] marked this conversation as resolved.
"p-limit": "^7.3.0",
"pako": "^2.1.0",
"prettier": "^3.8.3",
Expand Down
77 changes: 77 additions & 0 deletions packages/core/src/server/framework-request-handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,31 @@ describe("framework request handler", () => {
return next();
}

// Faithful model of h3 core's `~request()`: it awaits `config.onRequest`
// BEFORE `handler()` snapshots the middleware list. This is the gate that
// works on every runtime, including the dev stub that wires neither Nitro
// hooks nor its own onRequest.
async function dispatchViaH3Request(nitroApp: any, pathname: string) {
const event = {
method: "GET",
url: new URL(`http://example.test${pathname}`),
path: pathname,
context: {},
res: { status: 200, headers: new Headers() },
};
const onRequest = nitroApp.h3?.config?.onRequest;
if (typeof onRequest === "function") await onRequest(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;
Expand Down Expand Up @@ -467,6 +492,58 @@ describe("framework request handler", () => {
await expect(pending).resolves.toEqual({ ok: true });
});

it("delivers a route registered during async init via the h3 onRequest gate (dev runtime, no Nitro hooks)", async () => {
// Plain nitroApp with no `hooks` — models Nitro's dev stub
// (`new H3Core({ onError })`, `hooks: undefined`). Only the
// `h3.config.onRequest` gate can rescue this path.
const nitroApp = createNitroApp();
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 = dispatchViaH3Request(
nitroApp,
"/_agent-native/actions/update-visual-plan",
);
// Init completes while h3 awaits config.onRequest, before the snapshot.
await Promise.resolve();
registerRoute();

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

it("serves a late-registered speculation-rules route via the onRequest gate", async () => {
const nitroApp = createNitroApp();
let registerRoute!: () => void;
const ready = new Promise<void>((resolve) => {
registerRoute = () => {
getH3App(nitroApp).use("/_agent-native/speculation-rules.json", () => ({
prefetch: [],
prerender: [],
}));
resolve();
};
});
trackPluginInit(nitroApp, ready, { paths: ["/_agent-native"] });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Broad tracked prefix can shadow unrelated healthy framework routes

Tracking this init under /_agent-native means the existing 503 failure fallback will match every sibling route under that prefix if init rejects once. In practice a transient core-routes failure can start returning 503 for already-healthy endpoints like /_agent-native/mcp or /_agent-native/agent-chat, so the failure handling needs narrower ownership or path scoping.

Additional Info
Independently reproduced against the current branch by registering a healthy `/_agent-native/agent-chat` handler, then calling `trackPluginInit(nitroApp, rejectedPromise, { paths: ["/_agent-native"] })` and dispatching `/_agent-native/agent-chat`; the placeholder returned `{ error: "agent-native route is initializing or unavailable: boom" }` with HTTP 503 instead of the healthy handler. Root cause is the unchanged placeholder logic in framework-request-handler.ts that matches failures by prefix via `resolveMountMatch(reqPath, failedPath)` before falling through.

Fix in Builder


const pending = dispatchViaH3Request(
nitroApp,
"/_agent-native/speculation-rules.json",
);
await Promise.resolve();
registerRoute();

await expect(pending).resolves.toEqual({ prefetch: [], prerender: [] });
});

it("does not treat similar non-prefixed paths as framework routes", async () => {
process.env.APP_BASE_PATH = "/docs";
const nitroApp = createNitroApp();
Expand Down
70 changes: 60 additions & 10 deletions packages/core/src/server/framework-request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const PLUGIN_FAILED_KEY = "_agentNativePluginInitFailures";
const PROVIDED_PLUGIN_STEMS_KEY = "_agentNativeProvidedPluginStems";
const MIDDLEWARE_DISPATCHER_PATCHED_KEY =
"_agentNativeMiddlewareDispatcherPatched";
const ONREQUEST_GATE_PATCHED_KEY = "_agentNativeOnRequestGatePatched";

interface PluginReadyEntry {
promise: Promise<void>;
Expand Down Expand Up @@ -110,6 +111,7 @@ export function markDefaultPluginProvided(nitroApp: any, stem: string): void {
export function getH3App(nitroApp: any): H3AppShim {
if (!nitroApp) throw new Error("getH3App: nitroApp is required");
ensureGlobalMiddlewareDispatch(nitroApp);
ensureReadinessOnRequest(nitroApp);

// Reuse the cached shim if we've wrapped this nitroApp before
const cached = nitroApp[APP_SHIM_KEY] as H3AppShim | undefined;
Expand All @@ -130,6 +132,7 @@ export function getH3App(nitroApp: any): H3AppShim {

if (!BOOTSTRAPPED.has(nitroApp)) {
BOOTSTRAPPED.add(nitroApp);

nitroApp[BOOTSTRAP_PROMISE_KEY] = bootstrapDefaultPlugins(nitroApp).catch(
(err) => {
console.warn(
Expand All @@ -143,10 +146,12 @@ export function getH3App(nitroApp: any): H3AppShim {
},
);

// Readiness gate: Nitro v3 doesn't await async plugins, so routes
// registered inside an async plugin may not exist when the first
// request arrives. These middleware entries hold framework routes
// until default-plugin bootstrap and tracked plugin inits complete.
// Fallback readiness gate (middleware). The primary gate is the
// `h3.config.onRequest` hook installed by `ensureReadinessOnRequest`, which
// awaits readiness BEFORE h3 snapshots the middleware list. This middleware
// gate can't fix the snapshot race on its own (it runs after the snapshot is
// taken), but it's kept for any runtime that dispatches without honoring
// `config.onRequest`, and it carries the per-plugin init-failure 503 logic.
const readinessGate = (async (event: H3Event) => {
const eventAny = event as any;
await awaitFrameworkRoutesReadyForRequest(
Expand All @@ -163,12 +168,10 @@ export function getH3App(nitroApp: any): H3AppShim {
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.
// Some Nitro presets bridge their own `request` hook to `config.onRequest`.
// Register here too so readiness is awaited on those runtimes even before
// `ensureReadinessOnRequest` patches h3 directly. Idempotent: both await the
// same readiness promises.
nitroApp.hooks?.hook?.("request", async (event: H3Event) => {
const reqPath = event.url?.pathname ?? "";
if (
Expand Down Expand Up @@ -223,6 +226,53 @@ function ensureGlobalMiddlewareDispatch(nitroApp: any): void {
h3[MIDDLEWARE_DISPATCHER_PATCHED_KEY] = wrappedGetMiddleware;
}

/**
* Primary readiness gate.
*
* Nitro v3 calls plugins synchronously and does not await async plugin init, so
* routes registered inside an async plugin (e.g. core-routes) may not exist yet
* when the first request arrives. h3 reads its middleware list exactly once per
* request — inside `handler()`, via `~getMiddleware()` — so a route registered
* after that snapshot is invisible to the in-flight request and 404s.
*
* h3's `~request()` awaits `config.onRequest` BEFORE calling `handler()` (see
* h3 core: `hookRes.then(() => this.handler(event))`). By patching
* `config.onRequest` to await framework readiness, we guarantee async routes
* are registered before the snapshot is taken — so they dispatch naturally, in
* order, with no per-route eager registration or post-snapshot re-dispatch.
*
* Nitro's production builds wire `config.onRequest` to their own request hook;
* the dev runtime stub does not (it constructs `new H3Core({ onError })` with
* `hooks: undefined`). Patching h3 directly closes the gap on every runtime.
*/
function ensureReadinessOnRequest(nitroApp: any): void {
const h3 = nitroApp?.h3;
if (!h3) return;
const config = h3.config ?? (h3.config = {});
if (
config.onRequest &&
config.onRequest === config[ONREQUEST_GATE_PATCHED_KEY]
)
return;

const previous =
typeof config.onRequest === "function" ? config.onRequest : undefined;

const patched = async (event: H3Event) => {
if (previous) await previous(event);
const reqPath = event.url?.pathname ?? "";
if (
resolveMountMatch(reqPath, FRAMEWORK_PREFIX) ||
resolveMountMatch(reqPath, WELL_KNOWN_PREFIX)
) {
await awaitFrameworkRoutesReadyForRequest(nitroApp, reqPath);
}
};

config.onRequest = patched;
config[ONREQUEST_GATE_PATCHED_KEY] = patched;
}

/**
* Wait for the framework's default-plugin bootstrap to complete.
*
Expand Down
57 changes: 57 additions & 0 deletions packages/core/src/vite/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
_getReactRouterAliases,
defineConfig,
isFrameworkDevPath,
isFrameworkDynamicDevPath,
stripMountedDevApiPath,
} from "./client.js";
import { signEmbedSessionToken } from "../server/embed-session.js";
Expand Down Expand Up @@ -60,6 +61,62 @@ describe("dev server mounted path helpers", () => {
);
});

it("treats framework + well-known paths as dynamic for the dev forwarder", () => {
// Extension-bearing framework endpoints that Nitro's dev asset heuristic
// would otherwise hand to Vite (→ 404) must be marked dynamic.
expect(
isFrameworkDynamicDevPath("/_agent-native/speculation-rules.json", "/"),
).toBe(true);
expect(isFrameworkDynamicDevPath("/.well-known/agent-card.json", "/")).toBe(
true,
);
expect(
isFrameworkDynamicDevPath(
"/docs/_agent-native/speculation-rules.json",
"/docs/",
),
).toBe(true);
expect(
isFrameworkDynamicDevPath("/docs/.well-known/agent-card.json", "/docs/"),
).toBe(true);
// Real Vite/static assets must NOT be forwarded.
expect(isFrameworkDynamicDevPath("/assets/logo.png", "/")).toBe(false);
expect(isFrameworkDynamicDevPath("/favicon.ico", "/")).toBe(false);
});

it("forces Nitro's dev classifier to treat framework assets as dynamic", () => {
const plugin = findPlugin("agent-native-framework-dev-dynamic-forwarder");
let middleware: Function | null = null;
const server = {
config: { base: "/" },
middlewares: {
use: vi.fn((fn: Function) => {
middleware = fn;
}),
},
};
plugin.configureServer(server as any);
expect(typeof middleware).toBe("function");

const req: any = {
url: "/_agent-native/speculation-rules.json",
headers: { accept: "application/json", "sec-fetch-dest": "empty" },
};
const next = vi.fn();
(middleware as unknown as Function)(req, {}, next);
expect(req.headers.accept).toContain("text/html");
expect(req.headers["sec-fetch-dest"]).toBe("empty");
expect(next).toHaveBeenCalledOnce();

// Non-framework asset requests are left untouched.
const assetReq: any = {
url: "/assets/logo.png",
headers: { accept: "image/png" },
};
(middleware as unknown as Function)(assetReq, {}, vi.fn());
expect(assetReq.headers.accept).toBe("image/png");
});

it("serves base-prefixed Vite module requests for embed sessions", async () => {
process.env.OAUTH_STATE_SECRET = "vite-embed-test-secret";
const plugin = findPlugin("agent-native-base-redirect-guard");
Expand Down
Loading