@nifrajs/web 2.2.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 (163) hide show
  1. package/README.md +3 -3
  2. package/dist/build-vite.d.ts +4 -4
  3. package/dist/build-vite.d.ts.map +1 -1
  4. package/dist/build-vite.js +20 -74
  5. package/dist/build-vite.js.map +1 -1
  6. package/dist/build.d.ts +64 -42
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/build.js +100 -78
  9. package/dist/build.js.map +1 -1
  10. package/dist/bun-dev-entry.d.ts +1 -1
  11. package/dist/bun-dev-entry.js +1 -1
  12. package/dist/chunk-names.js +1 -1
  13. package/dist/client.d.ts +3 -9
  14. package/dist/client.d.ts.map +1 -1
  15. package/dist/client.js +201 -29
  16. package/dist/client.js.map +1 -1
  17. package/dist/conformance.d.ts +2 -2
  18. package/dist/conformance.js +3 -3
  19. package/dist/deferred.d.ts +11 -11
  20. package/dist/deferred.d.ts.map +1 -1
  21. package/dist/deferred.js +16 -16
  22. package/dist/dev-error.d.ts +2 -2
  23. package/dist/dev-error.js +3 -3
  24. package/dist/dev-port.d.ts +2 -2
  25. package/dist/dev-port.js +1 -1
  26. package/dist/dev.d.ts +3 -3
  27. package/dist/dev.js +9 -9
  28. package/dist/draft.d.ts +12 -12
  29. package/dist/draft.js +11 -11
  30. package/dist/fn-state.d.ts +56 -0
  31. package/dist/fn-state.d.ts.map +1 -0
  32. package/dist/fn-state.js +77 -0
  33. package/dist/fn-state.js.map +1 -0
  34. package/dist/fn.d.ts +111 -0
  35. package/dist/fn.d.ts.map +1 -0
  36. package/dist/fn.js +125 -0
  37. package/dist/fn.js.map +1 -0
  38. package/dist/fonts-google.d.ts +9 -9
  39. package/dist/fonts-google.js +10 -10
  40. package/dist/fonts.d.ts +7 -7
  41. package/dist/fonts.js +1 -1
  42. package/dist/forms.d.ts +8 -8
  43. package/dist/fs.d.ts +1 -1
  44. package/dist/fs.js +1 -1
  45. package/dist/index.d.ts +151 -70
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +317 -139
  48. package/dist/index.js.map +1 -1
  49. package/dist/internal/head-attributes.d.ts +15 -0
  50. package/dist/internal/head-attributes.d.ts.map +1 -0
  51. package/dist/internal/head-attributes.js +99 -0
  52. package/dist/internal/head-attributes.js.map +1 -0
  53. package/dist/internal/script-types.d.ts +14 -0
  54. package/dist/internal/script-types.d.ts.map +1 -0
  55. package/dist/internal/script-types.js +17 -0
  56. package/dist/internal/script-types.js.map +1 -0
  57. package/dist/internal/server-boundary.d.ts +60 -0
  58. package/dist/internal/server-boundary.d.ts.map +1 -0
  59. package/dist/internal/server-boundary.js +174 -0
  60. package/dist/internal/server-boundary.js.map +1 -0
  61. package/dist/internal/timing-safe-equal.d.ts +1 -1
  62. package/dist/internal/timing-safe-equal.js +1 -1
  63. package/dist/internal/vite-import.d.ts +63 -0
  64. package/dist/internal/vite-import.d.ts.map +1 -0
  65. package/dist/internal/vite-import.js +94 -0
  66. package/dist/internal/vite-import.js.map +1 -0
  67. package/dist/islands.d.ts +5 -5
  68. package/dist/islands.js +5 -5
  69. package/dist/isr.d.ts +15 -15
  70. package/dist/isr.js +7 -7
  71. package/dist/manifest.d.ts +73 -35
  72. package/dist/manifest.d.ts.map +1 -1
  73. package/dist/manifest.js +10 -16
  74. package/dist/manifest.js.map +1 -1
  75. package/dist/module-graph.d.ts +5 -5
  76. package/dist/module-graph.js +4 -4
  77. package/dist/navigation.d.ts +120 -5
  78. package/dist/navigation.d.ts.map +1 -1
  79. package/dist/navigation.js +37 -5
  80. package/dist/navigation.js.map +1 -1
  81. package/dist/plugins/css-modules.d.ts +3 -3
  82. package/dist/plugins/css-modules.js +0 -0
  83. package/dist/plugins/kit.d.ts +8 -8
  84. package/dist/plugins/kit.js +9 -9
  85. package/dist/plugins/scss.js +3 -3
  86. package/dist/plugins/vite-leak-guard.d.ts +1 -1
  87. package/dist/plugins/vite-leak-guard.js +1 -1
  88. package/dist/plugins/vite-server-fn.d.ts +23 -0
  89. package/dist/plugins/vite-server-fn.d.ts.map +1 -0
  90. package/dist/plugins/vite-server-fn.js +51 -0
  91. package/dist/plugins/vite-server-fn.js.map +1 -0
  92. package/dist/plugins/vite-server-only.d.ts +49 -0
  93. package/dist/plugins/vite-server-only.d.ts.map +1 -0
  94. package/dist/plugins/vite-server-only.js +57 -0
  95. package/dist/plugins/vite-server-only.js.map +1 -0
  96. package/dist/prerender.d.ts +5 -5
  97. package/dist/prerender.js +7 -7
  98. package/dist/query.d.ts +10 -10
  99. package/dist/query.js +15 -15
  100. package/dist/route-manifest.d.ts +1 -1
  101. package/dist/route-manifest.js +2 -2
  102. package/dist/router.d.ts +30 -21
  103. package/dist/router.d.ts.map +1 -1
  104. package/dist/router.js +57 -28
  105. package/dist/router.js.map +1 -1
  106. package/dist/search.d.ts +94 -0
  107. package/dist/search.d.ts.map +1 -0
  108. package/dist/search.js +232 -0
  109. package/dist/search.js.map +1 -0
  110. package/dist/server-only.d.ts +6 -6
  111. package/dist/server-only.js +7 -7
  112. package/dist/service-worker.d.ts +56 -0
  113. package/dist/service-worker.d.ts.map +1 -0
  114. package/dist/service-worker.js +127 -0
  115. package/dist/service-worker.js.map +1 -0
  116. package/dist/vite.d.ts +15 -13
  117. package/dist/vite.d.ts.map +1 -1
  118. package/dist/vite.js +31 -21
  119. package/dist/vite.js.map +1 -1
  120. package/package.json +33 -4
  121. package/server-manifest.d.ts +31 -0
  122. package/src/build-vite.ts +24 -77
  123. package/src/build.ts +137 -105
  124. package/src/bun-dev-entry.ts +1 -1
  125. package/src/chunk-names.ts +1 -1
  126. package/src/client.ts +215 -32
  127. package/src/conformance.ts +3 -3
  128. package/src/deferred.ts +17 -17
  129. package/src/dev-error.ts +3 -3
  130. package/src/dev-port.ts +2 -2
  131. package/src/dev.ts +12 -12
  132. package/src/draft.ts +15 -15
  133. package/src/fn-state.ts +107 -0
  134. package/src/fn.ts +199 -0
  135. package/src/fonts-google.ts +13 -13
  136. package/src/fonts.ts +7 -7
  137. package/src/forms.ts +8 -8
  138. package/src/fs.ts +2 -2
  139. package/src/index.ts +460 -181
  140. package/src/internal/head-attributes.ts +109 -0
  141. package/src/internal/script-types.ts +18 -0
  142. package/src/internal/server-boundary.ts +187 -0
  143. package/src/internal/timing-safe-equal.ts +1 -1
  144. package/src/internal/vite-import.ts +92 -0
  145. package/src/islands.ts +7 -7
  146. package/src/isr.ts +17 -17
  147. package/src/manifest.ts +83 -42
  148. package/src/module-graph.ts +6 -6
  149. package/src/navigation.ts +151 -5
  150. package/src/plugins/css-modules.ts +0 -0
  151. package/src/plugins/kit.ts +11 -11
  152. package/src/plugins/scss.ts +3 -3
  153. package/src/plugins/vite-leak-guard.ts +2 -2
  154. package/src/plugins/vite-server-fn.ts +67 -0
  155. package/src/plugins/vite-server-only.ts +75 -0
  156. package/src/prerender.ts +10 -10
  157. package/src/query.ts +22 -22
  158. package/src/route-manifest.ts +3 -3
  159. package/src/router.ts +91 -41
  160. package/src/search.ts +297 -0
  161. package/src/server-only.ts +7 -7
  162. package/src/service-worker.ts +165 -0
  163. package/src/vite.ts +41 -29
