@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/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
  /**
Binary file
@@ -11,6 +11,16 @@ import type { BunPlugin } from "bun"
11
11
  /** The argument Bun passes to a plugin's `setup` - Bun doesn't export the type, so derive it. */
12
12
  export type PluginBuilder = Parameters<BunPlugin["setup"]>[0]
13
13
 
14
+ /**
15
+ * Re-keys the app-owned imports of a module the SERVER half of a plugin just compiled, so an edit below
16
+ * a route reaches SSR instead of being served from Bun's import cache. A plugin that claims a file
17
+ * extension is the only code that ever sees that file's source, so it is the only place its imports can
18
+ * be versioned - every `generate: "ssr"` loader should return its output through this.
19
+ *
20
+ * A no-op on the client half and outside a dev server. See `../dev-ssr-graph.ts` for the whole design.
21
+ */
22
+ export { rewriteSsrImports } from "../dev-ssr-graph.ts"
23
+
14
24
  const packageRootCache = new Map<string, string>()
15
25
 
16
26
  /** The nearest ancestor directory of `startDir` that holds a `package.json` (the file's package root),
@@ -57,6 +67,67 @@ export function hash8(input: string): string {
57
67
  return h.toString(16).padStart(8, "0")
58
68
  }
59
69
 
70
+ /**
71
+ * Env flag a nifra dev server sets for its own lifetime, so a framework compiler plugin can tell a dev
72
+ * compile from a `nifra build` one. Exported for the dev server that sets it; plugins ask
73
+ * {@link devServerCompile} instead of reading it.
74
+ */
75
+ export const DEV_HMR_ENV = "NIFRA_DEV_HMR"
76
+
77
+ /**
78
+ * True while a nifra dev server is running, i.e. the compile may emit HMR wiring (and, where the
79
+ * framework couples the two, dev-mode runtime output).
80
+ *
81
+ * The phase cannot be a plugin constructor argument: an app builds its `clientPlugins` once in
82
+ * `nifra.config.ts` and the same plugin objects serve both `nifra dev` and `nifra build`. Guarding the
83
+ * emitted code with `if (import.meta.hot)` is not sufficient either - `Bun.build` keeps the branch, so
84
+ * the HMR calls were measured shipping in production client chunks. The dev server announces itself in
85
+ * the environment instead, and `nifra build` never sets it. Read per compile (not captured at
86
+ * registration) so the flag is honoured however late the server sets it.
87
+ */
88
+ export function devServerCompile(): boolean {
89
+ return process.env[DEV_HMR_ENV] === "1"
90
+ }
91
+
92
+ /**
93
+ * The app root and the routes directory of the running dev server, for plugins that need to tell one of
94
+ * the app's own components from a dependency or a route module. Set alongside {@link DEV_HMR_ENV}; read
95
+ * through {@link devHotComponent}.
96
+ */
97
+ export const DEV_ROOT_ENV = "NIFRA_DEV_ROOT"
98
+ export const DEV_ROUTES_ENV = "NIFRA_DEV_ROUTES"
99
+
100
+ const under = (path: string, dir: string | undefined): boolean =>
101
+ dir !== undefined &&
102
+ dir !== "" &&
103
+ (path === dir || path.startsWith(dir.endsWith("/") ? dir : `${dir}/`))
104
+
105
+ /**
106
+ * Whether a dev compile of `path` may wrap the module in component-level hot-patching - i.e. whether it
107
+ * is one of the app's OWN components rather than a dependency or a route module.
108
+ *
109
+ * The exclusions are not caution, they are two things such a wrapper cannot survive:
110
+ *
111
+ * - a **dependency**, whose components are the framework's own composition machinery. An adapter folds
112
+ * a layout chain through them, so they are entered from a snippet or as a dynamic component - and a
113
+ * wrapper there sits between the SSR markup and the component that is supposed to claim it.
114
+ * - a **route module**, for the same reason one level down: a route or layout IS a chain member, so it
115
+ * is always the dynamic child. It is also the wrong boundary to patch - a route module carries the
116
+ * loader and meta the server ran, and re-running those is a navigation, not a patch. Editing one
117
+ * finds no accepting module and reloads the page, which is the correct outcome.
118
+ *
119
+ * Both cost nothing in practice: the components an edit loop actually touches are the views, and a view
120
+ * that wants to hot-patch lives outside the routes directory. Where the split is unknown (no dev server,
121
+ * or a root that was never announced), the answer is `false` - a full reload is always correct, a
122
+ * mispatched tree is not.
123
+ */
124
+ export function devHotComponent(path: string): boolean {
125
+ const root = process.env[DEV_ROOT_ENV]
126
+ if (!under(path, root)) return false
127
+ if (path.includes("/node_modules/")) return false
128
+ return !under(path, process.env[DEV_ROUTES_ENV])
129
+ }
130
+
60
131
  /**
61
132
  * Records compiled CSS and wires it into the client bundle through a virtual `?<namespace>` module -
62
133
  * the idiom the Vue plugin established (`?vue-css`). Register one per plugin `setup`; call `emit` per
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Framework-agnostic rendering protocol shared by the web runtime and every render adapter.
3
+ *
4
+ * This module is intentionally small and has no runtime dependencies. Keeping the protocol and the
5
+ * dev SSR-loader singleton here lets adapters and conformance tests depend on the seam directly
6
+ * without importing the document renderer through the root module. The singleton is stored on
7
+ * globalThis because the Bun and Vite dev pipelines can evaluate two copies in one process.
8
+ */
9
+ import type { Submission } from "./router.ts"
10
+
11
+ /** The data handed to a route component. */
12
+ export interface RenderProps {
13
+ readonly data: unknown
14
+ /** Per-layout loader data, aligned index-for-index with the layout prefix of `chain`. */
15
+ readonly layoutData?: readonly unknown[]
16
+ readonly actionData?: unknown
17
+ /** True while a client navigation or submit is in flight (client-only; absent/false on SSR). */
18
+ readonly pending?: boolean
19
+ /** The path a client navigation is transitioning TO while `pending` (client-only; absent on SSR). */
20
+ readonly pendingPath?: string
21
+ /** The in-flight client submit, for optimistic UI (client-only; absent on SSR). */
22
+ readonly submission?: Submission
23
+ /** The matched route's decoded path params. */
24
+ readonly params?: Readonly<Record<string, string>>
25
+ /** The current URL's `pathname + search` (no hash). */
26
+ readonly path?: string
27
+ /** The route's typed, validated search params. */
28
+ readonly search?: Record<string, unknown>
29
+ }
30
+
31
+ /**
32
+ * The seam every render adapter implements. New adapters should prove these invariants with
33
+ * `assertRenderAdapterConformance`; framework-specific behavior remains locally tested.
34
+ */
35
+ export interface RenderAdapter {
36
+ /**
37
+ * Render a route's layout `chain` (outermost layout → page) to a Web stream of HTML bytes,
38
+ * including the framework's hydration markers.
39
+ */
40
+ renderToStream(
41
+ chain: readonly unknown[],
42
+ props: RenderProps,
43
+ ): ReadableStream<Uint8Array> | Promise<ReadableStream<Uint8Array>>
44
+ /** Render the chain to a complete HTML string when no content is deferred. */
45
+ renderToString?(chain: readonly unknown[], props: RenderProps): string | Promise<string>
46
+ /** Per-document bootstrap markup injected into `<head>` for client hydration. */
47
+ hydrationHead(nonce?: string): string
48
+ }
49
+
50
+ /** Loads a module through the dev server's module graph rather than the runtime's resolver. */
51
+ export type SsrModuleLoader = (id: string) => Promise<unknown>
52
+
53
+ const SSR_MODULE_LOADER_SLOT = Symbol.for("nifra.web.ssr-module-loader")
54
+ const loaderSlot = globalThis as { [SSR_MODULE_LOADER_SLOT]?: SsrModuleLoader | undefined }
55
+
56
+ /** Publish or clear the dev server's SSR module loader. */
57
+ export function setSsrModuleLoader(load: SsrModuleLoader | undefined): void {
58
+ loaderSlot[SSR_MODULE_LOADER_SLOT] = load
59
+ }
60
+
61
+ /** Return the dev server's SSR module loader, when one owns SSR resolution. */
62
+ export function ssrModuleLoader(): SsrModuleLoader | undefined {
63
+ return loaderSlot[SSR_MODULE_LOADER_SLOT]
64
+ }
65
+
66
+ /** Global the server serializes loader data into; the client reads it to hydrate. */
67
+ export const DATA_GLOBAL = "__NIFRA_DATA__"
68
+ /** Per-layout loader data for hydration. */
69
+ export const LAYOUT_DATA_GLOBAL = "__NIFRA_LAYOUT_DATA__"
70
+ /** Global the server writes the matched route id into. */
71
+ export const ROUTE_GLOBAL = "__NIFRA_ROUTE__"
72
+ /** Global the server serializes an action's data return into (absent on GETs); the client reads it
73
+ * so hydration after a native form POST matches the server-rendered markup. */
74
+ export const ACTION_GLOBAL = "__NIFRA_ACTION__"
75
+ /** Marker attribute used to find a non-default hydration container. */
76
+ export const ROOT_ATTRIBUTE = "data-nifra-root"
package/src/vite.ts CHANGED
@@ -20,9 +20,11 @@ import { relative, resolve as resolvePath } from "node:path"
20
20
  import { createDevDiagnostics } from "./dev-diagnostics.ts"
