@nifrajs/web 2.9.1 → 2.11.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 (62) hide show
  1. package/dist/client.d.ts +12 -0
  2. package/dist/client.d.ts.map +1 -1
  3. package/dist/client.js +16 -0
  4. package/dist/client.js.map +1 -1
  5. package/dist/dev-ssr-graph.d.ts +33 -0
  6. package/dist/dev-ssr-graph.d.ts.map +1 -0
  7. package/dist/dev-ssr-graph.js +285 -0
  8. package/dist/dev-ssr-graph.js.map +1 -0
  9. package/dist/dev.d.ts.map +1 -1
  10. package/dist/dev.js +76 -18
  11. package/dist/dev.js.map +1 -1
  12. package/dist/index.d.ts +28 -25
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +53 -79
  15. package/dist/index.js.map +1 -1
  16. package/dist/internal/head-merge.d.ts +27 -0
  17. package/dist/internal/head-merge.d.ts.map +1 -0
  18. package/dist/internal/head-merge.js +65 -0
  19. package/dist/internal/head-merge.js.map +1 -0
  20. package/dist/internal/server-boundary.d.ts +1 -2
  21. package/dist/internal/server-boundary.d.ts.map +1 -1
  22. package/dist/internal/server-boundary.js +1 -2
  23. package/dist/internal/server-boundary.js.map +1 -1
  24. package/dist/isr.d.ts +32 -3
  25. package/dist/isr.d.ts.map +1 -1
  26. package/dist/isr.js +181 -16
  27. package/dist/isr.js.map +1 -1
  28. package/dist/manifest.d.ts +2 -0
  29. package/dist/manifest.d.ts.map +1 -1
  30. package/dist/manifest.js.map +1 -1
  31. package/dist/plugins/css-modules.d.ts +12 -0
  32. package/dist/plugins/css-modules.d.ts.map +1 -1
  33. package/dist/plugins/css-modules.js +0 -0
  34. package/dist/plugins/css-modules.js.map +1 -1
  35. package/dist/plugins/kit.d.ts +54 -0
  36. package/dist/plugins/kit.d.ts.map +1 -1
  37. package/dist/plugins/kit.js +66 -0
  38. package/dist/plugins/kit.js.map +1 -1
  39. package/dist/public-dir.d.ts.map +1 -1
  40. package/dist/public-dir.js +71 -4
  41. package/dist/public-dir.js.map +1 -1
  42. package/dist/route-manifest.d.ts +3 -1
  43. package/dist/route-manifest.d.ts.map +1 -1
  44. package/dist/route-manifest.js +3 -0
  45. package/dist/route-manifest.js.map +1 -1
  46. package/dist/vite.d.ts.map +1 -1
  47. package/dist/vite.js +27 -1
  48. package/dist/vite.js.map +1 -1
  49. package/package.json +3 -3
  50. package/src/client.ts +28 -0
  51. package/src/dev-ssr-graph.ts +311 -0
  52. package/src/dev.ts +81 -20
  53. package/src/index.ts +63 -71
  54. package/src/internal/head-merge.ts +80 -0
  55. package/src/internal/server-boundary.ts +1 -2
  56. package/src/isr.ts +216 -19
  57. package/src/manifest.ts +2 -0
  58. package/src/plugins/css-modules.ts +0 -0
  59. package/src/plugins/kit.ts +71 -0
  60. package/src/public-dir.ts +74 -4
  61. package/src/route-manifest.ts +10 -1
  62. package/src/vite.ts +29 -1
package/src/index.ts CHANGED
@@ -25,9 +25,13 @@ 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
- import { ISR_REVALIDATE_HEADER } from "./isr.ts"
34
+ import { ISR_REVALIDATE_HEADER, ISR_REVALIDATE_TAGS_HEADER, serializeISRTags } from "./isr.ts"
31
35
  import { generateLlmsTxt } from "./llms-txt.ts"