package/src/search.ts ADDED
@@ -0,0 +1,297 @@
1
+ /**
2
+ * `@nifrajs/web` search-param engine - the agnostic core behind typed, validated search params. It parses
3
+ * a URL query into a structured object, validates it against a route's Standard Schema (failing CLOSED to
4
+ * defaults on hostile input), serializes it back, and structural-shares successive values so selector
5
+ * reads stay referentially stable. Pure: no DOM, no framework, no fs - it runs identically at server match
6
+ * time and on client navigation, so the two sides cannot drift. See SEARCH-PARAMS-PLAN.md.
7
+ *
8
+ * Not yet re-exported from the package entry: this is the engine the route/server/client wiring (later
9
+ * phases) build on. The public surface (`searchSchema` on a route, `useSearch`, typed `navigate`) lands
10
+ * with that wiring.
11
+ */
12
+ import type { InferOutput, StandardSchemaV1 } from "@nifrajs/core/server"
13
+
14
+ /**
15
+ * Bounds applied while parsing a query string. A search string is attacker-controlled, so it is capped
16
+ * BEFORE an object graph is built from it - defense-in-depth alongside the route schema's own validation.
17
+ */
18
+ export interface SearchLimits {
19
+ /** Longest raw query (with or without a leading `?`) that will be parsed; longer fails closed to `{}`. */
20
+ readonly maxLength: number
21
+ /** Most top-level keys that will be kept; keys beyond the cap are dropped. */
22
+ readonly maxKeys: number
23
+ /** Deepest nesting a decoded JSON value may have; deeper values are kept as their raw string instead. */
24
+ readonly maxDepth: number
25
+ }
26
+
27
+ /** Conservative defaults: a 4 KB query, 64 keys, 6 levels of nesting. */
28
+ export const DEFAULT_SEARCH_LIMITS: SearchLimits = { maxLength: 4096, maxKeys: 64, maxDepth: 6 }
29
+
30
+ /**
31
+ * A pluggable query <-> object codec. The default is JSON-first (numbers/booleans/arrays/nested objects
32
+ * survive the round-trip); an app can supply its own (compact, base64, comma-arrays) in a later phase.
33
+ */
34
+ export interface SearchCodec {
35
+ /** Parse a raw query (`"?a=1&b=x"` or `"a=1&b=x"`) into a structured object, honoring `limits`. */
36
+ parse(raw: string, limits: SearchLimits): Record<string, unknown>
37
+ /** Serialize a structured object to a query string, including a leading `?` (or `""` when empty). */
38
+ serialize(value: Record<string, unknown>): string
39
+ }
40
+
41
+ // Keys that must never be written from untrusted input: a bracket-assignment to `__proto__` (or the
42
+ // others) can pollute Object.prototype. Dropped at every level of a decoded value.
43
+ const FORBIDDEN_KEYS = new Set<string>(["__proto__", "prototype", "constructor"])
44
+
45
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
46
+ return typeof value === "object" && value !== null && !Array.isArray(value)
47
+ }
48
+
49
+ // True when `value` nests deeper than `remaining` levels (a scalar never does). Used to reject an
50
+ // over-deep decoded JSON value back to its raw string rather than store the graph.
51
+ function exceedsDepth(value: unknown, remaining: number): boolean {
52
+ if (Array.isArray(value)) {
53
+ if (remaining <= 0) return true
54
+ return value.some((item) => exceedsDepth(item, remaining - 1))
55
+ }
56
+ if (isPlainObject(value)) {
57
+ if (remaining <= 0) return true
58
+ return Object.values(value).some((item) => exceedsDepth(item, remaining - 1))
59
+ }
60
+ return false
61
+ }
62
+
63
+ // Recursively strip forbidden keys from a decoded value. `JSON.parse('{"__proto__":1}')` yields an OWN
64
+ // enumerable `__proto__` property (it does not set the prototype), but a later spread/assign of it would,
65
+ // so it is removed here, at the source.
66
+ function sanitize(value: unknown): unknown {
67
+ if (Array.isArray(value)) return value.map(sanitize)
68
+ if (isPlainObject(value)) {
69
+ const out: Record<string, unknown> = {}
70
+ for (const [key, item] of Object.entries(value)) {
71
+ if (FORBIDDEN_KEYS.has(key)) continue
72
+ out[key] = sanitize(item)
73
+ }
74
+ return out
75
+ }
76
+ return value
77
+ }
78
+
79
+ // One query value: JSON-decode it (so `2` is a number, `true` a boolean, `{...}` an object), falling back
80
+ // to the raw string when it is not JSON or nests past the depth cap.
81
+ function decodeValue(raw: string, limits: SearchLimits): unknown {
82
+ let parsed: unknown
83
+ try {
84
+ parsed = JSON.parse(raw)
85
+ } catch {
86
+ return raw
87
+ }
88
+ if ((isPlainObject(parsed) || Array.isArray(parsed)) && exceedsDepth(parsed, limits.maxDepth)) {
89
+ return raw
90
+ }
91
+ return sanitize(parsed)
92
+ }
93
+
94
+ function encodeValue(value: unknown): string {
95
+ if (typeof value !== "string") return JSON.stringify(value)
96
+ // The codec is JSON-first on decode, so a raw string such as `"2"`, `"true"`, or `"null"`
97
+ // would otherwise change type on a serialize → parse round-trip. Quote only ambiguous strings;
98
+ // ordinary text stays readable (`?q=hello`, not `?q=%22hello%22`).
99
+ try {
100
+ JSON.parse(value)
101
+ return JSON.stringify(value)
102
+ } catch {
103
+ return value
104
+ }
105
+ }
106
+
107
+ /** The default JSON-first codec. Repeated keys (`?t=a&t=b`) decode to an array; single keys to a value. */
108
+ const jsonCodec: SearchCodec = {
109
+ parse(raw, limits) {
110
+ const usp = new URLSearchParams(raw.startsWith("?") ? raw.slice(1) : raw)
111
+ const out: Record<string, unknown> = {}
112
+ let kept = 0
113
+ for (const key of new Set(usp.keys())) {
114
+ if (FORBIDDEN_KEYS.has(key)) continue
115
+ if (++kept > limits.maxKeys) break
116
+ const decoded = usp.getAll(key).map((value) => decodeValue(value, limits))
117
+ out[key] = decoded.length > 1 ? decoded : decoded[0]
118
+ }
119
+ return out
120
+ },
121
+ serialize(value) {
122
+ const usp = new URLSearchParams()
123
+ for (const [key, item] of Object.entries(value)) {
124
+ if (item === undefined) continue
125
+ if (Array.isArray(item) && item.length > 1) {
126
+ for (const element of item) usp.append(key, encodeValue(element))
127
+ } else {
128
+ // Repeated query keys naturally preserve arrays of length >= 2. Empty and singleton arrays
129
+ // need one JSON value or they collapse to a missing key / scalar during decoding.
130
+ usp.set(key, encodeValue(item))
131
+ }
132
+ }
133
+ const query = usp.toString()
134
+ return query === "" ? "" : `?${query}`
135
+ },
136
+ }
137
+
138
+ /**
139
+ * Parse a raw URL query into a structured object. Fails closed to `{}` when the raw string exceeds
140
+ * `limits.maxLength` (before any parsing work is done).
141
+ */
142
+ export function parseSearch(
143
+ raw: string,
144
+ codec: SearchCodec = jsonCodec,
145
+ limits: SearchLimits = DEFAULT_SEARCH_LIMITS,
146
+ ): Record<string, unknown> {
147
+ if (raw.length > limits.maxLength) return {}
148
+ return codec.parse(raw, limits)
149
+ }
150
+
151
+ /** Serialize a structured search object back to a query string (leading `?`, or `""` when empty). */
152
+ export function serializeSearch(
153
+ value: Record<string, unknown>,
154
+ codec: SearchCodec = jsonCodec,
155
+ ): string {
156
+ return codec.serialize(value)
157
+ }
158
+
159
+ /**
160
+ * The search a route sees for a raw URL query: parsed, then validated against a single `searchSchema`
161
+ * when the route declares one (failing closed to its defaults), or the raw parsed query otherwise. A
162
+ * one-link {@link searchOfChain}; use that directly for a layout+page chain. Both the server (`renderPage`,
163
+ * loader ctx) and the client (the adapter mount) derive search this way, from the same URL + schema, so
164
+ * they produce the identical value - SSR-correct by construction, no serialization.
165
+ */
166
+ export function searchOf(
167
+ searchSchema: StandardSchemaV1 | undefined,
168
+ rawSearch: string,
169
+ ): Record<string, unknown> {
170
+ return searchOfChain([searchSchema], rawSearch)
171
+ }
172
+
173
+ /**
174
+ * The search for a route whose effective schema is a CHAIN - a `_layout` may declare `searchSchema` for
175
+ * shared keys (`?org`, `?theme`) and each page declares its own. The raw query is validated against every
176
+ * schema in the chain (outermost layout first, page last) and their outputs are merged, page-wins on a key
177
+ * conflict (nearest-wins, like `mergeHeads`): validate-each-then-combine, since Standard Schema has no
178
+ * `.merge()`. `undefined` links (a layout with no `searchSchema`) contribute nothing; when the whole chain
179
+ * is empty, the raw parsed query is returned. Fails closed per schema, same as {@link searchOf} (which is
180
+ * this with a one-link chain). The server and the client build the identical chain, so the value matches.
181
+ */
182
+ export function searchOfChain(
183
+ schemas: readonly (StandardSchemaV1 | undefined)[],
184
+ rawSearch: string,
185
+ ): Record<string, unknown> {
186
+ const parsed = parseSearch(rawSearch)
187
+ const defined = schemas.filter((s): s is StandardSchemaV1 => s !== undefined)
188
+ if (defined.length === 0) return parsed
189
+ let out: Record<string, unknown> = {}
190
+ for (const schema of defined) {
191
+ const validated = validateSearch(schema, parsed)
192
+ if (!isPlainObject(validated)) {
193
+ throw new TypeError("[nifra/web] a search schema must produce an object")
194
+ }
195
+ out = { ...out, ...validated }
196
+ }
197
+ return out
198
+ }
199
+
200
+ /**
201
+ * The search OUTPUT type for a route MODULE - its `searchSchema`'s validated output, or the raw parsed
202
+ * query (`Record<string, unknown>`) when it declares none. The building block for typed cross-route
203
+ * navigation: generated route types (`nifra sync-routes`) map each path to
204
+ * `SearchOf<typeof import("./routes/<file>")>`, populating the augmentable `RouteSearch` interface.
205
+ */
206
+ export type SearchOf<Module> = Module extends { searchSchema: infer S }
207
+ ? S extends StandardSchemaV1
208
+ ? InferOutput<S> extends Record<string, unknown>
209
+ ? InferOutput<S>
210
+ : never
211
+ : Record<string, unknown>
212
+ : Record<string, unknown>
213
+
214
+ /**
215
+ * Validate a parsed search object against a route's Standard Schema, returning the typed output. Fails
216
+ * CLOSED: on validation issues it retries against an empty object so the schema's per-field defaults apply,
217
+ * and degrades to `{}` only if even that fails - it never throws on hostile input. An async validator is a
218
+ * configuration error (search must validate synchronously, in the render/nav path) and throws loudly.
219
+ */
220
+ export function validateSearch<Schema extends StandardSchemaV1>(
221
+ schema: Schema,
222
+ parsed: Record<string, unknown>,
223
+ ): InferOutput<Schema> {
224
+ const result = schema["~standard"].validate(parsed)
225
+ if (result instanceof Promise) {
226
+ throw new TypeError("[nifra/web] a search schema must validate synchronously")
227
+ }
228
+ if (result.issues === undefined) return result.value as InferOutput<Schema>
229
+ const fallback = schema["~standard"].validate({})
230
+ if (fallback instanceof Promise) {
231
+ throw new TypeError("[nifra/web] a search schema must validate synchronously")
232
+ }
233
+ return (fallback.issues === undefined ? fallback.value : {}) as InferOutput<Schema>
234
+ }
235
+
236
+ function deepEqual(a: unknown, b: unknown): boolean {
237
+ if (Object.is(a, b)) return true
238
+ if (Array.isArray(a) && Array.isArray(b)) {
239
+ return a.length === b.length && a.every((item, index) => deepEqual(item, b[index]))
240
+ }
241
+ if (isPlainObject(a) && isPlainObject(b)) {
242
+ const aKeys = Object.keys(a)
243
+ return (
244
+ aKeys.length === Object.keys(b).length &&
245
+ aKeys.every((key) => key in b && deepEqual(a[key], b[key]))
246
+ )
247
+ }
248
+ return false
249
+ }
250
+
251
+ /**
252
+ * Structural sharing (shallow-first): return `next`, but reuse `prev`'s reference for every top-level key
253
+ * whose value is deep-equal. When nothing changed, `prev` itself is returned (stable identity), so a
254
+ * selector like `useSearch(s => s.filters)` does not see a change - and a filters-only component does not
255
+ * re-render when an unrelated key (`page`) changes.
256
+ */
257
+ export function shareSearch<T>(prev: T, next: T): T {
258
+ if (Object.is(prev, next)) return prev
259
+ if (!isPlainObject(prev) || !isPlainObject(next)) return next
260
+ const out: Record<string, unknown> = {}
261
+ let identical = Object.keys(prev).length === Object.keys(next).length
262
+ for (const [key, after] of Object.entries(next)) {
263
+ if (key in prev && deepEqual(prev[key], after)) {
264
+ out[key] = prev[key]
265
+ } else {
266
+ out[key] = after
267
+ identical = false
268
+ }
269
+ }
270
+ return identical ? prev : (out as T)
271
+ }
272
+
273
+ /**
274
+ * True when two raw queries differ ONLY in keys a route declared client-only (`searchClientKeys`) - the
275
+ * signal a client navigation can update search WITHOUT re-running the loader (re-render, not revalidate:
276
+ * `?tab`, a client-side `?sort` over already-loaded rows, `?modal`). Returns false when a key OUTSIDE the
277
+ * allow-list changed (that key drives data, so the nav must revalidate) and when nothing changed at all (a
278
+ * same-URL nav still revalidates). Fail-safe by construction: an empty allow-list can never return true,
279
+ * so a route that declares no client keys always revalidates - the correct, never-stale default.
280
+ */
281
+ export function isClientOnlySearchChange(
282
+ prevRaw: string,
283
+ nextRaw: string,
284
+ clientKeys: readonly string[],
285
+ ): boolean {
286
+ if (clientKeys.length === 0) return false
287
+ const prev = parseSearch(prevRaw)
288
+ const next = parseSearch(nextRaw)
289
+ const allow = new Set(clientKeys)
290
+ let changed = false
291
+ for (const key of new Set([...Object.keys(prev), ...Object.keys(next)])) {
292
+ if (deepEqual(prev[key], next[key])) continue
293
+ if (!allow.has(key)) return false // a loader-affecting key changed - must revalidate
294
+ changed = true
295
+ }
296
+ return changed // only a real change to a client-only key skips the fetch (else revalidate)
297
+ }
@@ -1,25 +1,25 @@
1
1
  /**
2
- * `@nifrajs/web/server-only` — the **explicit, opt-in poison-import marker** (Next's `import
3
- * "server-only"`). A module of PURE server logic that carries no `node:` import — a secret-bearing
4
- * constant, a server-only API call — can't be caught by nifra's other two client-leak guards: the
2
+ * `@nifrajs/web/server-only` - the **explicit, opt-in poison-import marker** (Next's `import
3
+ * "server-only"`). A module of PURE server logic that carries no `node:` import - a secret-bearing
4
+ * constant, a server-only API call - can't be caught by nifra's other two client-leak guards: the
5
5
  * `.server` convention (which empties `*.server` modules in the client build) needs the file to be
6
6
  * NAMED `*.server`, and the node-builtin guard only fires when a `node:` import actually reaches a
7
- * client chunk. This marker closes that gap: drop a side-effect import at the top of the module —
7
+ * client chunk. This marker closes that gap: drop a side-effect import at the top of the module -
8
8
  *
9
9
  * import "@nifrajs/web/server-only"
10
10
  *
11
- * — and the CLIENT build ({@link buildClient}) FAILS LOUD, naming the import chain, if that module
11
+ * - and the CLIENT build ({@link buildClient}) FAILS LOUD, naming the import chain, if that module
12
12
  * ever lands in a browser chunk. On the SERVER build/runtime it's a harmless no-op (this empty
13
13
  * module), so the same file runs untouched server-side.
14
14
  *
15
15
  * Pair it with the type-level {@link ServerOnly} brand (from `@nifrajs/web`) on the module's exports
16
16
  * to signal intent to readers + the compiler; this runtime import is what actually fails the build.
17
17
  *
18
- * @see ServerOnly — the type-level intent marker that complements this runtime import.
18
+ * @see ServerOnly - the type-level intent marker that complements this runtime import.
19
19
  */
