@nifrajs/web 2.11.0 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/build-vite.d.ts.map +1 -1
  2. package/dist/build-vite.js +10 -0
  3. package/dist/build-vite.js.map +1 -1
  4. package/dist/build.d.ts +7 -0
  5. package/dist/build.d.ts.map +1 -1
  6. package/dist/build.js +5 -2
  7. package/dist/build.js.map +1 -1
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +12 -7
  10. package/dist/client.js.map +1 -1
  11. package/dist/conformance.d.ts +1 -1
  12. package/dist/conformance.d.ts.map +1 -1
  13. package/dist/deferred.d.ts +1 -1
  14. package/dist/deferred.d.ts.map +1 -1
  15. package/dist/deferred.js +14 -3
  16. package/dist/deferred.js.map +1 -1
  17. package/dist/dev.d.ts.map +1 -1
  18. package/dist/dev.js +16 -0
  19. package/dist/dev.js.map +1 -1
  20. package/dist/fonts-google.d.ts +2 -2
  21. package/dist/fonts-google.d.ts.map +1 -1
  22. package/dist/fonts-google.js +12 -3
  23. package/dist/fonts-google.js.map +1 -1
  24. package/dist/index.d.ts +65 -117
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +60 -66
  27. package/dist/index.js.map +1 -1
  28. package/dist/isr.d.ts.map +1 -1
  29. package/dist/isr.js +4 -1
  30. package/dist/isr.js.map +1 -1
  31. package/dist/llms-txt.d.ts +3 -1
  32. package/dist/llms-txt.d.ts.map +1 -1
  33. package/dist/llms-txt.js +23 -20
  34. package/dist/llms-txt.js.map +1 -1
  35. package/dist/manifest.d.ts +5 -3
  36. package/dist/manifest.d.ts.map +1 -1
  37. package/dist/manifest.js.map +1 -1
  38. package/dist/navigation.d.ts +9 -0
  39. package/dist/navigation.d.ts.map +1 -1
  40. package/dist/navigation.js +8 -1
  41. package/dist/navigation.js.map +1 -1
  42. package/dist/render-seam.d.ts +61 -0
  43. package/dist/render-seam.d.ts.map +1 -0
  44. package/dist/render-seam.js +22 -0
  45. package/dist/render-seam.js.map +1 -0
  46. package/package.json +2 -2
  47. package/src/build-vite.ts +11 -0
  48. package/src/build.ts +18 -1
  49. package/src/client.ts +13 -7
  50. package/src/conformance.ts +1 -1
  51. package/src/deferred.ts +14 -2
  52. package/src/dev.ts +14 -0
  53. package/src/fonts-google.ts +14 -5
  54. package/src/index.ts +137 -156
  55. package/src/isr.ts +4 -1
  56. package/src/llms-txt.ts +24 -20
  57. package/src/manifest.ts +5 -3
  58. package/src/navigation.ts +14 -1
  59. package/src/render-seam.ts +76 -0
