@nifrajs/web 2.1.0 → 2.3.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 (194) hide show
  1. package/README.md +3 -3
  2. package/dist/build-vite.d.ts +68 -0
  3. package/dist/build-vite.d.ts.map +1 -0
  4. package/dist/build-vite.js +433 -0
  5. package/dist/build-vite.js.map +1 -0
  6. package/dist/build.d.ts +174 -65
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/build.js +376 -135
  9. package/dist/build.js.map +1 -1
  10. package/dist/bun-dev-entry.d.ts +83 -0
  11. package/dist/bun-dev-entry.d.ts.map +1 -0
  12. package/dist/bun-dev-entry.js +136 -0
  13. package/dist/bun-dev-entry.js.map +1 -0
  14. package/dist/chunk-names.js +1 -1
  15. package/dist/client.d.ts +3 -9
  16. package/dist/client.d.ts.map +1 -1
  17. package/dist/client.js +209 -27
  18. package/dist/client.js.map +1 -1
  19. package/dist/conformance.d.ts +2 -2
  20. package/dist/conformance.js +3 -3
  21. package/dist/deferred.d.ts +11 -11
  22. package/dist/deferred.d.ts.map +1 -1
  23. package/dist/deferred.js +16 -16
  24. package/dist/dev-error.d.ts +2 -2
  25. package/dist/dev-error.js +3 -3
  26. package/dist/dev-port.d.ts +36 -0
  27. package/dist/dev-port.d.ts.map +1 -0
  28. package/dist/dev-port.js +48 -0
  29. package/dist/dev-port.js.map +1 -0
  30. package/dist/dev.d.ts +62 -5
  31. package/dist/dev.d.ts.map +1 -1
  32. package/dist/dev.js +419 -60
  33. package/dist/dev.js.map +1 -1
  34. package/dist/draft.d.ts +44 -7
  35. package/dist/draft.d.ts.map +1 -1
  36. package/dist/draft.js +92 -9
  37. package/dist/draft.js.map +1 -1
  38. package/dist/fn-state.d.ts +56 -0
  39. package/dist/fn-state.d.ts.map +1 -0
  40. package/dist/fn-state.js +77 -0
  41. package/dist/fn-state.js.map +1 -0
  42. package/dist/fn.d.ts +111 -0
  43. package/dist/fn.d.ts.map +1 -0
  44. package/dist/fn.js +125 -0
  45. package/dist/fn.js.map +1 -0
  46. package/dist/fonts-google.d.ts +9 -9
  47. package/dist/fonts-google.js +10 -10
  48. package/dist/fonts.d.ts +7 -7
  49. package/dist/fonts.js +1 -1
  50. package/dist/forms.d.ts +8 -8
  51. package/dist/fs.d.ts +15 -2
  52. package/dist/fs.d.ts.map +1 -1
  53. package/dist/fs.js +8 -2
  54. package/dist/fs.js.map +1 -1
  55. package/dist/index.d.ts +266 -74
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +869 -179
  58. package/dist/index.js.map +1 -1
  59. package/dist/internal/head-attributes.d.ts +15 -0
  60. package/dist/internal/head-attributes.d.ts.map +1 -0
  61. package/dist/internal/head-attributes.js +99 -0
  62. package/dist/internal/head-attributes.js.map +1 -0
  63. package/dist/internal/script-types.d.ts +14 -0
  64. package/dist/internal/script-types.d.ts.map +1 -0
  65. package/dist/internal/script-types.js +17 -0
  66. package/dist/internal/script-types.js.map +1 -0
  67. package/dist/internal/server-boundary.d.ts +60 -0
  68. package/dist/internal/server-boundary.d.ts.map +1 -0
  69. package/dist/internal/server-boundary.js +174 -0
  70. package/dist/internal/server-boundary.js.map +1 -0
  71. package/dist/internal/timing-safe-equal.d.ts +16 -0
  72. package/dist/internal/timing-safe-equal.d.ts.map +1 -0
  73. package/dist/internal/timing-safe-equal.js +26 -0
  74. package/dist/internal/timing-safe-equal.js.map +1 -0
  75. package/dist/internal/vite-import.d.ts +63 -0
  76. package/dist/internal/vite-import.d.ts.map +1 -0
  77. package/dist/internal/vite-import.js +94 -0
  78. package/dist/internal/vite-import.js.map +1 -0
  79. package/dist/islands.d.ts +5 -5
  80. package/dist/islands.js +5 -5
  81. package/dist/isr.d.ts +15 -15
  82. package/dist/isr.d.ts.map +1 -1
  83. package/dist/isr.js +8 -21
  84. package/dist/isr.js.map +1 -1
  85. package/dist/llms-txt.d.ts.map +1 -1
  86. package/dist/llms-txt.js +12 -6
  87. package/dist/llms-txt.js.map +1 -1
  88. package/dist/manifest.d.ts +121 -40
  89. package/dist/manifest.d.ts.map +1 -1
  90. package/dist/manifest.js +136 -26
  91. package/dist/manifest.js.map +1 -1
  92. package/dist/module-graph.d.ts +91 -0
  93. package/dist/module-graph.d.ts.map +1 -0
  94. package/dist/module-graph.js +92 -0
  95. package/dist/module-graph.js.map +1 -0
  96. package/dist/navigation.d.ts +120 -5
  97. package/dist/navigation.d.ts.map +1 -1
  98. package/dist/navigation.js +37 -5
  99. package/dist/navigation.js.map +1 -1
  100. package/dist/plugins/css-modules.d.ts +3 -3
  101. package/dist/plugins/css-modules.d.ts.map +1 -1
  102. package/dist/plugins/css-modules.js +0 -0
  103. package/dist/plugins/css-modules.js.map +1 -1
  104. package/dist/plugins/kit.d.ts +8 -8
  105. package/dist/plugins/kit.js +9 -9
  106. package/dist/plugins/scss.js +3 -3
  107. package/dist/plugins/vite-leak-guard.d.ts +45 -0
  108. package/dist/plugins/vite-leak-guard.d.ts.map +1 -0
  109. package/dist/plugins/vite-leak-guard.js +67 -0
  110. package/dist/plugins/vite-leak-guard.js.map +1 -0
  111. package/dist/plugins/vite-server-fn.d.ts +23 -0
  112. package/dist/plugins/vite-server-fn.d.ts.map +1 -0
  113. package/dist/plugins/vite-server-fn.js +51 -0
  114. package/dist/plugins/vite-server-fn.js.map +1 -0
  115. package/dist/plugins/vite-server-only.d.ts +49 -0
  116. package/dist/plugins/vite-server-only.d.ts.map +1 -0
  117. package/dist/plugins/vite-server-only.js +57 -0
  118. package/dist/plugins/vite-server-only.js.map +1 -0
  119. package/dist/prerender.d.ts +5 -5
  120. package/dist/prerender.js +7 -7
  121. package/dist/public-dir.d.ts +36 -0
  122. package/dist/public-dir.d.ts.map +1 -0
  123. package/dist/public-dir.js +89 -0
  124. package/dist/public-dir.js.map +1 -0
  125. package/dist/query.d.ts +10 -10
  126. package/dist/query.js +15 -15
  127. package/dist/route-manifest.d.ts +91 -0
  128. package/dist/route-manifest.d.ts.map +1 -0
  129. package/dist/route-manifest.js +128 -0
  130. package/dist/route-manifest.js.map +1 -0
  131. package/dist/router.d.ts +94 -22
  132. package/dist/router.d.ts.map +1 -1
  133. package/dist/router.js +170 -58
  134. package/dist/router.js.map +1 -1
  135. package/dist/search.d.ts +94 -0
  136. package/dist/search.d.ts.map +1 -0
  137. package/dist/search.js +232 -0
  138. package/dist/search.js.map +1 -0
  139. package/dist/server-only.d.ts +6 -6
  140. package/dist/server-only.js +7 -7
  141. package/dist/service-worker.d.ts +56 -0
  142. package/dist/service-worker.d.ts.map +1 -0
  143. package/dist/service-worker.js +127 -0
  144. package/dist/service-worker.js.map +1 -0
  145. package/dist/vite.d.ts +25 -17
  146. package/dist/vite.d.ts.map +1 -1
  147. package/dist/vite.js +75 -33
  148. package/dist/vite.js.map +1 -1
  149. package/package.json +48 -4
  150. package/server-manifest.d.ts +31 -0
  151. package/src/build-vite.ts +553 -0
  152. package/src/build.ts +563 -172
  153. package/src/bun-dev-entry.ts +184 -0
  154. package/src/chunk-names.ts +1 -1
  155. package/src/client.ts +221 -30
  156. package/src/conformance.ts +3 -3
  157. package/src/deferred.ts +17 -17
  158. package/src/dev-error.ts +3 -3
  159. package/src/dev-port.ts +63 -0
  160. package/src/dev.ts +468 -66
  161. package/src/draft.ts +124 -12
  162. package/src/fn-state.ts +107 -0
  163. package/src/fn.ts +199 -0
  164. package/src/fonts-google.ts +13 -13
  165. package/src/fonts.ts +7 -7
  166. package/src/forms.ts +8 -8
  167. package/src/fs.ts +24 -6
  168. package/src/index.ts +1174 -225
  169. package/src/internal/head-attributes.ts +109 -0
  170. package/src/internal/script-types.ts +18 -0
  171. package/src/internal/server-boundary.ts +187 -0
  172. package/src/internal/timing-safe-equal.ts +24 -0
  173. package/src/internal/vite-import.ts +92 -0
  174. package/src/islands.ts +7 -7
  175. package/src/isr.ts +18 -30
  176. package/src/llms-txt.ts +13 -5
  177. package/src/manifest.ts +264 -48
  178. package/src/module-graph.ts +146 -0
  179. package/src/navigation.ts +151 -5
  180. package/src/plugins/css-modules.ts +0 -0
  181. package/src/plugins/kit.ts +11 -11
  182. package/src/plugins/scss.ts +3 -3
  183. package/src/plugins/vite-leak-guard.ts +110 -0
  184. package/src/plugins/vite-server-fn.ts +67 -0
  185. package/src/plugins/vite-server-only.ts +75 -0
  186. package/src/prerender.ts +10 -10
  187. package/src/public-dir.ts +108 -0
  188. package/src/query.ts +22 -22
  189. package/src/route-manifest.ts +240 -0
  190. package/src/router.ts +288 -74
  191. package/src/search.ts +297 -0
  192. package/src/server-only.ts +7 -7
  193. package/src/service-worker.ts +165 -0
  194. package/src/vite.ts +103 -49
