@nifrajs/web 2.10.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 (89) 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 +12 -0
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +28 -7
  11. package/dist/client.js.map +1 -1
  12. package/dist/conformance.d.ts +1 -1
  13. package/dist/conformance.d.ts.map +1 -1
  14. package/dist/deferred.d.ts +1 -1
  15. package/dist/deferred.d.ts.map +1 -1
  16. package/dist/deferred.js +14 -3
  17. package/dist/deferred.js.map +1 -1
  18. package/dist/dev-ssr-graph.d.ts +33 -0
  19. package/dist/dev-ssr-graph.d.ts.map +1 -0
  20. package/dist/dev-ssr-graph.js +285 -0
  21. package/dist/dev-ssr-graph.js.map +1 -0
  22. package/dist/dev.d.ts.map +1 -1
  23. package/dist/dev.js +88 -17
  24. package/dist/dev.js.map +1 -1
  25. package/dist/fonts-google.d.ts +2 -2
  26. package/dist/fonts-google.d.ts.map +1 -1
  27. package/dist/fonts-google.js +12 -3
  28. package/dist/fonts-google.js.map +1 -1
  29. package/dist/index.d.ts +68 -119
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +78 -114
  32. package/dist/index.js.map +1 -1
  33. package/dist/internal/head-merge.d.ts +27 -0
  34. package/dist/internal/head-merge.d.ts.map +1 -0
  35. package/dist/internal/head-merge.js +65 -0
  36. package/dist/internal/head-merge.js.map +1 -0
  37. package/dist/internal/server-boundary.d.ts +1 -2
  38. package/dist/internal/server-boundary.d.ts.map +1 -1
  39. package/dist/internal/server-boundary.js +1 -2
  40. package/dist/internal/server-boundary.js.map +1 -1
  41. package/dist/isr.d.ts.map +1 -1
  42. package/dist/isr.js +4 -1
  43. package/dist/isr.js.map +1 -1
  44. package/dist/llms-txt.d.ts +3 -1
  45. package/dist/llms-txt.d.ts.map +1 -1
  46. package/dist/llms-txt.js +23 -20
  47. package/dist/llms-txt.js.map +1 -1
  48. package/dist/manifest.d.ts +5 -3
  49. package/dist/manifest.d.ts.map +1 -1
  50. package/dist/manifest.js.map +1 -1
  51. package/dist/navigation.d.ts +9 -0
  52. package/dist/navigation.d.ts.map +1 -1
  53. package/dist/navigation.js +8 -1
  54. package/dist/navigation.js.map +1 -1
  55. package/dist/plugins/css-modules.d.ts +12 -0
  56. package/dist/plugins/css-modules.d.ts.map +1 -1
  57. package/dist/plugins/css-modules.js +0 -0
  58. package/dist/plugins/css-modules.js.map +1 -1
  59. package/dist/plugins/kit.d.ts +54 -0
  60. package/dist/plugins/kit.d.ts.map +1 -1
  61. package/dist/plugins/kit.js +66 -0
  62. package/dist/plugins/kit.js.map +1 -1
  63. package/dist/render-seam.d.ts +61 -0
  64. package/dist/render-seam.d.ts.map +1 -0
  65. package/dist/render-seam.js +22 -0
  66. package/dist/render-seam.js.map +1 -0
  67. package/dist/vite.d.ts.map +1 -1
  68. package/dist/vite.js +27 -1
  69. package/dist/vite.js.map +1 -1
  70. package/package.json +3 -3
  71. package/src/build-vite.ts +11 -0
  72. package/src/build.ts +18 -1
  73. package/src/client.ts +41 -7
  74. package/src/conformance.ts +1 -1
  75. package/src/deferred.ts +14 -2
  76. package/src/dev-ssr-graph.ts +311 -0
  77. package/src/dev.ts +91 -19
  78. package/src/fonts-google.ts +14 -5
  79. package/src/index.ts +155 -189
  80. package/src/internal/head-merge.ts +80 -0
  81. package/src/internal/server-boundary.ts +1 -2
  82. package/src/isr.ts +4 -1
  83. package/src/llms-txt.ts +24 -20
  84. package/src/manifest.ts +5 -3
  85. package/src/navigation.ts +14 -1
  86. package/src/plugins/css-modules.ts +0 -0
  87. package/src/plugins/kit.ts +71 -0
  88. package/src/render-seam.ts +76 -0
  89. package/src/vite.ts +29 -1
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,
@@ -25,6 +25,10 @@ import {
25
25
  } from "./deferred.ts"