21
21
  import { listenOrExplain } from "./dev-port.ts"
22
22
  import { discoverRoutes } from "./fs.ts"
23
- import { DEFAULT_DEV_PORT, generateClientEntry } from "./index.ts"
23
+ import { DEFAULT_DEV_PORT, generateClientEntry, setSsrModuleLoader } from "./index.ts"
24
24
  import { vitePublicEnvPrefix } from "./internal/server-boundary.ts"
25
25
  import { importVite } from "./internal/vite-import.ts"
26
+ import { scopedName } from "./plugins/css-modules.ts"
27
+ import { DEV_ROOT_ENV, DEV_ROUTES_ENV, reproduciblePath } from "./plugins/kit.ts"
26
28
  import { viteServerFnStub } from "./plugins/vite-server-fn.ts"
27
29
  import { viteServerOnlyEmpty } from "./plugins/vite-server-only.ts"
28
30
 
@@ -296,6 +298,12 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
296
298
  const routesDir = resolvePath(options.routesDir)
297
299
  const port = options.port ?? DEFAULT_DEV_PORT
298
300
 
301
+ // Which files are the app's own components, for framework plugins that hot-patch at component
302
+ // granularity (`devHotComponent`). Announced in the environment rather than passed, because the
303
+ // plugin that reads it is constructed by the app, in its own config, before this server exists.
304
+ process.env[DEV_ROOT_ENV] = root
305
+ process.env[DEV_ROUTES_ENV] = routesDir
306
+
299
307
  // Codegen the client entry with Vite-servable, root-relative specifiers (e.g. `/routes/index.tsx`).