@@ -0,0 +1,146 @@
1
+ /**
2
+ * A bundler-neutral view of a client build, for the guards that must run on every pipeline.
3
+ *
4
+ * nifra's two client-leak guards - server-only code reaching the browser, and `node:` builtins in
5
+ * client code - are the reason a pipeline choice is safe. They are security guards, not lints: one
6
+ * stops secrets and database access shipping to a browser. Today both read Bun's metafile directly,
7
+ * so a second production pipeline would either arrive without them or need them hastily ported, and
8
+ * "mostly ported" is the wrong outcome for a guard of that kind.
9
+ *
10
+ * This is the seam that prevents that. It is introduced while Bun is still the ONLY producer, so the
11
+ * adapter can be verified against the existing behaviour rather than written under pressure beside a
12
+ * new bundler. Adding Rollup later becomes one more `from…` function rather than a second copy of the
13
+ * detection logic.
14
+ *
15
+ * Deliberately minimal - exactly what the guards read, nothing more:
16
+ * - which modules exist and what each imports (to walk the import chain to a sink)
17
+ * - which output chunk each module landed in (to name the chunk a leak reached)
18
+ */
19
+
20
+ /** One import edge, as the bundler recorded it. */
21
+ export interface GraphImport {
22
+ /** The resolved path, when the bundler resolved it. */
23
+ readonly path?: string
24
+ /** The specifier as written in source - the only form that survives an unresolved import. */
25
+ readonly original?: string
26
+ }
27
+
28
+ export interface GraphModule {
29
+ readonly imports: readonly GraphImport[]
30
+ }
31
+
32
+ export interface GraphChunk {
33
+ /** Source module this chunk is the entry for, if it is one. */
34
+ readonly entryPoint?: string
35
+ /** Module ids that landed in this chunk. */
36
+ readonly modules: readonly string[]
37
+ }
38
+
39
+ /** What a client build looks like to the guards, whichever bundler produced it. */
40
+ export interface ClientModuleGraph {
41
+ /** Module id → its imports. Ids are bundler-native (Bun graph keys, Rollup module ids). */
42
+ readonly modules: Readonly<Record<string, GraphModule>>
43
+ /** Output path → what it contains. */
44
+ readonly chunks: Readonly<Record<string, GraphChunk>>
45
+ }
46
+
47
+ /** The slice of Bun's metafile this seam consumes. Not yet in `@types/bun`; shape per the docs. */
48
+ export interface BunMetafileLike {
49
+ readonly inputs?: Readonly<
50
+ Record<string, { readonly imports?: ReadonlyArray<{ path?: string; original?: string }> }>
51
+ >
52
+ readonly outputs?: Readonly<
53
+ Record<
54
+ string,
55
+ { readonly entryPoint?: string; readonly inputs?: Readonly<Record<string, unknown>> }
56
+ >
57
+ >
58
+ }
59
+
60
+ /**
61
+ * Adapt a `Bun.build` metafile to the neutral graph.
62
+ *
63
+ * A total function: an absent or partial metafile yields an empty graph rather than throwing, because
64
+ * a guard that crashes on an unexpected build shape fails the build for the wrong reason. An empty
65
+ * graph reports no findings, which matches the existing behaviour when the metafile is missing.
66
+ */
67
+ export function fromBunMetafile(meta: BunMetafileLike | undefined): ClientModuleGraph {
68
+ const modules: Record<string, GraphModule> = {}
69
+ for (const [id, input] of Object.entries(meta?.inputs ?? {})) {
70
+ modules[id] = { imports: input.imports ?? [] }
71
+ }
72
+ const chunks: Record<string, GraphChunk> = {}
73
+ for (const [path, output] of Object.entries(meta?.outputs ?? {})) {
74
+ chunks[path] = {
75
+ ...(output.entryPoint !== undefined ? { entryPoint: output.entryPoint } : {}),
76
+ modules: Object.keys(output.inputs ?? {}),
77
+ }
78
+ }
79
+ return { modules, chunks }
80
+ }
81
+
82
+ /**
83
+ * The slice of a Rollup/Vite output bundle this seam consumes. `OutputBundle` is
84
+ * `Record<fileName, OutputChunk | OutputAsset>`; only chunks carry a module graph. Typed structurally
85
+ * (no `rollup`/`vite` type dependency) - the fields are stable Rollup output API.
86
+ */
87
+ export interface RollupChunkLike {
88
+ /** `"chunk"` for JS output, `"asset"` for CSS/static - assets have no module graph. */
89
+ readonly type?: string
90
+ /** The entry module id this chunk was built for, if it is an entry. Rollup's `facadeModuleId`. */
91
+ readonly facadeModuleId?: string | null
92
+ /** Every module id that landed in this chunk. Rollup's `moduleIds`. */
93
+ readonly moduleIds?: readonly string[]
94
+ }
95
+ export type RollupBundleLike = Readonly<Record<string, RollupChunkLike>>
96
+
97
+ /**
98
+ * Adapt a Rollup/Vite output bundle to the neutral graph - the second producer the seam was built for.
99
+ *
100
+ * The bundle records which modules landed in which chunk (`moduleIds`) but NOT each module's import
101
+ * edges, so those come from `importsOf`, which the caller backs with `this.getModuleInfo(id).importedIds`
102
+ * inside a plugin hook (a test backs it with a plain map). Edges carry only the RESOLVED id - Rollup does
103
+ * not keep the as-written specifier per edge - so `path` is set and `original` is left undefined. Both
104
+ * guards read the resolved `path` as their fallback (a `node:` prefix, the `server-only` basename), so
105
+ * detection is unaffected; only the human-readable chain shows resolved paths instead of as-written ones.
106
+ *
107
+ * Total, like {@link fromBunMetafile}: an empty bundle yields an empty graph (no findings), never a throw
108
+ * that would fail a build for the wrong reason.
109
+ */
110
+ export function fromRollupBundle(
111
+ bundle: RollupBundleLike,
112
+ importsOf: (moduleId: string) => readonly string[],
113
+ ): ClientModuleGraph {
114
+ const modules: Record<string, GraphModule> = {}
115
+ const chunks: Record<string, GraphChunk> = {}
116
+ for (const [fileName, output] of Object.entries(bundle)) {
117
+ // Assets (CSS, copied files) have no module graph; `type` is absent only on hand-built test inputs,
118
+ // which are always chunks, so treat "not explicitly an asset" as a chunk.
119
+ if (output.type === "asset") continue
120
+ const moduleIds = output.moduleIds ?? []
121
+ // Start from the real modules; `node:` builtins get appended below.
122
+ const chunkModules: string[] = [...moduleIds]
123
+ for (const id of moduleIds) {
124
+ const imports = importsOf(id).map((path) => ({ path }))
125
+ if (modules[id] === undefined) modules[id] = { imports }
126
+ // The guards locate a leak by finding the builtin IN a chunk's module list - Bun BUNDLES the `node:`
127
+ // polyfill, so the builtin appears there as an input. Rollup EXTERNALIZES `node:`, so it never lands
128
+ // in `moduleIds` and the guard would see nothing. Synthesize the Bun shape: register each imported
129
+ // builtin as a member of THIS chunk (where the leak is) and as a leaf module, so the neutral graph
130
+ // is identical whichever bundler produced it. Only `node:` needs this; a server-only-marked module
131
+ // is a real user module and is already a chunk member.
132
+ for (const im of imports) {
133
+ if (!im.path.startsWith("node:")) continue
134
+ if (!chunkModules.includes(im.path)) chunkModules.push(im.path)
135
+ if (modules[im.path] === undefined) modules[im.path] = { imports: [] }
136
+ }
137
+ }
138
+ chunks[fileName] = {
139
+ // `facadeModuleId` is null for a shared/common chunk (no single entry) - omit it then, matching
140
+ // how a non-entry Bun output omits `entryPoint`, so the guards' entry set stays the real entries.
141
+ ...(output.facadeModuleId ? { entryPoint: output.facadeModuleId } : {}),
142
+ modules: chunkModules,
143
+ }
144
+ }
145
+ return { modules, chunks }
146
+ }
package/src/navigation.ts CHANGED
@@ -1,23 +1,94 @@
1
1
  /**
2
- * `@nifrajs/web` navigation bridge — a tiny, DOM-free seam that lets an adapter's `useNavigate`
3
- * (a route component, which must import only from the agnostic `@nifrajs/web` main entry — never the
2
+ * `@nifrajs/web` navigation bridge - a tiny, DOM-free seam that lets an adapter's `useNavigate`
3
+ * (a route component, which must import only from the agnostic `@nifrajs/web` main entry - never the
4
4
  * DOM-only `/client`) reach the browser's history-aware navigate, WITHOUT the route component pulling
5
5
  * `@nifrajs/web/client` (and its `document`/`history` access) into a bundle that also renders on the
6
6
  * server.
7
7
  *
8
8
  * The browser layer (`installHistory`, in `./client.ts`) populates this on setup and clears it on
9
- * teardown; a framework binding reads it via {@link getBrowserNavigate}. Module-level singleton — the
9
+ * teardown; a framework binding reads it via {@link getBrowserNavigate}. Module-level singleton - the
10
10
  * browser mounts exactly one app per page (the same convention the adapters' `setMountedRouter` uses).
11
11
  * On the server (and before hydration) the getter returns `undefined`, so a binding degrades to the
12
- * native `<a href>` full-page navigation — progressive enhancement, no throw.
12
+ * native `<a href>` full-page navigation - progressive enhancement, no throw.
13
13
  */