@@ -30,8 +30,8 @@ import type { LinkDescriptor } from "./manifest.ts"
30
30
  export interface GoogleFontOptions {
31
31
  /** Family name exactly as Google lists it, e.g. `"Inter"`, `"Open Sans"`, `"Roboto Mono"`. */
32
32
  readonly family: string
33
- /** Weights to request - numbers (`400`), numeric strings, a variable range (`"100 900"`), or the
34
- * keywords `"normal"`/`"bold"`. Defaults to `[400]`. */
33
+ /** Weights to request - numbers (`400`), numeric strings, a variable range (`"100..900"`, the
34
+ * CSS-style `"100 900"` also accepted), or the keywords `"normal"`/`"bold"`. Defaults to `[400]`. */
35
35
  readonly weights?: readonly (number | string)[]
36
36
  /** Styles to request. Defaults to `["normal"]`. */
37
37
  readonly styles?: readonly ("normal" | "italic")[]
@@ -144,7 +144,8 @@ function validateFamily(family: string): string {
144
144
  }
145
145
 
146
146
  /** Normalize a weight to the token Google expects: `400`, `"700"`, `normal`→`400`, `bold`→`700`, or a
147
- * variable range `"100 900"`. Rejects anything else (these land in the request URL). */
147
+ * variable range as `"100..900"` (also accepted spelled `"100 900"`). Rejects anything else (these
148
+ * land in the request URL). */
148
149
  function normalizeWeight(weight: number | string): string {
149
150
  if (typeof weight === "number") {
150
151
  if (!Number.isInteger(weight) || weight < 1 || weight > 1000) {
@@ -155,8 +156,16 @@ function normalizeWeight(weight: number | string): string {
155
156
  const w = weight.trim()
156
157
  if (w === "normal") return "400"
157
158
  if (w === "bold") return "700"
158
- // A single numeric weight, or a variable range like "100 900".
159
- if (/^\d{1,4}$/.test(w) || /^\d{1,4} \d{1,4}$/.test(w)) return w
159
+ if (/^\d{1,4}$/.test(w)) return w
160
+ // A variable range - Google's own "100..900" axis syntax or the CSS-style "100 900" spelling.
161
+ // Either way the URL token must be the DOTTED form; a literal space 400s at the CSS2 endpoint.
162
+ const range = /^(\d{1,4})(?:\.\.| )(\d{1,4})$/.exec(w)
163
+ if (range !== null) {
164
+ if (Number(range[1]) >= Number(range[2])) {
165
+ throw new Error(`loadGoogleFont: invalid weight range ${JSON.stringify(weight)} (min < max)`)
166
+ }
167
+ return `${range[1]}..${range[2]}`
168
+ }
160
169
  throw new Error(`loadGoogleFont: invalid weight ${JSON.stringify(weight)}`)
161
170
  }
162
171
 
package/src/index.ts CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  type BackendMountHandler,
14
14
  NIFRA_BACKEND_MOUNT,
15
15
  } from "@nifrajs/core/mount"
16
- import { server, type UrlParts, urlPartsOf } from "@nifrajs/core/server"
16
+ import { type ServerOptions, server, type UrlParts, urlPartsOf } from "@nifrajs/core/server"
17
17
  import {
18
18
  DEFERRED_ERROR_CODE,
19
19
  DEFERRED_RUNTIME,
@@ -48,6 +48,14 @@ import type {
48
48
  ScriptDescriptor,
49
49
  UnsafeScriptDescriptor,
50
50
  } from "./manifest.ts"
51
+ import type { RenderAdapter, RenderProps } from "./render-seam.ts"
52
+ import {
53
+ ACTION_GLOBAL,
54
+ DATA_GLOBAL,
55
+ LAYOUT_DATA_GLOBAL,
56
+ ROOT_ATTRIBUTE,
57
+ ROUTE_GLOBAL,
58
+ } from "./render-seam.ts"
51
59
  import {
52
60
  createMatcher,
53
61
  DATA_HEADER,
@@ -57,7 +65,6 @@ import {
57
65
  RETAIN_HEADER,
58
66
  REVALIDATE_HEADER,
59
67
  STATUS_HEADER,
60
- type Submission,
61
68
  } from "./router.ts"
62
69
  import { searchOf, searchOfChain } from "./search.ts"
63
70
 
@@ -117,6 +124,7 @@ export {
117
124
  type Manifest,
118
125
  type Meta,
119
126
  type MetaArgs,
127
+ type MetaDescriptor,
120
128
  type MetaInput,
121
129
  type RouteEntry,
122
130
  type RouteModule,
@@ -235,6 +243,18 @@ export {
235
243
  RenderAdapterConformanceError,
236
244
  type RenderAdapterConformanceFixture,
237
245
  } from "./conformance.ts"
246
+ export {
247
+ ACTION_GLOBAL,
248
+ DATA_GLOBAL,
249
+ LAYOUT_DATA_GLOBAL,
250
+ type RenderAdapter,
251
+ type RenderProps,
252
+ ROOT_ATTRIBUTE,
253
+ ROUTE_GLOBAL,
254
+ type SsrModuleLoader,
255
+ setSsrModuleLoader,
256
+ ssrModuleLoader,
257
+ } from "./render-seam.ts"
238
258
 
239
259
  // The search-derivation primitive: parse a URL query, validate it against a route's `searchSchema`
240
260
  // (fail-closed to defaults), or return the raw parsed query when there is none. Both the server (loader
@@ -242,142 +262,6 @@ export {
242
262
  // produce the identical value by construction. An adapter's `useSearch` binding reads its result.
243
263
  export { type SearchOf, searchOf, searchOfChain, serializeSearch } from "./search.ts"
244
264
 
245
- /** The data handed to a route component. Opaque to the core. `actionData` is the return of a
246
- * route `action` after a POST (absent on plain GETs). `pending` + `submission` are client-only
247
- * (absent on SSR): they drive **optimistic UI** - render from `submission.formData` while `pending`. */
248
- export interface RenderProps {
249
- readonly data: unknown
250
- /**
251
- * Per-layout loader data, aligned index-for-index with the layout prefix of `chain`.
252
- *
253
- * Absent when no layout in the chain exports a `loader`, so a page-only app is byte-identical to
254
- * before. An adapter that does not read it renders exactly as it did; one that does passes
255
- * `layoutData[i]` to `chain[i]`.
256
- */
257
- readonly layoutData?: readonly unknown[]
258
- readonly actionData?: unknown
259
- /** True while a client navigation or submit is in flight (client-only; absent/false on SSR). */
260
- readonly pending?: boolean
261
- /** The path a client navigation is transitioning TO while `pending` (client-only; absent on SSR and
262
- * when idle). Lets an adapter tell a `NavLink` whether its own target is the one loading. */
263
- readonly pendingPath?: string
264
- /** The in-flight client submit, for optimistic UI (client-only; absent on SSR + when idle). */
265
- readonly submission?: Submission
266
- /** The matched route's decoded path params (`/users/:id` → `{ id: "7" }`). Threaded identically on
267
- * SSR (from the request match) and on client navigation (from router state) so an adapter's
268
- * `useParams` reads the same value on both, with no hydration mismatch. Absent ⇒ no params (`{}`). */
269
- readonly params?: Readonly<Record<string, string>>
270
- /** The current URL's `pathname + search` (no hash - the hash never reaches the server). Threaded on
271
- * SSR and client alike so an adapter's `useLocation`/`useSearchParams` hydrate consistently. Absent
272
- * on adapters/callers that don't supply it (treated as `""`). */
273
- readonly path?: string
274
- /** The route's typed, validated search params (the value the loader also gets as `ctx.search`),
275
- * threaded to the adapter's `useSearch`. Derived identically on SSR (from the request) and on client
276
- * navigation (adapter mount) via the shared `searchOf`, so a component reading it hydrates with no
277
- * drift. Absent ⇒ no search context (`{}`). */
278
- readonly search?: Record<string, unknown>
279
- }
280
-
281
- /**
282
- * The seam every render adapter implements. New adapters should prove these invariants with
283
- * {@link assertRenderAdapterConformance}; framework-specific behavior remains locally tested.
284
- */
285
- export interface RenderAdapter {
286
- /**
287
- * Server: render a route's layout `chain` (outermost layout → page) to a **stream** of HTML
288
- * bytes, including the framework's hydration markers. The page (innermost) receives `props`
289
- * (the loader data); each layout wraps the child via its `children`. Returns (or resolves to)
290
- * a Web `ReadableStream<Uint8Array>`: a streaming renderer flushes the shell first and streams
291
- * Suspense boundaries as they resolve; a non-streaming renderer may return a one-chunk stream.
292
- * May be async - e.g. React resolves once the shell is renderable, so a shell-render throw can
293
- * still map to an error status before any byte is sent.
294
- */
295
- renderToStream(
296
- chain: readonly unknown[],
297
- props: RenderProps,
298
- ): ReadableStream<Uint8Array> | Promise<ReadableStream<Uint8Array>>
299
- /**
300
- * Server (optional): render the chain to a complete HTML **string** in one pass, with the same
301
- * hydration markers `renderToStream` produces. When provided, `renderPage` uses it for any page
302
- * that does **not** `defer()` - buffering the document is faster than the streaming pipeline there,
303
- * because the framework's synchronous renderer (React `renderToString`, Solid `renderToString`,
304
- * Preact `render`, Vue `renderToString`, Svelte `render`) is markedly cheaper than its streaming
305
- * renderer - most visibly on Bun, where React Fizz / Solid `renderToStream` are the heaviest. Pages
306
- * that `defer()` keep the streaming path (progressive `<Await>` needs it). Markup MUST be
307
- * hydration-equivalent to `renderToStream`; a throw surfaces like the streaming shell-readiness
308
- * await (the `_error` boundary maps it to a status). Omit it to always stream.
309
- */
310
- renderToString?(chain: readonly unknown[], props: RenderProps): string | Promise<string>
311
- /**
312
- * Server: per-document bootstrap markup injected into `<head>` that the client
313
- * `hydrate` requires (Solid: `generateHydrationScript()`). Empty string if none.
314
- */
315
- hydrationHead(nonce?: string): string
316
- }
317
-
318
- /**
319
- * Loads a module through the DEV SERVER'S module graph rather than the runtime's. Takes an absolute
320
- * file path or a bare specifier; resolution, compilation and caching are the dev server's.
321
- */
322
- export type SsrModuleLoader = (id: string) => Promise<unknown>
323
-
324
- /* A `globalThis` singleton for the same reason `@nifrajs/web-react`'s router context is one: in dev
325
- * this module is evaluated twice in one process (the CLI's copy under Bun, and the app's copy), and a
326
- * module-level `let` would leave the adapter reading a slot the dev server set on the other copy. */
327
- const SSR_MODULE_LOADER_SLOT = Symbol.for("nifra.web.ssr-module-loader")
328
- /* Explicitly `| undefined`: clearing the slot is part of the contract (a dev server clears it on
329
- * stop), and under `exactOptionalPropertyTypes` "absent" and "set to undefined" are separate types. */
330
- const loaderSlot = globalThis as { [SSR_MODULE_LOADER_SLOT]?: SsrModuleLoader | undefined }
331
-
332
- /**
333
- * Publishes the dev server's SSR module loader. Called by a dev server that owns SSR resolution
334
- * itself; pass `undefined` to clear it on shutdown.
335
- */
336
- export function setSsrModuleLoader(load: SsrModuleLoader | undefined): void {
337
- loaderSlot[SSR_MODULE_LOADER_SLOT] = load
338
- }
339
-
340
- /**
341
- * The dev server's SSR module loader, or `undefined` when nothing owns SSR resolution but the runtime
342
- * - the Bun dev pipeline, and every production build, where the adapter's assets are compiled ahead
343
- * of time and a plain `import` is already the right thing.
344
- *
345
- * An adapter that must load a COMPILED asset at runtime (a `.svelte` component, say) has to prefer
346
- * this over a bare `import`, and has to take the framework's SERVER RENDERER from it as well, not
347
- * just the component. A component compiled by the dev server renders through the renderer the dev
348
- * server resolved; mixing the two puts two copies of the framework runtime in one component tree,
349
- * where context written by one half is invisible to the other.
350
- */
351
- export function ssrModuleLoader(): SsrModuleLoader | undefined {
352
- return loaderSlot[SSR_MODULE_LOADER_SLOT]
353
- }
354
-
355
- /** Global the server serializes loader data into; the client reads it to hydrate. */
356
- export const DATA_GLOBAL = "__NIFRA_DATA__"
357
- /** Per-layout loader data for hydration. Emitted ONLY when some layout in the chain has a loader, so
358
- * a page-only app's HTML is byte-identical to before layout loaders existed. */
359
- export const LAYOUT_DATA_GLOBAL = "__NIFRA_LAYOUT_DATA__"
360
-
361
- /** Global the server writes the matched route id into; the client uses it to pick the chain. */
362
- export const ROUTE_GLOBAL = "__NIFRA_ROUTE__"
363
-
364
- /** Global the server serializes an action's data return into (absent on GETs); the client
365
- * reads it so hydration after a native form POST matches the server-rendered markup. */
366
- export const ACTION_GLOBAL = "__NIFRA_ACTION__"
367
-
368
- /**
369
- * Marker attribute on the app container, emitted ONLY when `rootId` is not the default.
370
- *
371
- * `rootId` is a per-RENDER option; the client entry is a per-BUILD artifact, so it cannot be
372
- * generated knowing which id a given render will use. The container therefore announces itself, and
373
- * the entry finds it by this attribute before falling back to `#root`. It has to be the DOM rather
374
- * than another `window.__NIFRA_*` global: a second copy of the id can drift from the markup, while
375
- * this is written by the same expression that writes the id and cannot.
376
- *
377
- * Absent on a default render, so an app that never sets `rootId` emits the same bytes it always has.
378
- */
379
- export const ROOT_ATTRIBUTE = "data-nifra-root"
380
-
381
265
  /**
382
266
  * Pre-hydration form guard - a tiny inline script flushed in `<head>` (it runs in the window between
383
267
  * first paint and the island bundle taking over). It neutralizes the one real hydration footgun: a
@@ -1028,6 +912,20 @@ function withDuplicateInstanceHint(err: unknown): unknown {
1028
912
  return augmented
1029
913
  }
1030
914
 
915
+ /**
916
+ * An action's `Response` passes straight through - except a redirect on a client-submit data
917
+ * request: fetch would follow the 3xx into HTML the client can't use, so the redirect rides the
918
+ * X-Nifra-Redirect header on a 204 and the client navigates. One conversion shared by the
919
+ * returned- and thrown-Response paths, so `return redirect()` and `throw redirect()` agree.
920
+ */
921
+ function actionResponse(response: Response, isDataRequest: boolean): Response {
922
+ if (isDataRequest && response.status >= 300 && response.status < 400) {
923
+ const location = response.headers.get("location") ?? "/"
924
+ return new Response(null, { status: 204, headers: { [REDIRECT_HEADER]: location } })
925
+ }
926
+ return response
927
+ }
928
+
1031
929
  /** A loaded layout module. `loader`/`gate` are the layout-loader surface; `meta` predates it. */
1032
930
  type LoadedLayoutModules = ReadonlyArray<{
1033
931
  default: unknown
@@ -1414,13 +1312,67 @@ export function unsafeInlineScript(
1414
1312
  }
1415
1313
  }
1416
1314
 
1417
- export interface CreateWebAppOptions {
1315
+ export interface CreateWebAppOptions<Env = unknown> {
1418
1316
  readonly adapter: RenderAdapter
1419
1317
  readonly manifest: Manifest
1420
1318
  /** URL of the built client entry (module script) injected into every page. */
1421
1319
  readonly clientEntry: string
1422
1320
  /** Default document title for all pages. */
1423
1321
  readonly title?: string
1322
+ /**
1323
+ * Options for the underlying `server()` - `requestTimeoutMs`, `admission`, `gracefulSignals`, and
1324
+ * the rest of {@link ServerOptions}.
1325
+ *
1326
+ * Needed because the `Server` is constructed in here, so a caller has no other way to reach its
1327
+ * constructor: a page app could set no request timeout and no capacity gate, which are exactly the
1328
+ * two knobs a production readiness check looks for. An SSR app wants them MORE than a backend does -
1329
+ * a render is slow and allocation-heavy, so a burst that a JSON API would absorb is the one that
1330
+ * exhausts the pod.
1331
+ *
1332
+ * Applies to the whole app, {@link mounts} and the {@link api} auto-mount included: the capacity gate
1333
+ * sits at the fetch entry, ahead of the request hooks those are dispatched from.
1334
+ */
1335
+ readonly server?: ServerOptions
1336
+ /**
1337
+ * Runs against the app BEFORE any page route is registered - the seam for `securityHeaders()`,
1338
+ * `requestId()`, `logger()`, a rate limit, or anything else applied with `use`.
1339
+ *
1340
+ * ```ts
1341
+ * createWebApp({
1342
+ * …,
1343
+ * use: (app) => {
1344
+ * app.use(securityHeaders())
1345
+ * app.use(requestId())
1346
+ * },
1347
+ * })
1348
+ * ```
1349
+ *
1350
+ * A callback rather than an array because `use` is overloaded - `Middleware`, `IdentityPlugin`,
1351
+ * `ContextPlugin` - and only the real call site instantiates the right overload. The return value
1352
+ * is ignored: this is for cross-cutting concerns, not for declaring routes (declare those on the
1353
+ * returned app, where they get their types).
1354
+ *
1355
+ * **Why the timing matters.** `beforeHandle`, `afterHandle`, `around`, `derive`/`decorate`, and
1356
+ * `onError` are snapshotted into each route AS IT IS DECLARED, and `createWebApp` declares every
1357
+ * page (plus the `/*` catch-all) before it returns - so a caller's `app.use(…)` afterwards binds
1358
+ * them to nothing, silently. `requestId()` is exactly that case: it is a `derive`, so applied late
1359
+ * it leaves every page without `c.requestId`. Route assurance evidence is order-scoped the same
1360
+ * way, so a late `securityHeaders()` leaves the pages unable to PROVE the header to `nifra assure`
1361
+ * even though it does still set it.
1362
+ *
1363
+ * `onRequest`/`onResponse` (and the response header/body hooks) are app-global arrays read at
1364
+ * request time, so those alone DO work when added late. Using this seam for everything avoids
1365
+ * having to remember which is which.
1366
+ *
1367
+ * Runs ahead of the {@link mounts} / {@link api} request hooks, so an `onRequest` middleware guards
1368
+ * a mounted auth handler too.
1369
+ *
1370
+ * A `ContextPlugin` applied here takes runtime effect but cannot widen the declared return type of
1371
+ * this function, so `c.requestId` is not typed on routes you declare afterwards. Named plugins are
1372
+ * idempotent, so `app.use(requestId())` on the returned app recovers the TYPE without applying the
1373
+ * plugin a second time.
1374
+ */
1375
+ readonly use?: (app: ReturnType<typeof server<Env>>) => void
1424
1376
  /** Injected into each loader's `ctx.api` - typically an `inProcessClient(app)` (typed
1425
1377
  * per-route via `@nifrajs/client`'s `createRoutes`). Opaque to the core.
1426
1378
  *
@@ -1485,6 +1437,11 @@ export interface CreateWebAppOptions {
1485
1437
  * `prerendered.json`). Injected as `window.__NIFRA_PRERENDERED__` on every page so a client soft-nav
1486
1438
  * into a prerendered route fetches its static `_data.json` instead of hitting the worker. */
1487
1439
  readonly prerenderedPaths?: readonly string[]
1440
+ /** Publish the project's `AGENTS.md` inside `/llms.txt` and `/llms-full.txt`. **Off by default**:
1441
+ * those endpoints are public and unauthenticated, while `AGENTS.md` is a repo file written for the
1442
+ * team - unreleased feature names, internal hostnames, and "don't touch X yet" notes live there
1443
+ * routinely. Turn it on only for a repo whose guidelines you would publish as a page. */
1444
+ readonly publishLocalGuidelines?: boolean
1488
1445
  /** SSG: per dynamic route pattern, its `getStaticPaths` `fallback` (from `enumerateStaticRoutes` or
1489
1446
  * the build's `prerendered.json`). A route mapped to `"404"` rejects any path NOT in
1490
1447
  * `prerenderedPaths` with the 404 page - the unlisted path simply doesn't exist. `"ssr"` (the
@@ -1573,7 +1530,7 @@ const urlPartsFor = (request: Request): UrlParts => {
1573
1530
  * secure default; validate at the trust boundary before use.
1574
1531
  */
1575
1532
  export function createWebApp<Env = unknown>(
1576
- options: CreateWebAppOptions,
1533
+ options: CreateWebAppOptions<Env>,
1577
1534
  ): ReturnType<typeof server<Env>> {
1578
1535
  const { adapter, manifest, clientEntry, title, api } = options
1579
1536
  const titleOption = title === undefined ? {} : { title }
@@ -1599,7 +1556,11 @@ export function createWebApp<Env = unknown>(
1599
1556
  // Seed the context with the declared `Env` so `app.fetch(req, { env })` / `toFetchHandler(app)` type
1600
1557
  // the platform bindings (see the `createWebApp` doc). The runtime `env` still arrives per-request via
1601
1558
  // `app.fetch(req, { env })`; this is a compile-time-only seed (`server<Env>()` casts, doesn't store).
1602
- const app = server<Env>()
1559
+ const app = server<Env>(options.server)
1560
+ // Before the mount hooks and before every page route, so the order-scoped hooks (`derive`,
1561
+ // `beforeHandle`, assurance evidence) actually bind to them - see the `use` option. A caller
1562
+ // cannot do this after the fact: by the time this function returns, the routes are declared.
1563
+ options.use?.(app)
1603
1564
  // Auto-mount the in-process backend over HTTP at `apiPrefix` (default `/api`), BEFORE page routing.
1604
1565
  // `onRequest` runs on the raw request ahead of the router, so this wins over the page wildcard `/*`
1605
1566
  // for every method (POST/GET/PUT/…) - not just the GET page routes - and a backend 404 (`/api/none`)
@@ -2142,6 +2103,13 @@ export function createWebApp<Env = unknown>(
2142
2103
  }
2143
2104
  return renderError(route, errorId, withDuplicateInstanceHint(err))
2144
2105
  }
2106
+ // A loader may RETURN its control-flow signal instead of throwing it - `return redirect(...)`
2107
+ // reads naturally and must not silently serialize the `Response` as loader data. Mirror the
2108
+ // catch above exactly: a status signal renders its boundary, any other `Response` (a
2109
+ // redirect, a hand-rolled response) passes through to core verbatim - so return and throw
2110
+ // are interchangeable.
2111
+ if (isStatusSignal(data)) return renderStatusSignal(c.req, data)
2112
+ if (data instanceof Response) return data
2145
2113
  // Client-side navigation asks (via the X-Nifra-Data header) for just the loader data - no full
2146
2114
  // document, no layout chain. A route with deferred data streams NDJSON (critical data first,
2147
2115
  // then each deferred value as it settles); otherwise one JSON (the fast path). Same loader,
@@ -2248,11 +2216,21 @@ export function createWebApp<Env = unknown>(
2248
2216
  draft,
2249
2217
  search: loaderSearch(mod.searchSchema, c.req),
2250
2218
  }
2251
- // A gate is an authorization boundary for everything beneath its layout, mutations included.
2252
- // Run only gates before the action; ordinary layout data loaders run after a native mutation.
2253
- const layoutModules = await runLayoutGates(route, actionContext)
2254
- const result = await mod.action(actionContext)
2255
2219
  const isDataRequest = c.req.headers.get(DATA_HEADER) !== null
2220
+ let layoutModules: LoadedLayoutModules
2221
+ let result: unknown
2222
+ try {
2223
+ // A gate is an authorization boundary for everything beneath its layout, mutations included.
2224
+ // Run only gates before the action; ordinary layout data loaders run after a native mutation.
2225
+ layoutModules = await runLayoutGates(route, actionContext)
2226
+ result = await mod.action(actionContext)
2227
+ } catch (err) {
2228
+ // A THROWN Response is the same control-flow signal as a returned one (`throw redirect()`
2229
+ // in a gate or an action) - route it through the same conversion as the returned-Response
2230
+ // branch below, so throw and return are interchangeable on the mutation path too.
2231
+ if (err instanceof Response) return actionResponse(err, isDataRequest)
2232
+ throw err
2233
+ }
2256
2234
  // An action may wrap its data in `revalidate(paths, data)` to declare which routes it changed.
2257
2235
  // Unwrap to the inner data; the paths ride the `X-Nifra-Revalidate` header on the data-mode
2258
2236
  // responses (the client acts on them - a full-page POST re-runs loaders inline, so no header).
@@ -2262,15 +2240,7 @@ export function createWebApp<Env = unknown>(
2262
2240
  const revalidateHeader: Record<string, string> = isRevalidate
2263
2241
  ? { [REVALIDATE_HEADER]: (result as RevalidateResult<unknown>).__nifraRevalidate.join(",") }
2264
2242
  : {}
2265
- if (actionResult instanceof Response) {
2266
- // Client submit can't read a 3xx Location (fetch follows it to HTML), so convey the
2267
- // redirect via a header on a 204 and let the client navigate. Native forms get the 3xx.
2268
- if (isDataRequest && actionResult.status >= 300 && actionResult.status < 400) {
2269
- const location = actionResult.headers.get("location") ?? "/"
2270
- return new Response(null, { status: 204, headers: { [REDIRECT_HEADER]: location } })
2271
- }
2272
- return actionResult
2273
- }
2243
+ if (actionResult instanceof Response) return actionResponse(actionResult, isDataRequest)
2274
2244
  // Client submit wants just the action's data (it revalidates the loader itself); a native
2275
2245
  // form POST re-renders the full page (loader re-runs) with the action data.
2276
2246
  if (isDataRequest) {
@@ -2327,15 +2297,16 @@ export function createWebApp<Env = unknown>(
2327
2297
  }
2328
2298
 
2329
2299
  // Register llms.txt & llms-full.txt
2300
+ const llmsOptions = { includeLocalGuidelines: options.publishLocalGuidelines === true }
2330
2301
  app.register("GET", "/llms.txt", undefined, async () => {
2331
- const text = await generateLlmsTxt(false, manifest.routes, api)
2302
+ const text = await generateLlmsTxt(false, manifest.routes, api, llmsOptions)
2332
2303
  return new Response(text, {
2333
2304
  headers: { "content-type": "text/plain; charset=utf-8" },
2334
2305
  })
2335
2306
  })
2336
2307
 
2337
2308
  app.register("GET", "/llms-full.txt", undefined, async () => {
2338
- const text = await generateLlmsTxt(true, manifest.routes, api)
2309
+ const text = await generateLlmsTxt(true, manifest.routes, api, llmsOptions)
2339
2310
  return new Response(text, {
2340
2311
  headers: { "content-type": "text/plain; charset=utf-8" },
2341
2312
  })
@@ -2428,6 +2399,16 @@ export function generateClientEntry(
2428
2399
  // access yields `undefined` if absent - unlike a named import, which would be a link error.
2429
2400
  `import * as __adapter from ${JSON.stringify(clientModule)}`,
2430
2401
  "const { mountRouter } = __adapter",
2402
+ // The assurance hook is optional: ordinary client entries pay only for the namespace lookup, while
2403
+ // hydration runners get a runtime identity from the exact adapter module that mounted the page. The
2404
+ // registry is token-only and lives on a Symbol.for key so it cannot collide with app data.
2405
+ "const __hydrationHook = __adapter.hydrationAssuranceHook",
2406
+ 'if (__hydrationHook && typeof __hydrationHook.runtimeIdentity === "function") {',
2407
+ ' const __runtimeKey = Symbol.for("nifra.hydration.runtime")',
2408
+ " const __runtimeState = globalThis[__runtimeKey]",
2409
+ " const __identities = __runtimeState && Array.isArray(__runtimeState.identities) ? __runtimeState.identities : []",
2410
+ " globalThis[__runtimeKey] = { framework: __hydrationHook.framework, identities: [...__identities, __hydrationHook.runtimeIdentity()] }",
2411
+ "}",
2431
2412
  // The `clientModule` contract is a string specifier, so nothing type-checks that the module it
2432
2413
  // names actually exports `mountRouter`. Without this the miss surfaces as
2433
2414
  // "mountRouter is not a function" from inside a bundled chunk, at first paint, naming neither the
package/src/isr.ts CHANGED
@@ -572,7 +572,10 @@ export function withISR(
572
572
  }
573
573
 
574
574
  return async (req, platform) => {
575
- const key = req.method === "GET" ? keyOf(req) : null
575
+ // A data-mode soft-nav GET bypasses the cache entirely: entries are full HTML documents keyed
576
+ // by URL, and serving one to a loader-data fetch would hand the client HTML where it expects
577
+ // the loader payload. (The write path already refuses to cache data-mode responses.)
578
+ const key = req.method === "GET" && req.headers.get("x-nifra-data") === null ? keyOf(req) : null
576
579
  if (key === null) return app.fetch(req, platform)
577
580
 
578
581
  // Draft/preview: an editor (valid signed cookie) always renders fresh and is never cached, so
package/src/llms-txt.ts CHANGED
@@ -79,35 +79,39 @@ function clientCall(method: string, path: string, schema: unknown): string {
79
79
  return `await ${chain}${call}`
80
80
  }
81
81
 
82
+ /** Read the project's `AGENTS.md`, or `""` if there isn't one (or we're on an edge runtime with no
83
+ * filesystem). Never throws - a missing file just means no guidelines section. */
84
+ async function readAgentsMd(): Promise<string> {
85
+ // Edge bundles define this flag and dead-code-eliminate the filesystem branch. Keeping the import
86
+ // behind a runtime-only `typeof Bun` check is insufficient: Rollup must still emit an unresolved
87
+ // `node:` dependency, which workerd cannot load.
88
+ const edgeRuntime = (
89
+ globalThis as typeof globalThis & { readonly __NIFRA_EDGE_RUNTIME__?: boolean }
90
+ ).__NIFRA_EDGE_RUNTIME__
91
+ if (edgeRuntime === true) return ""
92
+ try {
93
+ if (typeof Bun !== "undefined") return await Bun.file("AGENTS.md").text()
94
+ const fs = await import("node:fs/promises")
95
+ return await fs.readFile("AGENTS.md", "utf-8")
96
+ } catch {
97
+ return ""
98
+ }
99
+ }
100
+
82
101
  export async function generateLlmsTxt(
83
102
  full: boolean,
84
103
  pageRoutes: ReadonlyArray<{ readonly pattern: string; readonly id: string }>,
85
104
  backend: unknown,
105
+ options: { readonly includeLocalGuidelines?: boolean } = {},
86
106
  ): Promise<string> {
87
107
  let output = ""
88
108
  output += `# Nifra App Context\n\n`
89
109
  output += `This is a machine-readable context endpoint describing the API routes, pages, and conventions of this Nifra application.\n\n`
90
110
 
91
- // 1. Project Guidelines
92
- let agentsMd = ""
93
- try {
94
- // Edge bundles define this flag and dead-code-eliminate the filesystem branch. Keeping the import
95
- // behind a runtime-only `typeof Bun` check is insufficient: Rollup must still emit an unresolved
96
- // `node:` dependency, which workerd cannot load.
97
- const edgeRuntime = (
98
- globalThis as typeof globalThis & { readonly __NIFRA_EDGE_RUNTIME__?: boolean }
99
- ).__NIFRA_EDGE_RUNTIME__
100
- if (edgeRuntime !== true) {
101
- if (typeof Bun !== "undefined") {
102
- agentsMd = await Bun.file("AGENTS.md").text()
103
- } else {
104
- const fs = await import("node:fs/promises")
105
- agentsMd = await fs.readFile("AGENTS.md", "utf-8")
106
- }
107
- }
108
- } catch {
109
- // ignore
110
- }
111
+ // 1. Project Guidelines. Off unless the app opts in: `AGENTS.md` is a repo file written for the
112
+ // team, not a page, and this endpoint is public - a note about an unshipped feature or an
113
+ // internal hostname in it would be served to anyone who asks.
114
+ const agentsMd = options.includeLocalGuidelines === true ? await readAgentsMd() : ""
111
115
 
112
116
  if (agentsMd) {
113
117
  output += `## Guidelines & Conventions\n\n`
package/src/manifest.ts CHANGED
@@ -222,9 +222,11 @@ export interface RouteModule {
222
222
  */
223
223
  readonly getStaticPaths?: GetStaticPaths
224
224
  /**
225
- * ISR freshness for this route, in **seconds**. `createWebApp` emits it as an `x-nifra-revalidate`
226
- * response header that `withISR` reads to set the page's cache TTL (overriding the wrapper's
227
- * default). Older-than-`revalidate` cached pages are served stale while regenerating.
225
+ * ISR freshness for this route, in **seconds**. `createWebApp` emits it as an
226
+ * `x-nifra-isr-revalidate` response header that `withISR` reads to set the page's cache TTL
227
+ * (overriding the wrapper's default). Older-than-`revalidate` cached pages are served stale while
228
+ * regenerating. (Distinct from the action-revalidation `x-nifra-revalidate` header - a CSV path
229
+ * list the client parses to refetch - so the two channels never alias.)
228
230
  */
229
231
  readonly revalidate?: number
230
232
  /** Optional bounded tags used by ISR on-demand invalidation (`?tag=...`). */
package/src/navigation.ts CHANGED
@@ -18,6 +18,13 @@ import { serializeSearch } from "./search.ts"
18
18
  export interface NavigateOptions {
19
19
  /** Replace the current history entry instead of pushing a new one (like `history.replaceState`). */
20
20
  readonly replace?: boolean
21
+ /**
22
+ * Opaque per-entry state stored on the created history entry (under `history.state.nifraState`, so
23
+ * it never collides with the router's own bookkeeping keys). Structured-cloneable only, like
24
+ * `history.pushState`'s first argument. Read it back via `history.state.nifraState`; the browser
25
+ * restores it automatically on back/forward. Ignored for history-delta navigations.
26
+ */
27
+ readonly state?: unknown
21
28
  }
22
29
 
23
30
  /**
@@ -56,6 +63,7 @@ export interface NavigateTargetInput {
56
63
  readonly to: string
57
64
  readonly search?: Record<string, unknown>
58
65
  readonly replace?: boolean
66
+ readonly state?: unknown
59
67
  }
60
68
 
61
69
  /**
@@ -71,6 +79,7 @@ export interface NavigateFunction {
71
79
  readonly to: To
72
80
  readonly search?: NavigateSearchOf<To>
73
81
  readonly replace?: boolean
82
+ readonly state?: unknown
74
83
  }): void
75
84
  }
76
85
 
@@ -86,7 +95,11 @@ export function resolveNavigate(
86
95
  ): { readonly to: string | number; readonly options: NavigateOptions | undefined } {
87
96
  if (typeof to !== "object") return { to, options }
88
97
  const query = to.search !== undefined ? serializeSearch(to.search) : ""
89
- return { to: to.to + query, options: to.replace === true ? { replace: true } : options }
98
+ if (to.replace !== true && to.state === undefined) return { to: to.to + query, options }
99
+ const resolved: { replace?: boolean; state?: unknown } = { ...options }
100
+ if (to.replace === true) resolved.replace = true
101
+ if (to.state !== undefined) resolved.state = to.state
102
+ return { to: to.to + query, options: resolved }
90
103
  }
91
104
 
92
105
  /**