@nifrajs/web 2.2.0 → 2.4.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.
- package/README.md +3 -3
- package/dist/build-vite.d.ts +4 -4
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +20 -74
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +64 -42
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +100 -78
- package/dist/build.js.map +1 -1
- package/dist/bun-dev-entry.d.ts +1 -1
- package/dist/bun-dev-entry.js +1 -1
- package/dist/chunk-names.js +1 -1
- package/dist/client.d.ts +3 -9
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +201 -29
- package/dist/client.js.map +1 -1
- package/dist/conformance.d.ts +2 -2
- package/dist/conformance.js +3 -3
- package/dist/deferred.d.ts +11 -11
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +16 -16
- package/dist/dev-diagnostics.d.ts +20 -0
- package/dist/dev-diagnostics.d.ts.map +1 -0
- package/dist/dev-diagnostics.js +34 -0
- package/dist/dev-diagnostics.js.map +1 -0
- package/dist/dev-error.d.ts +10 -13
- package/dist/dev-error.d.ts.map +1 -1
- package/dist/dev-error.js +51 -37
- package/dist/dev-error.js.map +1 -1
- package/dist/dev-port.d.ts +3 -3
- package/dist/dev-port.d.ts.map +1 -1
- package/dist/dev-port.js +10 -4
- package/dist/dev-port.js.map +1 -1
- package/dist/dev.d.ts +4 -4
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +28 -12
- package/dist/dev.js.map +1 -1
- package/dist/diagnostic.d.ts +93 -0
- package/dist/diagnostic.d.ts.map +1 -0
- package/dist/diagnostic.js +205 -0
- package/dist/diagnostic.js.map +1 -0
- package/dist/draft.d.ts +12 -12
- package/dist/draft.js +11 -11
- package/dist/fn-state.d.ts +56 -0
- package/dist/fn-state.d.ts.map +1 -0
- package/dist/fn-state.js +77 -0
- package/dist/fn-state.js.map +1 -0
- package/dist/fn.d.ts +111 -0
- package/dist/fn.d.ts.map +1 -0
- package/dist/fn.js +125 -0
- package/dist/fn.js.map +1 -0
- package/dist/fonts-google.d.ts +9 -9
- package/dist/fonts-google.js +10 -10
- package/dist/fonts.d.ts +7 -7
- package/dist/fonts.js +1 -1
- package/dist/forms.d.ts +8 -8
- package/dist/fs.d.ts +1 -1
- package/dist/fs.js +1 -1
- package/dist/index.d.ts +151 -70
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +317 -139
- package/dist/index.js.map +1 -1
- package/dist/internal/head-attributes.d.ts +15 -0
- package/dist/internal/head-attributes.d.ts.map +1 -0
- package/dist/internal/head-attributes.js +99 -0
- package/dist/internal/head-attributes.js.map +1 -0
- package/dist/internal/script-types.d.ts +14 -0
- package/dist/internal/script-types.d.ts.map +1 -0
- package/dist/internal/script-types.js +17 -0
- package/dist/internal/script-types.js.map +1 -0
- package/dist/internal/server-boundary.d.ts +60 -0
- package/dist/internal/server-boundary.d.ts.map +1 -0
- package/dist/internal/server-boundary.js +174 -0
- package/dist/internal/server-boundary.js.map +1 -0
- package/dist/internal/timing-safe-equal.d.ts +1 -1
- package/dist/internal/timing-safe-equal.js +1 -1
- package/dist/internal/vite-import.d.ts +63 -0
- package/dist/internal/vite-import.d.ts.map +1 -0
- package/dist/internal/vite-import.js +94 -0
- package/dist/internal/vite-import.js.map +1 -0
- package/dist/islands.d.ts +5 -5
- package/dist/islands.js +5 -5
- package/dist/isr.d.ts +15 -15
- package/dist/isr.js +7 -7
- package/dist/manifest.d.ts +73 -35
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +10 -16
- package/dist/manifest.js.map +1 -1
- package/dist/module-graph.d.ts +5 -5
- package/dist/module-graph.js +4 -4
- package/dist/navigation.d.ts +120 -5
- package/dist/navigation.d.ts.map +1 -1
- package/dist/navigation.js +37 -5
- package/dist/navigation.js.map +1 -1
- package/dist/plugins/css-modules.d.ts +3 -3
- package/dist/plugins/css-modules.js +0 -0
- package/dist/plugins/kit.d.ts +8 -8
- package/dist/plugins/kit.js +9 -9
- package/dist/plugins/scss.js +3 -3
- package/dist/plugins/vite-leak-guard.d.ts +1 -1
- package/dist/plugins/vite-leak-guard.js +1 -1
- package/dist/plugins/vite-server-fn.d.ts +23 -0
- package/dist/plugins/vite-server-fn.d.ts.map +1 -0
- package/dist/plugins/vite-server-fn.js +51 -0
- package/dist/plugins/vite-server-fn.js.map +1 -0
- package/dist/plugins/vite-server-only.d.ts +49 -0
- package/dist/plugins/vite-server-only.d.ts.map +1 -0
- package/dist/plugins/vite-server-only.js +57 -0
- package/dist/plugins/vite-server-only.js.map +1 -0
- package/dist/prerender.d.ts +5 -5
- package/dist/prerender.js +7 -7
- package/dist/query.d.ts +10 -10
- package/dist/query.js +15 -15
- package/dist/route-manifest.d.ts +1 -1
- package/dist/route-manifest.js +2 -2
- package/dist/router.d.ts +30 -21
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +62 -32
- package/dist/router.js.map +1 -1
- package/dist/search.d.ts +94 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +232 -0
- package/dist/search.js.map +1 -0
- package/dist/server-only.d.ts +6 -6
- package/dist/server-only.js +7 -7
- package/dist/service-worker.d.ts +56 -0
- package/dist/service-worker.d.ts.map +1 -0
- package/dist/service-worker.js +127 -0
- package/dist/service-worker.js.map +1 -0
- package/dist/vite.d.ts +16 -14
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +51 -24
- package/dist/vite.js.map +1 -1
- package/package.json +38 -4
- package/server-manifest.d.ts +31 -0
- package/src/build-vite.ts +24 -77
- package/src/build.ts +137 -105
- package/src/bun-dev-entry.ts +1 -1
- package/src/chunk-names.ts +1 -1
- package/src/client.ts +215 -32
- package/src/conformance.ts +3 -3
- package/src/deferred.ts +17 -17
- package/src/dev-diagnostics.ts +47 -0
- package/src/dev-error.ts +64 -33
- package/src/dev-port.ts +17 -5
- package/src/dev.ts +36 -20
- package/src/diagnostic.ts +284 -0
- package/src/draft.ts +15 -15
- package/src/fn-state.ts +107 -0
- package/src/fn.ts +199 -0
- package/src/fonts-google.ts +13 -13
- package/src/fonts.ts +7 -7
- package/src/forms.ts +8 -8
- package/src/fs.ts +2 -2
- package/src/index.ts +460 -181
- package/src/internal/head-attributes.ts +109 -0
- package/src/internal/script-types.ts +18 -0
- package/src/internal/server-boundary.ts +187 -0
- package/src/internal/timing-safe-equal.ts +1 -1
- package/src/internal/vite-import.ts +92 -0
- package/src/islands.ts +7 -7
- package/src/isr.ts +17 -17
- package/src/manifest.ts +83 -42
- package/src/module-graph.ts +6 -6
- package/src/navigation.ts +151 -5
- package/src/plugins/css-modules.ts +0 -0
- package/src/plugins/kit.ts +11 -11
- package/src/plugins/scss.ts +3 -3
- package/src/plugins/vite-leak-guard.ts +2 -2
- package/src/plugins/vite-server-fn.ts +67 -0
- package/src/plugins/vite-server-only.ts +75 -0
- package/src/prerender.ts +10 -10
- package/src/query.ts +22 -22
- package/src/route-manifest.ts +3 -3
- package/src/router.ts +98 -45
- package/src/search.ts +297 -0
- package/src/server-only.ts +7 -7
- package/src/service-worker.ts +165 -0
- package/src/vite.ts +61 -32
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
|
+
}
|
package/src/server-only.ts
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/web/server-only`
|
|
3
|
-
* "server-only"`). A module of PURE server logic that carries no `node:` import
|
|
4
|
-
* constant, a server-only API call
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
+
}
|