300
308
  const toUrl = (file: string): string => `/${relative(root, `${routesDir}/${file}`)}`
301
309
  const writeClientEntry = (): void => {
@@ -442,10 +450,26 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
442
450
  externalConditions: ssrConditions,
443
451
  },
444
452
  },
453
+ css: {
454
+ modules: {
455
+ // Vite ships its own CSS-Modules naming (`_name_<base64>`); the Bun pipeline and every
456
+ // `nifra build` use `scopedName`. Left alone, one class is called two different things
457
+ // depending on which pipeline served it, so a selector written against a generated name works
458
+ // in dev and vanishes in production - and the two dev pipelines disagree with each other. The
459
+ // name is defined once, in the plugin, and Vite is handed that definition.
460
+ generateScopedName: (name: string, filename: string): string =>
461
+ scopedName(reproduciblePath(filename), name),
462
+ },
463
+ },
445
464
  ...(options.define ? { define: options.define } : {}),
446
465
  })
447
466
 
448
467
  const ssrLoad = (absolutePath: string): Promise<unknown> => vite.ssrLoadModule(absolutePath)
468
+ // Published so an ADAPTER can reach this graph too. Route modules already load through it; an
469
+ // adapter that has to load a compiled asset of its own on the server (`@nifrajs/web-svelte` and its
470
+ // `Chain.svelte`) would otherwise take it from the runtime, which has no compiler for it and no
471
+ // reason to agree with Vite about which copy of the framework runtime it renders through.
472
+ setSsrModuleLoader(ssrLoad)
449
473
  app = await options.createApp(entryUrl, ssrLoad)
450
474
 
451
475
  // Re-create the app on change so a hard reload picks up a route ADD/REMOVE (the manifest comes
@@ -486,6 +510,10 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
486
510
  return {
487
511
  port: typeof address === "object" && address !== null ? address.port : port,
488
512
  stop: async () => {
513
+ // Cleared with the server that owns it: the slot is process-global, so a stopped server leaving
514
+ // its loader behind would hand the next one's adapter a graph that is closed (tests start and
515
+ // stop several dev servers in one process).
516
+ setSsrModuleLoader(undefined)
489
517
  server.close()
490
518
  await vite.close()
491
519
  },