14
14
 
15
+ import { serializeSearch } from "./search.ts"
16
+
15
17
  /** Options for a programmatic navigation. */
16
18
  export interface NavigateOptions {
17
19
  /** Replace the current history entry instead of pushing a new one (like `history.replaceState`). */
18
20
  readonly replace?: boolean
19
21
  }
20
22
 
23
+ /**
24
+ * The augmentable route -> search-type map for typed cross-route navigation. Empty by default (so an
25
+ * object-form navigate to any path is allowed with a loose `search`). A build step (`nifra sync-routes`)
26
+ * OR the app declares one entry per route path against its `searchSchema` output:
27
+ *
28
+ * ```ts
29
+ * declare module "@nifrajs/web" {
30
+ * interface RouteSearch {
31
+ * "/reports": { page: number; q: string }
32
+ * }
33
+ * }
34
+ * ```
35
+ *
36
+ * With that, `navigate({ to: "/reports", search: { page: 2 } })` type-checks `search` against
37
+ * `/reports`'s schema, and a wrong shape is a compile error. See {@link NavigateTarget}.
38
+ */
39
+ // biome-ignore lint/suspicious/noEmptyInterface: augmentation target - empty by design, apps/codegen fill it.
40
+ export interface RouteSearch {}
41
+
42
+ /**
43
+ * The `search` type for a navigate to `To`: the route's schema output when `To` is a mapped
44
+ * {@link RouteSearch} key, otherwise the loose `Record<string, unknown>` (so a navigate to any path is
45
+ * always allowed). Keyed on `To` rather than a union, so a mapped route can't fall back to the loose form
46
+ * with a wrong shape - `navigate({ to: "/reports", search: { page: "x" } })` is a compile error.
47
+ */
48
+ export type NavigateSearchOf<To extends string> = To extends keyof RouteSearch
49
+ ? RouteSearch[To]
50
+ : Record<string, unknown>
51
+
52
+ /** The runtime shape of an object-form navigate target (loose - the typed narrowing lives in
53
+ * {@link NavigateFunction}'s generic call signature). `to` is a bare pathname; `search` is serialized
54
+ * onto it; `replace` folds into the options. */
55
+ export interface NavigateTargetInput {
56
+ readonly to: string
57
+ readonly search?: Record<string, unknown>
58
+ readonly replace?: boolean
59
+ }
60
+
61
+ /**
62
+ * A programmatic navigate, shared by every adapter's `useNavigate`. Three forms: a string path (push, or
63
+ * replace via `{ replace: true }`), a history delta (`-1`/`1`), or an object target `{ to, search, replace }`
64
+ * whose `search` is typed against `to`'s route schema via {@link NavigateSearchOf} (a wrong shape for a
65
+ * mapped route is a compile error; an unmapped path takes a loose `search`). The object form's `search` is
66
+ * serialized onto `to`. A no-op on the server / before hydration.
67
+ */
68
+ export interface NavigateFunction {
69
+ (to: string | number, options?: NavigateOptions): void
70
+ <To extends string>(target: {
71
+ readonly to: To
72
+ readonly search?: NavigateSearchOf<To>
73
+ readonly replace?: boolean
74
+ }): void
75
+ }
76
+
77
+ /**
78
+ * Normalize a navigate argument to the bridge's `(to, options)`: a string path or history-delta passes
79
+ * through; an object target has its `search` serialized onto `to` and its `replace` folded into the
80
+ * options. The one place the object form becomes a URL, so every adapter's `navigate` resolves it
81
+ * identically.
82
+ */
83
+ export function resolveNavigate(
84
+ to: string | number | NavigateTargetInput,
85
+ options?: NavigateOptions,
86
+ ): { readonly to: string | number; readonly options: NavigateOptions | undefined } {
87
+ if (typeof to !== "object") return { to, options }
88
+ const query = to.search !== undefined ? serializeSearch(to.search) : ""
89
+ return { to: to.to + query, options: to.replace === true ? { replace: true } : options }
90
+ }
91
+
21
92
  /**
22
93
  * A history-aware navigate. A **string** `to` is a same-origin path (`/users/7?tab=a`) navigated to
23
94
  * (push, or replace with `{ replace: true }`); a **number** is a history delta (`-1` back, `1`
@@ -29,7 +100,7 @@ export type BrowserNavigate = (to: string | number, options?: NavigateOptions) =
29
100
  // per page; absent on the server and before hydration.
30
101
  let browserNavigate: BrowserNavigate | undefined
31
102
 
32
- /** Register (or clear, with `undefined`) the browser navigate — called by `installHistory`. Not for
103
+ /** Register (or clear, with `undefined`) the browser navigate - called by `installHistory`. Not for
33
104
  * app use. */