26
26
  import { isDraftEnabled } from "./draft.ts"
27
27
  import { trustedHeadAttributes } from "./internal/head-attributes.ts"
28
+ // Head resolution/merging. Defined in an internal module so `@nifrajs/web/client` can export the same
29
+ // two functions to the generated client entry without dragging THIS module (and its server code) into
30
+ // the browser graph - see internal/head-merge.ts. Re-exported here so the public API is unchanged.
31
+ import { mergeHeads, resolveMeta } from "./internal/head-merge.ts"
28
32
  import { jsStringLiteral } from "./internal/js-string.ts"
29
33
  import { EXECUTABLE_SCRIPT_TYPES, INERT_SCRIPT_TYPES } from "./internal/script-types.ts"
30
34
  import { ISR_REVALIDATE_HEADER, ISR_REVALIDATE_TAGS_HEADER, serializeISRTags } from "./isr.ts"
@@ -44,6 +48,14 @@ import type {
44
48
  ScriptDescriptor,
45
49
  UnsafeScriptDescriptor,
46
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"
47
59
  import {
48
60
  createMatcher,
49
61
  DATA_HEADER,
@@ -53,7 +65,6 @@ import {
53
65
  RETAIN_HEADER,
54
66
  REVALIDATE_HEADER,
55
67
  STATUS_HEADER,
56
- type Submission,
57
68
  } from "./router.ts"
58
69
  import { searchOf, searchOfChain } from "./search.ts"
59
70
 
@@ -113,6 +124,7 @@ export {
113
124
  type Manifest,
114
125
  type Meta,
115
126
  type MetaArgs,
127
+ type MetaDescriptor,
116
128
  type MetaInput,
117
129
  type RouteEntry,
118
130
  type RouteModule,
@@ -195,7 +207,7 @@ export {
195
207
  type SubmitOptions,
196
208
  } from "./router.ts"
197
209
  // Deferred loader data (`defer()` + the `Deferred<T>` type) - consumed by the adapter's `<Await>`.
198
- export { type Deferred, defer }
210
+ export { type Deferred, defer, mergeHeads, resolveMeta }
199
211
 
200
212
  /** Phantom brand key for {@link ServerOnly}. A unique symbol so the brand can't be forged by a plain
201
213
  * object literal; `unique symbol` keys never appear in the emitted JS, so the brand is purely
@@ -231,6 +243,18 @@ export {
231
243
  RenderAdapterConformanceError,
232
244
  type RenderAdapterConformanceFixture,
233
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"
234
258
 
235
259
  // The search-derivation primitive: parse a URL query, validate it against a route's `searchSchema`
236
260
  // (fail-closed to defaults), or return the raw parsed query when there is none. Both the server (loader
@@ -238,105 +262,6 @@ export {
238
262
  // produce the identical value by construction. An adapter's `useSearch` binding reads its result.
239
263
  export { type SearchOf, searchOf, searchOfChain, serializeSearch } from "./search.ts"
240
264
 
241
- /** The data handed to a route component. Opaque to the core. `actionData` is the return of a
242
- * route `action` after a POST (absent on plain GETs). `pending` + `submission` are client-only
243
- * (absent on SSR): they drive **optimistic UI** - render from `submission.formData` while `pending`. */
244
- export interface RenderProps {
245
- readonly data: unknown
246
- /**
247
- * Per-layout loader data, aligned index-for-index with the layout prefix of `chain`.
248
- *
249
- * Absent when no layout in the chain exports a `loader`, so a page-only app is byte-identical to
250
- * before. An adapter that does not read it renders exactly as it did; one that does passes
251
- * `layoutData[i]` to `chain[i]`.
252
- */
253
- readonly layoutData?: readonly unknown[]
254
- readonly actionData?: unknown
255
- /** True while a client navigation or submit is in flight (client-only; absent/false on SSR). */
256
- readonly pending?: boolean
257
- /** The path a client navigation is transitioning TO while `pending` (client-only; absent on SSR and
258
- * when idle). Lets an adapter tell a `NavLink` whether its own target is the one loading. */
259
- readonly pendingPath?: string
260
- /** The in-flight client submit, for optimistic UI (client-only; absent on SSR + when idle). */
261
- readonly submission?: Submission
262
- /** The matched route's decoded path params (`/users/:id` → `{ id: "7" }`). Threaded identically on
263
- * SSR (from the request match) and on client navigation (from router state) so an adapter's
264
- * `useParams` reads the same value on both, with no hydration mismatch. Absent ⇒ no params (`{}`). */
265
- readonly params?: Readonly<Record<string, string>>
266
- /** The current URL's `pathname + search` (no hash - the hash never reaches the server). Threaded on
267
- * SSR and client alike so an adapter's `useLocation`/`useSearchParams` hydrate consistently. Absent
268
- * on adapters/callers that don't supply it (treated as `""`). */
269
- readonly path?: string
270
- /** The route's typed, validated search params (the value the loader also gets as `ctx.search`),
271
- * threaded to the adapter's `useSearch`. Derived identically on SSR (from the request) and on client
272
- * navigation (adapter mount) via the shared `searchOf`, so a component reading it hydrates with no
273
- * drift. Absent ⇒ no search context (`{}`). */
274
- readonly search?: Record<string, unknown>
275
- }
276
-
277
- /**
278
- * The seam every render adapter implements. New adapters should prove these invariants with
279
- * {@link assertRenderAdapterConformance}; framework-specific behavior remains locally tested.
280
- */
281
- export interface RenderAdapter {
282
- /**
283
- * Server: render a route's layout `chain` (outermost layout → page) to a **stream** of HTML
284
- * bytes, including the framework's hydration markers. The page (innermost) receives `props`
285
- * (the loader data); each layout wraps the child via its `children`. Returns (or resolves to)
286
- * a Web `ReadableStream<Uint8Array>`: a streaming renderer flushes the shell first and streams
287
- * Suspense boundaries as they resolve; a non-streaming renderer may return a one-chunk stream.
288
- * May be async - e.g. React resolves once the shell is renderable, so a shell-render throw can
289
- * still map to an error status before any byte is sent.
290
- */
291
- renderToStream(
292
- chain: readonly unknown[],
293
- props: RenderProps,
294
- ): ReadableStream<Uint8Array> | Promise<ReadableStream<Uint8Array>>
295
- /**
296
- * Server (optional): render the chain to a complete HTML **string** in one pass, with the same
297
- * hydration markers `renderToStream` produces. When provided, `renderPage` uses it for any page
298
- * that does **not** `defer()` - buffering the document is faster than the streaming pipeline there,
299
- * because the framework's synchronous renderer (React `renderToString`, Solid `renderToString`,
300
- * Preact `render`, Vue `renderToString`, Svelte `render`) is markedly cheaper than its streaming
301
- * renderer - most visibly on Bun, where React Fizz / Solid `renderToStream` are the heaviest. Pages
302
- * that `defer()` keep the streaming path (progressive `<Await>` needs it). Markup MUST be
303
- * hydration-equivalent to `renderToStream`; a throw surfaces like the streaming shell-readiness
304
- * await (the `_error` boundary maps it to a status). Omit it to always stream.
305
- */
306
- renderToString?(chain: readonly unknown[], props: RenderProps): string | Promise<string>
307
- /**
308
- * Server: per-document bootstrap markup injected into `<head>` that the client
309
- * `hydrate` requires (Solid: `generateHydrationScript()`). Empty string if none.
310
- */
311
- hydrationHead(nonce?: string): string
312
- }
313
-
314
- /** Global the server serializes loader data into; the client reads it to hydrate. */
315
- export const DATA_GLOBAL = "__NIFRA_DATA__"
316
- /** Per-layout loader data for hydration. Emitted ONLY when some layout in the chain has a loader, so
317
- * a page-only app's HTML is byte-identical to before layout loaders existed. */
318
- export const LAYOUT_DATA_GLOBAL = "__NIFRA_LAYOUT_DATA__"
319
-
320
- /** Global the server writes the matched route id into; the client uses it to pick the chain. */
321
- export const ROUTE_GLOBAL = "__NIFRA_ROUTE__"
322
-
323
- /** Global the server serializes an action's data return into (absent on GETs); the client
324
- * reads it so hydration after a native form POST matches the server-rendered markup. */
325
- export const ACTION_GLOBAL = "__NIFRA_ACTION__"
326
-
327
- /**
328
- * Marker attribute on the app container, emitted ONLY when `rootId` is not the default.
329
- *
330
- * `rootId` is a per-RENDER option; the client entry is a per-BUILD artifact, so it cannot be
331
- * generated knowing which id a given render will use. The container therefore announces itself, and
332
- * the entry finds it by this attribute before falling back to `#root`. It has to be the DOM rather
333
- * than another `window.__NIFRA_*` global: a second copy of the id can drift from the markup, while
334
- * this is written by the same expression that writes the id and cannot.
335
- *
336
- * Absent on a default render, so an app that never sets `rootId` emits the same bytes it always has.
337
- */
338
- export const ROOT_ATTRIBUTE = "data-nifra-root"
339
-
340
265
  /**
341
266
  * Pre-hydration form guard - a tiny inline script flushed in `<head>` (it runs in the window between
342
267
  * first paint and the island bundle taking over). It neutralizes the one real hydration footgun: a
@@ -987,6 +912,20 @@ function withDuplicateInstanceHint(err: unknown): unknown {
987
912
  return augmented
988
913
  }
989
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
+
990
929
  /** A loaded layout module. `loader`/`gate` are the layout-loader surface; `meta` predates it. */
991
930
  type LoadedLayoutModules = ReadonlyArray<{
992
931
  default: unknown
@@ -1287,12 +1226,6 @@ function headTags(head: Meta | undefined): string {
1287
1226
  return out
1288
1227
  }
1289
1228
 
1290
- /** Resolve a route's `meta` (static or a function of the loader data + params) to a {@link Meta}. */
1291
- export function resolveMeta(meta: MetaInput | undefined, args: MetaArgs): Meta {
1292
- if (meta === undefined) return {}
1293
- return typeof meta === "function" ? meta(args) : meta
1294
- }
1295
-
1296
1229
  /**
1297
1230
  * A `<link rel="canonical">` descriptor for a route's `meta.link`. The canonical URL tells search
1298
1231
  * engines which URL is authoritative for a page (deduping query-string / tracking variants).
@@ -1379,75 +1312,67 @@ export function unsafeInlineScript(
1379
1312
  }
1380
1313
  }
1381
1314
 
1382
- /**
1383
- * Merge a route's `<head>` contributions from its layout chain + the page into one {@link Meta}.
1384
- *
1385
- * The head contract (see {@link CreateWebAppOptions} and `LayoutEntry`): a `_layout.tsx` may export
1386
- * `meta` to put sitewide tags (`hreflang`, `preconnect`, a default `<title>`, …) on every page below
1387
- * it. `heads` is passed **outermost layout → … → innermost layout → page** (the same order as the
1388
- * render chain), and merges:
1389
- * - **`title`** - *nearest-wins*: the last defined value across the list, so the page overrides an
1390
- * inner layout, which overrides an outer one. A layout `title` is the section default; an undefined
1391
- * page `title` keeps it.
1392
- * - **`meta` / `link`** - *concatenated* in list order (outermost first, page last). Duplicate-tag
1393
- * de-duplication is the caller's concern; the framework emits exactly what's declared so a layout
1394
- * can ship N `<link rel="alternate" hreflang>` tags and a page can add its own canonical.
1395
- *
1396
- * Returns a fresh object whose identity is stable per `heads` *content* only when every entry is a
1397
- * static (by-reference) `Meta` and there is exactly one - otherwise a new object each call. That is
1398
- * fine: {@link headTags}'s memo is keyed on identity, so a per-request merge simply recomputes (its
1399
- * content can vary with loader data anyway).
1400
- */
1401
- export function mergeHeads(heads: readonly Meta[]): Meta {
1402
- // Single-head fast path (a route with no layout `meta`, by far the common case) - return the
1403
- // resolved object by reference so headTags' identity-keyed memo hits across requests for static meta.
1404
- if (heads.length === 1) return heads[0] as Meta
1405
- let title: string | undefined
1406
- let lang: string | undefined
1407
- let dir: Meta["dir"]
1408
- const meta: MetaDescriptor[] = []
1409
- const link: LinkDescriptor[] = []
1410
- const script: ScriptDescriptor[] = []
1411
- const unsafeScript: UnsafeScriptDescriptor[] = []
1412
- for (const h of heads) {
1413
- if (h.title !== undefined) title = h.title // nearest-wins: later (more specific) overrides
1414
- if (h.lang !== undefined) lang = h.lang // nearest-wins, like title
1415
- if (h.dir !== undefined) dir = h.dir
1416
- if (h.meta !== undefined) meta.push(...h.meta)
1417
- if (h.link !== undefined) link.push(...h.link)
1418
- if (h.script !== undefined) script.push(...h.script) // concatenated like meta/link (outermost first)
1419
- if (h.unsafeScript !== undefined) unsafeScript.push(...h.unsafeScript)
1420
- }
1421
- // Build the result with only the fields that were actually contributed - an empty `meta`/`link`/
1422
- // `script` array would otherwise be a spurious (if harmless) key. A mutable local; the cast to `Meta`
1423
- // is sound because a key is assigned only when defined (so `exactOptionalPropertyTypes` never sees
1424
- // `undefined`).
1425
- const merged: {
1426
- title?: string
1427
- meta?: Meta["meta"]
1428
- link?: Meta["link"]
1429
- script?: Meta["script"]
1430
- unsafeScript?: Meta["unsafeScript"]
1431
- lang?: string
1432
- dir?: Meta["dir"]
1433
- } = {}
1434
- if (title !== undefined) merged.title = title
1435
- if (meta.length > 0) merged.meta = meta
1436
- if (link.length > 0) merged.link = link
1437
- if (script.length > 0) merged.script = script
1438
- if (unsafeScript.length > 0) merged.unsafeScript = unsafeScript
1439
- if (lang !== undefined) merged.lang = lang
1440
- if (dir !== undefined) merged.dir = dir
1441
- return merged as Meta
1442
- }
1443
-
1444
- export interface CreateWebAppOptions {
1315
+ export interface CreateWebAppOptions<Env = unknown> {
1445
1316
  readonly adapter: RenderAdapter
1446
1317
  readonly manifest: Manifest
1447
1318
  /** URL of the built client entry (module script) injected into every page. */
1448
1319
  readonly clientEntry: string
1449
1320
  /** Default document title for all pages. */
1450
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
1451
1376
  /** Injected into each loader's `ctx.api` - typically an `inProcessClient(app)` (typed
1452
1377
  * per-route via `@nifrajs/client`'s `createRoutes`). Opaque to the core.
1453
1378
  *
@@ -1512,6 +1437,11 @@ export interface CreateWebAppOptions {
1512
1437
  * `prerendered.json`). Injected as `window.__NIFRA_PRERENDERED__` on every page so a client soft-nav
1513
1438
  * into a prerendered route fetches its static `_data.json` instead of hitting the worker. */
1514
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
1515
1445
  /** SSG: per dynamic route pattern, its `getStaticPaths` `fallback` (from `enumerateStaticRoutes` or
1516
1446
  * the build's `prerendered.json`). A route mapped to `"404"` rejects any path NOT in
1517
1447
  * `prerenderedPaths` with the 404 page - the unlisted path simply doesn't exist. `"ssr"` (the
@@ -1600,7 +1530,7 @@ const urlPartsFor = (request: Request): UrlParts => {
1600
1530
  * secure default; validate at the trust boundary before use.
1601
1531
  */
1602
1532
  export function createWebApp<Env = unknown>(
1603
- options: CreateWebAppOptions,
1533
+ options: CreateWebAppOptions<Env>,
1604
1534
  ): ReturnType<typeof server<Env>> {
1605
1535
  const { adapter, manifest, clientEntry, title, api } = options
1606
1536
  const titleOption = title === undefined ? {} : { title }
@@ -1626,7 +1556,11 @@ export function createWebApp<Env = unknown>(
1626
1556
  // Seed the context with the declared `Env` so `app.fetch(req, { env })` / `toFetchHandler(app)` type
1627
1557
  // the platform bindings (see the `createWebApp` doc). The runtime `env` still arrives per-request via
1628
1558
  // `app.fetch(req, { env })`; this is a compile-time-only seed (`server<Env>()` casts, doesn't store).
1629
- 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)
1630
1564
  // Auto-mount the in-process backend over HTTP at `apiPrefix` (default `/api`), BEFORE page routing.
1631
1565
  // `onRequest` runs on the raw request ahead of the router, so this wins over the page wildcard `/*`
1632
1566
  // for every method (POST/GET/PUT/…) - not just the GET page routes - and a backend 404 (`/api/none`)
@@ -2169,6 +2103,13 @@ export function createWebApp<Env = unknown>(
2169
2103
  }
2170
2104
  return renderError(route, errorId, withDuplicateInstanceHint(err))
2171
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
2172
2113
  // Client-side navigation asks (via the X-Nifra-Data header) for just the loader data - no full
2173
2114
  // document, no layout chain. A route with deferred data streams NDJSON (critical data first,
2174
2115
  // then each deferred value as it settles); otherwise one JSON (the fast path). Same loader,
@@ -2275,11 +2216,21 @@ export function createWebApp<Env = unknown>(
2275
2216
  draft,
2276
2217
  search: loaderSearch(mod.searchSchema, c.req),
2277
2218
  }
2278
- // A gate is an authorization boundary for everything beneath its layout, mutations included.
2279
- // Run only gates before the action; ordinary layout data loaders run after a native mutation.
2280
- const layoutModules = await runLayoutGates(route, actionContext)
2281
- const result = await mod.action(actionContext)
2282
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
+ }
2283
2234
  // An action may wrap its data in `revalidate(paths, data)` to declare which routes it changed.
2284
2235
  // Unwrap to the inner data; the paths ride the `X-Nifra-Revalidate` header on the data-mode
2285
2236
  // responses (the client acts on them - a full-page POST re-runs loaders inline, so no header).
@@ -2289,15 +2240,7 @@ export function createWebApp<Env = unknown>(
2289
2240
  const revalidateHeader: Record<string, string> = isRevalidate
2290
2241
  ? { [REVALIDATE_HEADER]: (result as RevalidateResult<unknown>).__nifraRevalidate.join(",") }
2291
2242
  : {}
2292
- if (actionResult instanceof Response) {
2293
- // Client submit can't read a 3xx Location (fetch follows it to HTML), so convey the
2294
- // redirect via a header on a 204 and let the client navigate. Native forms get the 3xx.
2295
- if (isDataRequest && actionResult.status >= 300 && actionResult.status < 400) {
2296
- const location = actionResult.headers.get("location") ?? "/"
2297
- return new Response(null, { status: 204, headers: { [REDIRECT_HEADER]: location } })
2298
- }
2299
- return actionResult
2300
- }
2243
+ if (actionResult instanceof Response) return actionResponse(actionResult, isDataRequest)
2301
2244
  // Client submit wants just the action's data (it revalidates the loader itself); a native
2302
2245
  // form POST re-renders the full page (loader re-runs) with the action data.
2303
2246
  if (isDataRequest) {
@@ -2354,15 +2297,16 @@ export function createWebApp<Env = unknown>(
2354
2297
  }
2355
2298
 
2356
2299
  // Register llms.txt & llms-full.txt
2300
+ const llmsOptions = { includeLocalGuidelines: options.publishLocalGuidelines === true }
2357
2301
  app.register("GET", "/llms.txt", undefined, async () => {
2358
- const text = await generateLlmsTxt(false, manifest.routes, api)
2302
+ const text = await generateLlmsTxt(false, manifest.routes, api, llmsOptions)
2359
2303
  return new Response(text, {
2360
2304
  headers: { "content-type": "text/plain; charset=utf-8" },
2361
2305
  })
2362
2306
  })
2363
2307
 
2364
2308
  app.register("GET", "/llms-full.txt", undefined, async () => {
2365
- const text = await generateLlmsTxt(true, manifest.routes, api)
2309
+ const text = await generateLlmsTxt(true, manifest.routes, api, llmsOptions)
2366
2310
  return new Response(text, {
2367
2311
  headers: { "content-type": "text/plain; charset=utf-8" },
2368
2312
  })
@@ -2447,12 +2391,24 @@ export function generateClientEntry(
2447
2391
  }
2448
2392
 
2449
2393
  return `${[
2450
- 'import { createClientRouter, createMatcher, mergeHeads, resolveMeta } from "@nifrajs/web"',
2394
+ // `/client`, never the root: the root's graph carries the server (renderPage, the static-file
2395
+ // server), and Vite's dev server evaluates what it is given instead of tree-shaking it.
2396
+ 'import { createClientRouter, createMatcher, mergeHeads, resolveMeta } from "@nifrajs/web/client"',
2451
2397
  'import { applyHead, installForms, installHistory, signalHydrated } from "@nifrajs/web/client"',
2452
2398
  // Namespace import: `errorBoundary` is optional (an adapter may not export it). A namespace member
2453
2399
  // access yields `undefined` if absent - unlike a named import, which would be a link error.
2454
2400
  `import * as __adapter from ${JSON.stringify(clientModule)}`,
2455
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
+ "}",
2456
2412
  // The `clientModule` contract is a string specifier, so nothing type-checks that the module it
2457
2413
  // names actually exports `mountRouter`. Without this the miss surfaces as
2458
2414
  // "mountRouter is not a function" from inside a bundled chunk, at first paint, naming neither the
@@ -2464,6 +2420,16 @@ export function generateClientEntry(
2464
2420
  '`/client` entry (e.g. "@nifrajs/web-react/client"), or re-export mountRouter from it.',
2465
2421
  )})`,
2466
2422
  "const errorBoundary = __adapter.errorBoundary",
2423
+ // A hot update that the framework cannot actually apply must end in a reload, not in nothing.
2424
+ //
2425
+ // Bun's dev server applies React Fast Refresh to JSX modules. For React that patches the component
2426
+ // and preserves state. For Preact it does not - the module still LOOKS Fast-Refresh-able, so the
2427
+ // update is accepted and then swallowed: `bun:beforeUpdate`/`bun:afterUpdate` both fire, the server
2428
+ // logs a rebuild, and the page silently keeps rendering the old code. An edit that appears to do
2429
+ // nothing is the worst failure mode in a dev loop - worse than a reload, because there is no signal
2430
+ // to act on. An adapter that knows its framework is in that position says so, and the entry turns
2431
+ // the swallowed update into an honest full reload.
2432
+ 'if (import.meta.hot && __adapter.hotUpdateNeedsReload) import.meta.hot.on("bun:afterUpdate", () => location.reload())',
2467
2433
  `const errorRouteIds = new Set(${JSON.stringify(errorRouteIds)})`,
2468
2434
  // Each route is a lazy loader: dynamic imports → Bun.build (splitting) emits one chunk per
2469
2435
  // route, shared layouts/deps deduped into shared chunks, so a route's code loads only when
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Head resolution + merging, in its own module so the CLIENT can have it without the server.
3
+ *
4
+ * These two are pure functions over `Meta`, but they used to live in `index.ts` - the module that also
5
+ * carries `renderPage`, `createWebApp` and the static-file server. The generated client entry imported
6
+ * them from `@nifrajs/web`, which under a bundler was harmless (server code tree-shakes away) and under
7
+ * Vite's dev server was fatal: it serves each module as-is, so pulling `index.ts` into the browser graph
8
+ * executed `public-dir.ts` and the page died on `Module "node:fs/promises" has been externalized`
9
+ * before hydrating. Splitting them out means the client entry never names the server module at all,
10
+ * rather than relying on a bundler to remove it afterwards.
11
+ *
12
+ * `index.ts` and `client.ts` both re-export from here, so the public API is unchanged in both places.
13
+ */
14
+ import type {
15
+ LinkDescriptor,
16
+ Meta,
17
+ MetaArgs,
18
+ MetaDescriptor,
19
+ MetaInput,
20
+ ScriptDescriptor,
21
+ UnsafeScriptDescriptor,
22
+ } from "../manifest.ts"
23
+
24
+ /** Resolve a route's `meta` export - a static object, or a function of the route's data/params. */
25
+ export function resolveMeta(meta: MetaInput | undefined, args: MetaArgs): Meta {
26
+ if (meta === undefined) return {}
27
+ return typeof meta === "function" ? meta(args) : meta
28
+ }
29
+
30
+ /**
31
+ * Merge a layout chain's heads, outermost first: `title`/`lang`/`dir` are nearest-wins, and
32
+ * `meta`/`link`/`script` concatenate in chain order.
33
+ *
34
+ * Returns a fresh object whose identity is stable per `heads` *content* only when every entry is a
35
+ * static (by-reference) `Meta` and there is exactly one - otherwise a new object each call. That is
36
+ * fine: `headTags`'s memo is keyed on identity, so a per-request merge simply recomputes (its
37
+ * content can vary with loader data anyway).
38
+ */
39
+ export function mergeHeads(heads: readonly Meta[]): Meta {
40
+ // Single-head fast path (a route with no layout `meta`, by far the common case) - return the
41
+ // resolved object by reference so headTags' identity-keyed memo hits across requests for static meta.
42
+ if (heads.length === 1) return heads[0] as Meta
43
+ let title: string | undefined
44
+ let lang: string | undefined
45
+ let dir: Meta["dir"]
46
+ const meta: MetaDescriptor[] = []
47
+ const link: LinkDescriptor[] = []
48
+ const script: ScriptDescriptor[] = []
49
+ const unsafeScript: UnsafeScriptDescriptor[] = []
50
+ for (const h of heads) {
51
+ if (h.title !== undefined) title = h.title // nearest-wins: later (more specific) overrides
52
+ if (h.lang !== undefined) lang = h.lang // nearest-wins, like title
53
+ if (h.dir !== undefined) dir = h.dir
54
+ if (h.meta !== undefined) meta.push(...h.meta)
55
+ if (h.link !== undefined) link.push(...h.link)
56
+ if (h.script !== undefined) script.push(...h.script) // concatenated like meta/link (outermost first)
57
+ if (h.unsafeScript !== undefined) unsafeScript.push(...h.unsafeScript)
58
+ }
59
+ // Build the result with only the fields that were actually contributed - an empty `meta`/`link`/
60
+ // `script` array would otherwise be a spurious (if harmless) key. A mutable local; the cast to `Meta`
61
+ // is sound because a key is assigned only when defined (so `exactOptionalPropertyTypes` never sees
62
+ // `undefined`).
63
+ const merged: {
64
+ title?: string
65
+ meta?: Meta["meta"]
66
+ link?: Meta["link"]
67
+ script?: Meta["script"]
68
+ unsafeScript?: Meta["unsafeScript"]
69
+ lang?: string
70
+ dir?: Meta["dir"]
71
+ } = {}
72
+ if (title !== undefined) merged.title = title
73
+ if (meta.length > 0) merged.meta = meta
74
+ if (link.length > 0) merged.link = link
75
+ if (script.length > 0) merged.script = script
76
+ if (unsafeScript.length > 0) merged.unsafeScript = unsafeScript
77
+ if (lang !== undefined) merged.lang = lang
78
+ if (dir !== undefined) merged.dir = dir
79
+ return merged as Meta
80
+ }
@@ -3,8 +3,7 @@
3
3
  *
4
4
  * The client build must never ship server-only code, or server-only config, to a browser. Three
5
5
  * conventions enforce that, and each has to mean the SAME thing in every pipeline that builds the client
6
- * - the Bun client build, the Vite dev server, the Vite production build, and `nifra dev --bun`'s refusal
7
- * (which cannot transform, so it refuses to start instead):
6
+ * - the Bun client build, the Vite dev server, the Vite production build, and `nifra dev --bun`:
8
7
  *
9
8
  * - `*.server` modules - emptied, so a secret / `node:` / native import subtree never reaches the client.
10
9
  * - `*.fn` modules - replaced with HTTP stubs, so the function bodies (and their imports) stay server-side.
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`