32
36
  import type {
33
37
  LayoutEntry,
@@ -83,6 +87,7 @@ export {
83
87
  type CachedResponse,
84
88
  type CacheStore,
85
89
  ISR_REVALIDATE_HEADER,
90
+ ISR_REVALIDATE_TAGS_HEADER,
86
91
  ISR_STATUS_HEADER,
87
92
  type ISRApp,
88
93
  type ISROptions,
@@ -194,7 +199,7 @@ export {
194
199
  type SubmitOptions,
195
200
  } from "./router.ts"
196
201
  // Deferred loader data (`defer()` + the `Deferred<T>` type) - consumed by the adapter's `<Await>`.
197
- export { type Deferred, defer }
202
+ export { type Deferred, defer, mergeHeads, resolveMeta }
198
203
 
199
204
  /** Phantom brand key for {@link ServerOnly}. A unique symbol so the brand can't be forged by a plain
200
205
  * object literal; `unique symbol` keys never appear in the emitted JS, so the brand is purely
@@ -310,6 +315,43 @@ export interface RenderAdapter {
310
315
  hydrationHead(nonce?: string): string
311
316
  }
312
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
+
313
355
  /** Global the server serializes loader data into; the client reads it to hydrate. */
314
356
  export const DATA_GLOBAL = "__NIFRA_DATA__"
315
357
  /** Per-layout loader data for hydration. Emitted ONLY when some layout in the chain has a loader, so
@@ -427,6 +469,8 @@ export interface RenderPageOptions {
427
469
  /** ISR: route freshness in seconds, emitted as the `x-nifra-isr-revalidate` header for a `withISR`
428
470
  * wrapper to read. Omit ⇒ no header (the wrapper's default TTL applies). */
429
471
  readonly revalidate?: number
472
+ /** ISR invalidation tags emitted as a bounded `x-nifra-isr-tags` header. */
473
+ readonly revalidateTags?: readonly string[]
430
474
  /** Matched route id; written to `window.__NIFRA_ROUTE__` so the client hydrates this chain. */
431
475
  readonly routeId?: string
432
476
  /** The matched route's decoded path params - surfaced to the page as `params` (via {@link RenderProps})
@@ -541,6 +585,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
541
585
  styles = [],
542
586
  prerenderedPaths = [],
543
587
  revalidate,
588
+ revalidateTags = [],
544
589
  routeId,
545
590
  status = 200,
546
591
  title = "nifra",
@@ -695,6 +740,8 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
695
740
  // dedicated header (not the action-revalidation `x-nifra-revalidate`) so the TTL channel never aliases
696
741
  // the client's path-list channel.
697
742
  if (revalidate !== undefined) headers[ISR_REVALIDATE_HEADER] = String(revalidate)
743
+ const serializedTags = serializeISRTags(revalidateTags)
744
+ if (serializedTags !== undefined) headers[ISR_REVALIDATE_TAGS_HEADER] = serializedTags
698
745
  const renderProps: RenderProps = {
699
746
  data: forComponent,
700
747
  actionData: actionSplit?.forComponent,
@@ -1281,12 +1328,6 @@ function headTags(head: Meta | undefined): string {
1281
1328
  return out
1282
1329
  }
1283
1330
 
1284
- /** Resolve a route's `meta` (static or a function of the loader data + params) to a {@link Meta}. */
1285
- export function resolveMeta(meta: MetaInput | undefined, args: MetaArgs): Meta {
1286
- if (meta === undefined) return {}
1287
- return typeof meta === "function" ? meta(args) : meta
1288
- }
1289
-
1290
1331
  /**
1291
1332
  * A `<link rel="canonical">` descriptor for a route's `meta.link`. The canonical URL tells search
1292
1333
  * engines which URL is authoritative for a page (deduping query-string / tracking variants).
@@ -1373,68 +1414,6 @@ export function unsafeInlineScript(
1373
1414
  }
1374
1415
  }
1375
1416
 
1376
- /**
1377
- * Merge a route's `<head>` contributions from its layout chain + the page into one {@link Meta}.
1378
- *
1379
- * The head contract (see {@link CreateWebAppOptions} and `LayoutEntry`): a `_layout.tsx` may export
1380
- * `meta` to put sitewide tags (`hreflang`, `preconnect`, a default `<title>`, …) on every page below
1381
- * it. `heads` is passed **outermost layout → … → innermost layout → page** (the same order as the
1382
- * render chain), and merges:
1383
- * - **`title`** - *nearest-wins*: the last defined value across the list, so the page overrides an
1384
- * inner layout, which overrides an outer one. A layout `title` is the section default; an undefined
1385
- * page `title` keeps it.
1386
- * - **`meta` / `link`** - *concatenated* in list order (outermost first, page last). Duplicate-tag
1387
- * de-duplication is the caller's concern; the framework emits exactly what's declared so a layout
1388
- * can ship N `<link rel="alternate" hreflang>` tags and a page can add its own canonical.
1389
- *
1390
- * Returns a fresh object whose identity is stable per `heads` *content* only when every entry is a
1391
- * static (by-reference) `Meta` and there is exactly one - otherwise a new object each call. That is
1392
- * fine: {@link headTags}'s memo is keyed on identity, so a per-request merge simply recomputes (its
1393
- * content can vary with loader data anyway).
1394
- */
1395
- export function mergeHeads(heads: readonly Meta[]): Meta {
1396
- // Single-head fast path (a route with no layout `meta`, by far the common case) - return the
1397
- // resolved object by reference so headTags' identity-keyed memo hits across requests for static meta.
1398
- if (heads.length === 1) return heads[0] as Meta
1399
- let title: string | undefined
1400
- let lang: string | undefined
1401
- let dir: Meta["dir"]
1402
- const meta: MetaDescriptor[] = []
1403
- const link: LinkDescriptor[] = []
1404
- const script: ScriptDescriptor[] = []
1405
- const unsafeScript: UnsafeScriptDescriptor[] = []
1406
- for (const h of heads) {
1407
- if (h.title !== undefined) title = h.title // nearest-wins: later (more specific) overrides
1408
- if (h.lang !== undefined) lang = h.lang // nearest-wins, like title
1409
- if (h.dir !== undefined) dir = h.dir
1410
- if (h.meta !== undefined) meta.push(...h.meta)
1411
- if (h.link !== undefined) link.push(...h.link)
1412
- if (h.script !== undefined) script.push(...h.script) // concatenated like meta/link (outermost first)
1413
- if (h.unsafeScript !== undefined) unsafeScript.push(...h.unsafeScript)
1414
- }
1415
- // Build the result with only the fields that were actually contributed - an empty `meta`/`link`/
1416
- // `script` array would otherwise be a spurious (if harmless) key. A mutable local; the cast to `Meta`
1417
- // is sound because a key is assigned only when defined (so `exactOptionalPropertyTypes` never sees
1418
- // `undefined`).
1419
- const merged: {
1420
- title?: string
1421
- meta?: Meta["meta"]
1422
- link?: Meta["link"]
1423
- script?: Meta["script"]
1424
- unsafeScript?: Meta["unsafeScript"]
1425
- lang?: string
1426
- dir?: Meta["dir"]
1427
- } = {}
1428
- if (title !== undefined) merged.title = title
1429
- if (meta.length > 0) merged.meta = meta
1430
- if (link.length > 0) merged.link = link
1431
- if (script.length > 0) merged.script = script
1432
- if (unsafeScript.length > 0) merged.unsafeScript = unsafeScript
1433
- if (lang !== undefined) merged.lang = lang
1434
- if (dir !== undefined) merged.dir = dir
1435
- return merged as Meta
1436
- }
1437
-
1438
1417
  export interface CreateWebAppOptions {
1439
1418
  readonly adapter: RenderAdapter
1440
1419
  readonly manifest: Manifest
@@ -2225,6 +2204,7 @@ export function createWebApp<Env = unknown>(
2225
2204
  ...stylesOf(route.id),
2226
2205
  prerenderedPaths: options.prerenderedPaths ?? [],
2227
2206
  ...(mod.revalidate !== undefined ? { revalidate: mod.revalidate } : {}),
2207
+ ...(mod.revalidateTags !== undefined ? { revalidateTags: mod.revalidateTags } : {}),
2228
2208
  ...(mod.islandScripts !== undefined ? { islandScripts: mod.islandScripts } : {}),
2229
2209
  ...titleOption,
2230
2210
  ...assemblyCacheFor(mod, chainLayoutModules),
@@ -2440,7 +2420,9 @@ export function generateClientEntry(
2440
2420
  }
2441
2421
 
2442
2422
  return `${[
2443
- 'import { createClientRouter, createMatcher, mergeHeads, resolveMeta } from "@nifrajs/web"',
2423
+ // `/client`, never the root: the root's graph carries the server (renderPage, the static-file
2424
+ // server), and Vite's dev server evaluates what it is given instead of tree-shaking it.
2425
+ 'import { createClientRouter, createMatcher, mergeHeads, resolveMeta } from "@nifrajs/web/client"',
2444
2426
  'import { applyHead, installForms, installHistory, signalHydrated } from "@nifrajs/web/client"',
2445
2427
  // Namespace import: `errorBoundary` is optional (an adapter may not export it). A namespace member
2446
2428
  // access yields `undefined` if absent - unlike a named import, which would be a link error.
@@ -2457,6 +2439,16 @@ export function generateClientEntry(
2457
2439
  '`/client` entry (e.g. "@nifrajs/web-react/client"), or re-export mountRouter from it.',
2458
2440
  )})`,
2459
2441
  "const errorBoundary = __adapter.errorBoundary",
2442
+ // A hot update that the framework cannot actually apply must end in a reload, not in nothing.
2443
+ //
2444
+ // Bun's dev server applies React Fast Refresh to JSX modules. For React that patches the component
2445
+ // and preserves state. For Preact it does not - the module still LOOKS Fast-Refresh-able, so the
2446
+ // update is accepted and then swallowed: `bun:beforeUpdate`/`bun:afterUpdate` both fire, the server
2447
+ // logs a rebuild, and the page silently keeps rendering the old code. An edit that appears to do
2448
+ // nothing is the worst failure mode in a dev loop - worse than a reload, because there is no signal
2449
+ // to act on. An adapter that knows its framework is in that position says so, and the entry turns
2450
+ // the swallowed update into an honest full reload.
2451
+ 'if (import.meta.hot && __adapter.hotUpdateNeedsReload) import.meta.hot.on("bun:afterUpdate", () => location.reload())',
2460
2452
  `const errorRouteIds = new Set(${JSON.stringify(errorRouteIds)})`,
2461
2453
  // Each route is a lazy loader: dynamic imports → Bun.build (splitting) emits one chunk per
2462
2454
  // 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.