34
105
  export function setBrowserNavigate(navigate: BrowserNavigate | undefined): void {
35
106
  browserNavigate = navigate
@@ -40,3 +111,78 @@ export function setBrowserNavigate(navigate: BrowserNavigate | undefined): void
40
111
  export function getBrowserNavigate(): BrowserNavigate | undefined {
41
112
  return browserNavigate
42
113
  }
114
+
115
+ /** A parsed navigation target the {@link BlockerFunction} decides on. `pathname`/`search`/`hash` match
116
+ * the DOM `Location` shape (search keeps its `?`, hash its `#`). */
117
+ export interface BlockerLocation {
118
+ readonly pathname: string
119
+ readonly search: string
120
+ readonly hash: string
121
+ }
122
+
123
+ /**
124
+ * Decide whether a navigation should be halted. Receives where the app is (`currentLocation`) and where
125
+ * it's heading (`nextLocation`), so a guard can allow same-section moves and block only real exits. A
126
+ * boolean form (`useBlocker(isDirty)`) is sugar for `() => isDirty`. Runs synchronously at navigation
127
+ * time - return the current answer; the async "are you sure?" happens afterward via {@link Blocker}'s
128
+ * `proceed`/`reset`.
129
+ */
130
+ export type BlockerFunction = (args: {
131
+ readonly currentLocation: BlockerLocation
132
+ readonly nextLocation: BlockerLocation
133
+ }) => boolean
134
+
135
+ /**
136
+ * The blocker's lifecycle. `unblocked` - idle, nothing intercepted. `blocked` - a navigation was halted
137
+ * and is awaiting the app's decision (`proceed`/`reset` are live). `proceeding` - the app called
138
+ * `proceed`; the held navigation is being replayed.
139
+ */
140
+ export type BlockerState = "unblocked" | "blocked" | "proceeding"
141
+
142
+ /**
143
+ * A navigation guard, mirroring react-router's shape. When `state` is `blocked`, `proceed()` lets the
144
+ * held navigation through and `reset()` cancels it (staying put); both are `undefined` otherwise. The
145
+ * pair is what a boolean `when` can't express - the app shows its OWN async confirmation UI, then calls
146
+ * one of them, instead of the browser's synchronous `confirm()`.
147
+ */
148
+ export interface Blocker {
149
+ readonly state: BlockerState
150
+ readonly proceed: (() => void) | undefined
151
+ readonly reset: (() => void) | undefined
152
+ }
153
+
154
+ /** The idle blocker - a stable reference (no needless adapter re-renders while unblocked). */
155
+ export const IDLE_BLOCKER: Blocker = { state: "unblocked", proceed: undefined, reset: undefined }
156
+
157
+ /**
158
+ * The browser layer's blocker registry - installed by `installHistory` (which owns navigation and can
159
+ * therefore halt, restore, and replay it) and read by an adapter's `useBlocker` through
160
+ * {@link registerBlocker}. Kept here, DOM-free, for the same reason as {@link BrowserNavigate}: a route
161
+ * component's blocker hook must import only this agnostic entry.
162
+ */
163
+ export interface BlockerController {
164
+ register(shouldBlock: BlockerFunction, onChange: (blocker: Blocker) => void): () => void
165
+ }
166
+
167
+ // The active controller (set by `installHistory`, cleared on teardown). Module-scoped: one app per page.
168
+ let blockerController: BlockerController | undefined
169
+
170
+ /** Register (or clear, with `undefined`) the blocker controller - called by `installHistory`. Not for
171
+ * app use. */
172
+ export function setBlockerController(controller: BlockerController | undefined): void {
173
+ blockerController = controller
174
+ }
175
+
176
+ /**
177
+ * Register a navigation guard, returning an unregister function. `onChange` is called with the current
178
+ * {@link Blocker} whenever its state changes (so the adapter can re-render its confirmation UI). Before
179
+ * `installHistory` has run (SSR, pre-hydration), there's no controller: registration is a no-op and the
180
+ * caller stays on {@link IDLE_BLOCKER}, degrading to native navigation. Called by an adapter's
181
+ * `useBlocker`, not by app code directly.
182
+ */
183
+ export function registerBlocker(
184
+ shouldBlock: BlockerFunction,
185
+ onChange: (blocker: Blocker) => void,
186
+ ): () => void {
187
+ return blockerController?.register(shouldBlock, onChange) ?? (() => {})
188
+ }
Binary file
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `@nifrajs/web/plugins` shared kit — the small infrastructure every CSS-bearing asset plugin reuses, so
2
+ * `@nifrajs/web/plugins` shared kit - the small infrastructure every CSS-bearing asset plugin reuses, so
3
3
  * P1 (CSS Modules), P2 (SCSS), and any future P4 (LESS/Stylus/PostCSS) don't each re-implement the
4
4
  * CSS→client-bundle wiring, the deterministic hash, or the optional-peer guard. Internal to
5
5
  * `@nifrajs/web` (imported by the sibling plugin modules); not a public subpath.
@@ -8,7 +8,7 @@ import { existsSync } from "node:fs"
8
8
  import { dirname, relative } from "node:path"
9
9
  import type { BunPlugin } from "bun"
10
10
 
11
- /** The argument Bun passes to a plugin's `setup` — Bun doesn't export the type, so derive it. */
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
14
  const packageRootCache = new Map<string, string>()
@@ -23,7 +23,7 @@ function packageRootOf(startDir: string): string {
23
23
  if (existsSync(`${dir}/package.json`)) break
24
24
  const parent = dirname(dir)
25
25
  if (parent === dir) {
26
- dir = startDir // reached the filesystem root with no package.json — anchor on the file's own dir
26
+ dir = startDir // reached the filesystem root with no package.json - anchor on the file's own dir
27
27
  break
28
28
  }
29
29
  dir = parent
@@ -33,11 +33,11 @@ function packageRootOf(startDir: string): string {
33
33
  }
34
34
 
35
35
  /**
36
- * A **package-root-relative**, forward-slashed form of an absolute path — the input to {@link hash8} for
36
+ * A **package-root-relative**, forward-slashed form of an absolute path - the input to {@link hash8} for
37
37
  * any build-stable identifier (e.g. CSS-module scoped names). Anchoring on the file's nearest
38
38
  * `package.json` (not the absolute path, not `process.cwd()`) makes the result independent of BOTH the
39
- * machine's directory layout AND the working directory: the dom build and the ssr runtime — even on
40
- * different machines or from different cwds — derive the SAME relative path for a given file, so their
39
+ * machine's directory layout AND the working directory: the dom build and the ssr runtime - even on
40
+ * different machines or from different cwds - derive the SAME relative path for a given file, so their
41
41
  * scoped class maps always agree. (Hashing the absolute path would differ across CI/host; a
42
42
  * cwd-relative path would silently desync if the SSR server started from a non-project-root cwd.)
43
43
  */
@@ -47,7 +47,7 @@ export function reproduciblePath(absolutePath: string): string {
47
47
  }
48
48
 
49
49
  /**
50
- * Deterministic 8-hex hash (djb2/xor). Stable across builds — no `Date.now`/`Math.random` — so build
50
+ * Deterministic 8-hex hash (djb2/xor). Stable across builds - no `Date.now`/`Math.random` - so build
51
51
  * output is reproducible. The single hash implementation behind CSS-module scoped names (and a drop-in
52
52
  * for any SFC scope id).
53
53
  */
@@ -58,7 +58,7 @@ export function hash8(input: string): string {
58
58
  }
59
59
 
60
60
  /**
61
- * Records compiled CSS and wires it into the client bundle through a virtual `?<namespace>` module —
61
+ * Records compiled CSS and wires it into the client bundle through a virtual `?<namespace>` module -
62
62
  * the idiom the Vue plugin established (`?vue-css`). Register one per plugin `setup`; call `emit` per
63
63
  * file to stash its CSS and get back the `import` line to append to the JS module.
64
64
  */
@@ -70,7 +70,7 @@ export interface StylesheetEmitter {
70
70
  /**
71
71
  * Wire the virtual-CSS-module handlers onto `build` for `namespace`, returning an {@link StylesheetEmitter}.
72
72
  * The `namespace` must be a plain identifier (letters/`-`); it's used verbatim as the import suffix and
73
- * the Bun namespace. Only the `"dom"` build should emit CSS — the `"ssr"` build ships no stylesheet.
73
+ * the Bun namespace. Only the `"dom"` build should emit CSS - the `"ssr"` build ships no stylesheet.
74
74
  */
75
75
  export function createStylesheetEmitter(
76
76
  build: PluginBuilder,
@@ -97,7 +97,7 @@ export function createStylesheetEmitter(
97
97
 
98
98
  /**
99
99
  * Load an optional peer compiler at build time, throwing a consistent, actionable install-hint error if
100
- * it's absent — the `@vue/compiler-sfc` peer pattern, centralized. Build-time only, so the dynamic
100
+ * it's absent - the `@vue/compiler-sfc` peer pattern, centralized. Build-time only, so the dynamic
101
101
  * `import` (which keeps the peer out of the package's hard dependencies) is correct here.
102
102
  */
103
103
  export async function requirePeer<T>(
@@ -107,7 +107,7 @@ export async function requirePeer<T>(
107
107
  try {
108
108
  return (await import(specifier)) as T
109
109
  } catch (err) {
110
- // Only a genuine resolution failure means "not installed" — surface anything else (a corrupt
110
+ // Only a genuine resolution failure means "not installed" - surface anything else (a corrupt
111
111
  // install, a native-binding error, a throw at module top-level) as the real error rather than
112
112
  // masking it behind a misleading "please install it" hint.
113
113
  if ((err as { code?: unknown }).code === "ERR_MODULE_NOT_FOUND") {
@@ -1,17 +1,17 @@
1
1
  /**
2
- * `@nifrajs/web/plugins/scss` — a SASS/SCSS (`*.scss`, `*.sass`) Bun plugin, in its OWN module so the SSR
2
+ * `@nifrajs/web/plugins/scss` - a SASS/SCSS (`*.scss`, `*.sass`) Bun plugin, in its OWN module so the SSR
3
3
  * preload registers it BEFORE any Sass file loads. Mirrors the `@nifrajs/web-vue/plugin` seam: pass
4
4
  * `"dom"` for the client bundle (`buildClient({ plugins: [...] })`) and preload `"ssr"` for the server
5
5
  * (`bun --preload`).
6
6
  *
7
- * The Dart Sass compiler is an **optional peer** (`sass`, or the faster `sass-embedded` — same API), not
7
+ * The Dart Sass compiler is an **optional peer** (`sass`, or the faster `sass-embedded` - same API), not
8
8
  * a hard dependency: it's loaded on first Sass file and fails loud with an install hint if absent. Pass
9
9
  * your own `compiler` to override (e.g. `sass-embedded`, or a stub in tests).
10
10
  *
11
11
  * Composes with CSS Modules: a `*.module.scss` / `*.module.sass` file is compiled to CSS and then run
12
12
  * through the same scoped-class transform as `@nifrajs/web/plugins/css-modules`, so its `import styles`
13
13
  * yields the `{ original: scoped }` map (SSR/dom class-map parity included). A plain `*.scss` is a
14
- * side-effect import — its CSS is bundled (dom) and it resolves to an empty module (ssr).
14
+ * side-effect import - its CSS is bundled (dom) and it resolves to an empty module (ssr).
15
15
  */
16
16
  import { dirname } from "node:path"
17
17
  import { pathToFileURL } from "node:url"
@@ -0,0 +1,110 @@
1
+ /**
2
+ * `@nifrajs/web/plugins/vite-leak-guard` - nifra's two client-leak guards, for a Vite/Rollup production
3
+ * build.
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * nifra's production default is Bun, and that is not changing: a Vite/Rollup production build inherits
8
+ * Rollup's build profile and gives up the Bun-native advantage nifra competes on. But some apps depend on
9
+ * a Vite-only transform that has no Bun equivalent, and for those a Vite production build is the escape
10
+ * hatch. The moment that hatch exists, the two client-leak guards have to come with it - they are security
11
+ * guards, not lints: one stops secrets and database access from shipping to a browser. A second production
12
+ * pipeline arriving WITHOUT them, or with a hastily re-implemented "mostly ported" copy, is the failure the
13
+ * neutral module-graph seam was built to prevent.
14
+ *
15
+ * So this is not a second implementation. The detection logic (`detectNodeBuiltinsInClient`,
16
+ * `detectServerOnlyInClient`) and the failure MESSAGES (`formatNodeBuiltinLeak`, `formatServerOnlyLeak`)
17
+ * are the exact same functions the Bun build calls. This plugin only adapts Rollup's bundle shape into the
18
+ * neutral `ClientModuleGraph` (`fromRollupBundle`) and runs them. A leak reads identically whichever
19
+ * bundler produced it, and there is one place to change if a guard changes.
20
+ *
21
+ * ## How
22
+ *
23
+ * In `generateBundle` the whole client graph is available: each output chunk lists its `moduleIds`, and
24
+ * `this.getModuleInfo(id)` gives each module's resolved import edges. That is exactly what
25
+ * `fromRollupBundle` needs. Findings fail the build through Rollup's `this.error`, so the message lands in
26
+ * the build output the same way a Bun-build throw does.
27
+ *
28
+ * Add it to the `plugins` of a Vite production build (the LAST plugin, so it sees the final graph):
29
+ *
30
+ * // vite.config.ts (production client build)
31
+ * import { viteLeakGuard } from "@nifrajs/web/plugins/vite-leak-guard"
32
+ * export default { build: { rollupOptions: { plugins: [viteLeakGuard()] } } }
33
+ */
34
+ import {
35
+ detectNodeBuiltinsInClient,
36
+ detectServerOnlyInClient,
37
+ formatNodeBuiltinLeak,
38
+ formatServerOnlyLeak,
39
+ } from "../build.ts"
40
+ import { fromRollupBundle, type RollupBundleLike } from "../module-graph.ts"
41
+
42
+ /**
43
+ * The Rollup plugin-context slice this uses: `getModuleInfo` for a module's resolved imports, and `error`
44
+ * to fail the build. Typed structurally so the file needs no `rollup`/`vite` type dependency.
45
+ */
46
+ interface RollupPluginContext {
47
+ getModuleInfo(id: string): {
48
+ readonly importedIds?: readonly string[]
49
+ readonly dynamicallyImportedIds?: readonly string[]
50
+ } | null
51
+ /**
52
+ * Takes an `Error`, never a string.
53
+ *
54
+ * Handed a string, rolldown synthesizes its own error type and calls `Error.captureStackTrace` on a
55
+ * plain object - which Bun rejects with "First argument must be an Error object", and THAT becomes the
56
+ * build failure. The guard still fires, but its message is replaced by an internal one naming nothing,
57
+ * so a real client leak reports as a stack-trace complaint. Passing an Error skips that construction
58
+ * entirely and the message survives.
59
+ */
60
+ error(error: Error): never
61
+ }
62
+
63
+ /** The minimal Rollup plugin shape this returns - `generateBundle` bound to the plugin context. */
64
+ export interface LeakGuardPlugin {
65
+ readonly name: string
66
+ /**
67
+ * The finding that failed the build, or `undefined` when the bundle was clean.
68
+ *
69
+ * Recorded as well as thrown because the throw does not reliably survive. A plugin error is handed to
70
+ * the bundler, which builds its own error to report it, and on some Bun + rolldown combinations that
71
+ * construction itself fails (`Error.captureStackTrace` rejecting a non-Error) - so what surfaces is an
72
+ * internal complaint naming neither the leaked module nor its import chain. A caller that owns the
73
+ * build can read this afterwards and raise the real message from its own code, where nothing can
74
+ * rewrite it. A leak is the one failure that must stay legible: the whole point is naming what
75
+ * reached the browser.
76
+ */
77
+ leak?: string
78
+ generateBundle(this: RollupPluginContext, options: unknown, bundle: RollupBundleLike): void
79
+ }
80
+
81
+ /**
82
+ * A Vite/Rollup plugin that fails the build when server-only code or a `node:` builtin reaches the client
83
+ * bundle - the same two guards, and the same error messages, as nifra's Bun production build.
84
+ */
85
+ export function viteLeakGuard(): LeakGuardPlugin {
86
+ const plugin: LeakGuardPlugin = {
87
+ name: "nifra:leak-guard",
88
+ generateBundle(_options, bundle) {
89
+ // Resolved import edges per module, straight from Rollup's graph. Dynamic imports count too: a
90
+ // `node:`/server-only module reached only via `import()` still ships to the browser.
91
+ const importsOf = (id: string): readonly string[] => {
92
+ const info = this.getModuleInfo(id)
93
+ if (info === null) return []
94
+ return [...(info.importedIds ?? []), ...(info.dynamicallyImportedIds ?? [])]
95
+ }
96
+ const graph = fromRollupBundle(bundle, importsOf)
97
+ // Node-builtin guard first, then server-only - the same order as the Bun build, so the first error a
98
+ // dev sees is the same across pipelines when a module trips both.
99
+ const leak =
100
+ formatNodeBuiltinLeak(detectNodeBuiltinsInClient(graph)) ??
101
+ formatServerOnlyLeak(detectServerOnlyInClient(graph))
102
+ if (leak === undefined) return
103
+ // Record before throwing: the throw is what fails the build for a standalone user of this plugin,
104
+ // and the record is what lets a caller that owns the build re-raise the real message (see `leak`).
105
+ plugin.leak = leak
106
+ this.error(new Error(leak))
107
+ },
108
+ }
109
+ return plugin
110
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * `@nifrajs/web/plugins/vite-server-fn` - the Vite half of the server-function client transform.
3
+ *
4
+ * A `*.fn` module must never reach a browser: it holds the function bodies and everything they import.
5
+ * The Bun build replaces each one with client stubs, and this does the same for Vite, in both the dev
6
+ * server and the production build.
7
+ *
8
+ * ## Why the generation is not in here
9
+ *
10
+ * The stub text comes from `internal/server-boundary.ts`, shared verbatim with the Bun plugin. Two
11
+ * implementations of "what a stub looks like" would drift, and the drift would be invisible until a
12
+ * client that worked in dev 404'd in production - the exact shape of failure this codebase has already
13
+ * paid for once. A parity test asserts both pipelines emit the same bytes.
14
+ *
15
+ * ## Why `transform` and not `load`
16
+ *
17
+ * Vite resolves and reads the file itself; taking `load` would mean re-implementing its resolution.
18
+ * `transform` receives the source Vite already read, and returning replacement code discards the
19
+ * original along with its imports - so the server subtree is never followed. `enforce: "pre"` puts this
20
+ * ahead of framework transforms, which have no business seeing server code either.
21
+ */
22
+ import {
23
+ generateServerFnStub,
24
+ SERVER_FN_MODULE,
25
+ serverFnNamespace,
26
+ } from "../internal/server-boundary.ts"
27
+
28
+ /** The slice of a Vite/Rollup plugin this returns. Structural, so `vite` stays an optional peer. */
29
+ export interface ServerFnStubPlugin {
30
+ readonly name: string
31
+ readonly enforce: "pre"
32
+ /** Applied only to the CLIENT build; the server build keeps the real module. */
33
+ readonly applyToEnvironment?: (environment: { readonly name: string }) => boolean
34
+ transform(
35
+ code: string,
36
+ id: string,
37
+ options?: { readonly ssr?: boolean },
38
+ ): { code: string; map: null } | null
39
+ }
40
+
41
+ /** Strips a Vite id's `?query` and `#hash` so the suffix test sees a plain path. */
42
+ const bare = (id: string): string => id.split("?")[0]?.split("#")[0] ?? id
43
+
44
+ /**
45
+ * Replace every `*.fn` module with its client stubs.
46
+ *
47
+ * Client-only by construction: nifra passes this to the client build and the dev server, never to the
48
+ * SSR build, because the server is what actually runs these functions.
49
+ */
50
+ export function viteServerFnStub(): ServerFnStubPlugin {
51
+ return {
52
+ name: "nifra:server-fn-stub",
53
+ enforce: "pre",
54
+ // Vite 6+ environments: the SSR environment must keep the real module. Older Vite ignores this and
55
+ // relies on nifra only registering the plugin on the client side.
56
+ applyToEnvironment: (environment) => environment.name === "client",
57
+ transform(code, id, options) {
58
+ // Vite 5 ignores `applyToEnvironment` but supplies this transform context for SSR.
59
+ if (options?.ssr === true) return null
60
+ const path = bare(id)
61
+ if (!SERVER_FN_MODULE.test(path)) return null
62
+ // `map: null` is honest rather than lazy: the stub shares no lines with the source, so any
63
+ // mapping would point a debugger at unrelated code.
64
+ return { code: generateServerFnStub(code, serverFnNamespace(path)), map: null }
65
+ },
66
+ }
67
+ }