20
20
 
21
21
  // Intentionally empty. The marker is the IMPORT itself: `buildClient` detects any module that imports
22
22
  // this specifier and lands in a client chunk (via the same Bun metafile graph the node-builtin guard
23
- // walks) and fails the build. There is no value to export and nothing to run — on the server it's a
23
+ // walks) and fails the build. There is no value to export and nothing to run - on the server it's a
24
24
  // no-op so the marked module behaves normally; on the client the build never completes.
25
25
  export {}
@@ -0,0 +1,165 @@
1
+ /**
2
+ * `@nifrajs/web/service-worker` - generate a service worker from a build manifest.
3
+ *
4
+ * A service worker is the one piece of an app that outlives a deploy, keeps serving after you have
5
+ * stopped, and can hand one user a response produced for another. So the generated worker is
6
+ * deliberately narrow, and every rule below exists because the permissive version of it is a bug:
7
+ *
8
+ * - **Only content-hashed assets are precached.** A hashed URL names its bytes, so serving it from
9
+ * cache forever is correct by construction. Anything unhashed is left to the network.
10
+ * - **Documents are never cached.** Only navigations that FAIL are answered, and only with the
11
+ * offline page you nominate. A cached HTML document is how a service worker serves one signed-in
12
+ * user the page rendered for another; there is no per-user story here worth that risk.
13
+ * - **GET, same-origin, `ok`, and not `no-store`.** Anything else goes straight to the network.
14
+ * - **The cache name carries the build id.** A deploy that changed the assets gets a new cache, and
15
+ * activation deletes every older one, so a stale worker cannot pin an old build indefinitely.
16
+ *
17
+ * It is opt-in and generated at build time; an app that does not call this ships nothing.
18
+ */
19
+
20
+ /** The parts of a `BuildManifest` a worker needs. Structural, so a caller can pass extra fields. */
21
+ export interface ServiceWorkerManifest {
22
+ readonly entry: string
23
+ readonly assets: readonly string[]
24
+ readonly css?: readonly string[]
25
+ }
26
+
27
+ export interface ServiceWorkerOptions {
28
+ /**
29
+ * Distinguishes this build's cache from the last one. Use something that changes exactly when the
30
+ * assets do - a content hash, a commit sha, a release version.
31
+ */
32
+ readonly buildId: string
33
+ /**
34
+ * URL of a page to serve when a navigation fails and the network is unreachable. It must be a
35
+ * static, user-independent document (a prerendered `/offline`), because every visitor gets the same
36
+ * bytes. Omit it and failed navigations simply fail, which is the honest default.
37
+ */
38
+ readonly offlineUrl?: string
39
+ /** Cache name prefix. Default `nifra`. */
40
+ readonly cacheName?: string
41
+ /** Extra same-origin URLs to precache. Only pass immutable ones. */
42
+ readonly additionalPrecache?: readonly string[]
43
+ }
44
+
45
+ const SW_URL = /^\/[\w\-./@%]*$/
46
+
47
+ /** A precache URL has to be same-origin, rooted, and free of anything that could break out of a list. */
48
+ function assertPrecachable(url: string, label: string): void {
49
+ if (typeof url !== "string" || url === "") {
50
+ throw new Error(`[nifra/service-worker] ${label} must be a non-empty string`)
51
+ }
52
+ if (!url.startsWith("/") || url.startsWith("//")) {
53
+ throw new Error(
54
+ `[nifra/service-worker] ${label} must be a root-relative same-origin path, got ${JSON.stringify(url)}`,
55
+ )
56
+ }
57
+ if (!SW_URL.test(url)) {
58
+ throw new Error(
59
+ `[nifra/service-worker] ${label} contains characters that are not valid in an asset path: ${JSON.stringify(url)}`,
60
+ )
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Generate the service worker source for a build.
66
+ *
67
+ * Write the result to a file served from the ORIGIN ROOT (`/sw.js`): a worker's default scope is its
68
+ * own directory, so one served from `/assets/` could never control the pages it exists for.
69
+ */
70
+ export function generateServiceWorker(
71
+ manifest: ServiceWorkerManifest,
72
+ options: ServiceWorkerOptions,
73
+ ): string {
74
+ if (typeof options.buildId !== "string" || options.buildId.trim() === "") {
75
+ throw new Error("[nifra/service-worker] buildId is required and must be non-empty")
76
+ }
77
+ if (/[^\w.-]/.test(options.buildId)) {
78
+ throw new Error(
79
+ `[nifra/service-worker] buildId must be word characters, dots or hyphens, got ${JSON.stringify(options.buildId)}`,
80
+ )
81
+ }
82
+ const prefix = options.cacheName ?? "nifra"
83
+ if (/[^\w.-]/.test(prefix)) {
84
+ throw new Error(`[nifra/service-worker] cacheName must be word characters, dots or hyphens`)
85
+ }
86
+
87
+ const precache = [
88
+ manifest.entry,
89
+ ...manifest.assets,
90
+ ...(manifest.css ?? []),
91
+ ...(options.additionalPrecache ?? []),
92
+ ]
93
+ for (const url of precache) assertPrecachable(url, "precache url")
94
+ const offline = options.offlineUrl
95
+ if (offline !== undefined) assertPrecachable(offline, "offlineUrl")
96
+
97
+ // De-duped and sorted so an unchanged build produces an unchanged worker - a byte-identical file
98
+ // means the browser does not treat it as an update.
99
+ const urls = [...new Set(offline === undefined ? precache : [...precache, offline])].sort()
100
+
101
+ return `// Generated by @nifrajs/web/service-worker. Do not edit.
102
+ const CACHE = ${JSON.stringify(`${prefix}-${options.buildId}`)}
103
+ const PRECACHE = ${JSON.stringify(urls)}
104
+ const OFFLINE = ${offline === undefined ? "null" : JSON.stringify(offline)}
105
+
106
+ self.addEventListener("install", (event) => {
107
+ // Take over as soon as the new assets are stored; the activate handler drops the old cache, so
108
+ // waiting would only serve stale bytes for longer.
109
+ event.waitUntil(caches.open(CACHE).then((c) => c.addAll(PRECACHE)).then(() => self.skipWaiting()))
110
+ })
111
+
112
+ self.addEventListener("activate", (event) => {
113
+ event.waitUntil(
114
+ caches
115
+ .keys()
116
+ .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
117
+ .then(() => self.clients.claim()),
118
+ )
119
+ })
120
+
121
+ self.addEventListener("fetch", (event) => {
122
+ const request = event.request
123
+ if (request.method !== "GET") return
124
+ const url = new URL(request.url)
125
+ if (url.origin !== self.location.origin) return
126
+
127
+ // Navigations: network only, with the offline document as a last resort. A cached HTML response is
128
+ // how one user gets served the page rendered for another, so nothing is stored here.
129
+ if (request.mode === "navigate") {
130
+ if (OFFLINE === null) return
131
+ event.respondWith(fetch(request).catch(() => caches.match(OFFLINE).then((r) => r ?? Response.error())))
132
+ return
133
+ }
134
+
135
+ // Assets: cache first, because every precached URL is content-hashed and therefore immutable.
136
+ event.respondWith(
137
+ caches.match(request).then((hit) => {
138
+ if (hit !== undefined) return hit
139
+ return fetch(request).then((response) => {
140
+ const cacheable =
141
+ response.ok &&
142
+ response.type === "basic" &&
143
+ !(response.headers.get("cache-control") ?? "").includes("no-store")
144
+ if (cacheable && PRECACHE.includes(url.pathname)) {
145
+ const copy = response.clone()
146
+ void caches.open(CACHE).then((c) => c.put(request, copy))
147
+ }
148
+ return response
149
+ })
150
+ }),
151
+ )
152
+ })
153
+ `
154
+ }
155
+
156
+ /**
157
+ * The registration snippet, for a `<script>` in your document shell.
158
+ *
159
+ * Registration is deliberately deferred to `load`: a worker installing during first paint competes
160
+ * with the page it is meant to speed up.
161
+ */
162
+ export function serviceWorkerRegistration(scriptUrl = "/sw.js"): string {
163
+ assertPrecachable(scriptUrl, "scriptUrl")
164
+ return `if("serviceWorker"in navigator){addEventListener("load",function(){navigator.serviceWorker.register(${JSON.stringify(scriptUrl)}).catch(function(){})})}`
165
+ }