@nifrajs/web 5.1.0 → 5.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 (162) hide show
  1. package/CHANGELOG.md +3042 -0
  2. package/dist/build-plan.d.ts +7 -0
  3. package/dist/build-plan.d.ts.map +1 -1
  4. package/dist/build-plan.js +19 -0
  5. package/dist/build-plan.js.map +1 -1
  6. package/dist/build-vite.d.ts +1 -0
  7. package/dist/build-vite.d.ts.map +1 -1
  8. package/dist/build-vite.js +51 -3
  9. package/dist/build-vite.js.map +1 -1
  10. package/dist/build.d.ts +23 -3
  11. package/dist/build.d.ts.map +1 -1
  12. package/dist/build.js +224 -16
  13. package/dist/build.js.map +1 -1
  14. package/dist/client.d.ts +2 -1
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +14 -7
  17. package/dist/client.js.map +1 -1
  18. package/dist/csp.d.ts +2 -0
  19. package/dist/csp.d.ts.map +1 -1
  20. package/dist/csp.js +2 -0
  21. package/dist/csp.js.map +1 -1
  22. package/dist/deferred.d.ts.map +1 -1
  23. package/dist/deferred.js +23 -3
  24. package/dist/deferred.js.map +1 -1
  25. package/dist/index.d.ts +4 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +2 -2
  28. package/dist/index.js.map +1 -1
  29. package/dist/internal/codegen.d.ts +6 -0
  30. package/dist/internal/codegen.d.ts.map +1 -1
  31. package/dist/internal/codegen.js +23 -5
  32. package/dist/internal/codegen.js.map +1 -1
  33. package/dist/internal/copy-split.d.ts +27 -0
  34. package/dist/internal/copy-split.d.ts.map +1 -0
  35. package/dist/internal/copy-split.js +173 -0
  36. package/dist/internal/copy-split.js.map +1 -0
  37. package/dist/internal/deferred-free.d.ts +3 -0
  38. package/dist/internal/deferred-free.d.ts.map +1 -0
  39. package/dist/internal/deferred-free.js +15 -0
  40. package/dist/internal/deferred-free.js.map +1 -0
  41. package/dist/internal/module-preparation.d.ts +7 -0
  42. package/dist/internal/module-preparation.d.ts.map +1 -0
  43. package/dist/internal/module-preparation.js +24 -0
  44. package/dist/internal/module-preparation.js.map +1 -0
  45. package/dist/internal/output-guard.d.ts +2 -0
  46. package/dist/internal/output-guard.d.ts.map +1 -1
  47. package/dist/internal/output-guard.js +51 -9
  48. package/dist/internal/output-guard.js.map +1 -1
  49. package/dist/internal/page-execution.d.ts +20 -1
  50. package/dist/internal/page-execution.d.ts.map +1 -1
  51. package/dist/internal/page-execution.js +115 -50
  52. package/dist/internal/page-execution.js.map +1 -1
  53. package/dist/internal/parity.d.ts.map +1 -1
  54. package/dist/internal/parity.js +3 -1
  55. package/dist/internal/parity.js.map +1 -1
  56. package/dist/internal/prerendered-pages.d.ts +42 -0
  57. package/dist/internal/prerendered-pages.d.ts.map +1 -0
  58. package/dist/internal/prerendered-pages.js +92 -0
  59. package/dist/internal/prerendered-pages.js.map +1 -0
  60. package/dist/internal/public-url.d.ts +7 -0
  61. package/dist/internal/public-url.d.ts.map +1 -1
  62. package/dist/internal/public-url.js +13 -0
  63. package/dist/internal/public-url.js.map +1 -1
  64. package/dist/internal/render-document.d.ts +11 -2
  65. package/dist/internal/render-document.d.ts.map +1 -1
  66. package/dist/internal/render-document.js +131 -41
  67. package/dist/internal/render-document.js.map +1 -1
  68. package/dist/internal/request-url.d.ts +4 -0
  69. package/dist/internal/request-url.d.ts.map +1 -1
  70. package/dist/internal/request-url.js +24 -0
  71. package/dist/internal/request-url.js.map +1 -1
  72. package/dist/internal/secret-scan.d.ts +4 -0
  73. package/dist/internal/secret-scan.d.ts.map +1 -1
  74. package/dist/internal/secret-scan.js +8 -1
  75. package/dist/internal/secret-scan.js.map +1 -1
  76. package/dist/internal/typescript-api.d.ts +4 -0
  77. package/dist/internal/typescript-api.d.ts.map +1 -0
  78. package/dist/internal/typescript-api.js +8 -0
  79. package/dist/internal/typescript-api.js.map +1 -0
  80. package/dist/internal/vite-plugin-compat.d.ts +28 -0
  81. package/dist/internal/vite-plugin-compat.d.ts.map +1 -0
  82. package/dist/internal/vite-plugin-compat.js +82 -0
  83. package/dist/internal/vite-plugin-compat.js.map +1 -0
  84. package/dist/internal/web-app.d.ts +45 -0
  85. package/dist/internal/web-app.d.ts.map +1 -1
  86. package/dist/internal/web-app.js +32 -1
  87. package/dist/internal/web-app.js.map +1 -1
  88. package/dist/isr.d.ts +1 -0
  89. package/dist/isr.d.ts.map +1 -1
  90. package/dist/isr.js +7 -4
  91. package/dist/isr.js.map +1 -1
  92. package/dist/plugins/import-glob.d.ts +20 -0
  93. package/dist/plugins/import-glob.d.ts.map +1 -0
  94. package/dist/plugins/import-glob.js +401 -0
  95. package/dist/plugins/import-glob.js.map +1 -0
  96. package/dist/plugins/kit.d.ts +11 -0
  97. package/dist/plugins/kit.d.ts.map +1 -1
  98. package/dist/plugins/kit.js +32 -0
  99. package/dist/plugins/kit.js.map +1 -1
  100. package/dist/plugins/stylex.d.ts.map +1 -1
  101. package/dist/plugins/stylex.js +2 -2
  102. package/dist/plugins/stylex.js.map +1 -1
  103. package/dist/prerender.d.ts +4 -0
  104. package/dist/prerender.d.ts.map +1 -1
  105. package/dist/prerender.js +9 -1
  106. package/dist/prerender.js.map +1 -1
  107. package/dist/public-dir.d.ts +15 -0
  108. package/dist/public-dir.d.ts.map +1 -1
  109. package/dist/public-dir.js +91 -12
  110. package/dist/public-dir.js.map +1 -1
  111. package/dist/render-seam.d.ts +42 -2
  112. package/dist/render-seam.d.ts.map +1 -1
  113. package/dist/render-seam.js +33 -0
  114. package/dist/render-seam.js.map +1 -1
  115. package/dist/router.d.ts +3 -2
  116. package/dist/router.d.ts.map +1 -1
  117. package/dist/router.js +4 -4
  118. package/dist/router.js.map +1 -1
  119. package/dist/search.d.ts.map +1 -1
  120. package/dist/search.js +2 -0
  121. package/dist/search.js.map +1 -1
  122. package/dist/server-components/typescript.d.ts.map +1 -1
  123. package/dist/server-components/typescript.js +2 -7
  124. package/dist/server-components/typescript.js.map +1 -1
  125. package/dist/vite.d.ts +7 -28
  126. package/dist/vite.d.ts.map +1 -1
  127. package/dist/vite.js +130 -123
  128. package/dist/vite.js.map +1 -1
  129. package/package.json +5 -4
  130. package/src/build-plan.ts +21 -0
  131. package/src/build-vite.ts +60 -3
  132. package/src/build.ts +283 -39
  133. package/src/client.ts +15 -7
  134. package/src/csp.ts +4 -0
  135. package/src/deferred.ts +20 -3
  136. package/src/index.ts +9 -0
  137. package/src/internal/codegen.ts +32 -3
  138. package/src/internal/copy-split.ts +220 -0
  139. package/src/internal/deferred-free.ts +15 -0
  140. package/src/internal/module-preparation.ts +30 -0
  141. package/src/internal/output-guard.ts +51 -9
  142. package/src/internal/page-execution.ts +137 -53
  143. package/src/internal/parity.ts +2 -1
  144. package/src/internal/prerendered-pages.ts +124 -0
  145. package/src/internal/public-url.ts +13 -0
  146. package/src/internal/render-document.ts +179 -58
  147. package/src/internal/request-url.ts +24 -0
  148. package/src/internal/secret-scan.ts +9 -1
  149. package/src/internal/typescript-api.ts +11 -0
  150. package/src/internal/vite-plugin-compat.ts +81 -0
  151. package/src/internal/web-app.ts +82 -2
  152. package/src/isr.ts +7 -4
  153. package/src/plugins/import-glob.ts +392 -0
  154. package/src/plugins/kit.ts +36 -0
  155. package/src/plugins/stylex.ts +2 -1
  156. package/src/prerender.ts +13 -1
  157. package/src/public-dir.ts +107 -12
  158. package/src/render-seam.ts +65 -2
  159. package/src/router.ts +7 -6
  160. package/src/search.ts +1 -0
  161. package/src/server-components/typescript.ts +2 -10
  162. package/src/vite.ts +133 -135
package/src/public-dir.ts CHANGED
@@ -12,11 +12,12 @@
12
12
  * Distinct from `publicPath` in `build.ts`, which is the URL prefix for content-hashed bundle chunks.
13
13
  * The names collide and the concepts do not: `publicPath` never covers user-authored files.
14
14
  */
15
- import { realpath } from "node:fs/promises"
15
+ import { type FileHandle, realpath } from "node:fs/promises"
16
16
  import { extname, normalize, resolve, sep } from "node:path"
17
17
  import { Readable } from "node:stream"
18
18
  import { parseByteRange } from "@nifrajs/core/range"
19
19
  import { pathnameOf } from "@nifrajs/core/server"
20
+ import { isHiddenPublicPath } from "./internal/public-url.ts"
20
21
  import { openResolved } from "./open-resolved.ts"
21
22
 
22
23
  /** How long each subtree may be cached. Content-hashed bundle output can be immutable; a
@@ -96,15 +97,33 @@ export const CONTENT_TYPES: Readonly<Record<string, string>> = {
96
97
  }
97
98
 
98
99
  /**
99
- * Whether a confined path crosses a dot-segment other than `.well-known`. The build copies `public/`
100
- * with dotfiles included, so an editor or VCS artifact (`.env`, `.git/`, `.DS_Store`) dropped there
101
- * would otherwise be downloadable. Checked on the resolved path, so `%2e` encodings are covered.
100
+ * Whether a confined path crosses a hidden segment ({@link isHiddenPublicPath}). Checked on the
101
+ * resolved path, so `%2e` encodings are covered, and again on the real target, so a visible name
102
+ * linking to `.env` is refused too.
102
103
  */
103
104
  function isHiddenPath(root: string, abs: string): boolean {
104
- for (const segment of abs.slice(root.length + 1).split(sep)) {
105
- if (segment.startsWith(".") && segment !== ".well-known") return true
105
+ return abs !== root && isHiddenPublicPath(abs.slice(root.length + 1))
106
+ }
107
+
108
+ // Up to this many bytes are read whole through the pinned descriptor, which then closes before the
109
+ // response leaves: on Bun a 200 KB asset is served in 0.17 ms against 0.28 ms through a node:fs
110
+ // stream. A larger body streams, so a video holds 64 KiB per request rather than the file.
111
+ const BUFFERED_BODY_MAX = 256 * 1024
112
+
113
+ /** `length` bytes of `handle` from `start`; fewer when the file shrank since its stat. */
114
+ async function readPinned(
115
+ handle: FileHandle,
116
+ start: number,
117
+ length: number,
118
+ ): Promise<Uint8Array<ArrayBuffer>> {
119
+ const bytes = new Uint8Array(length)
120
+ let offset = 0
121
+ while (offset < length) {
122
+ const { bytesRead } = await handle.read(bytes, offset, length - offset, start + offset)
123
+ if (bytesRead === 0) break
124
+ offset += bytesRead
106
125
  }
107
- return false
126
+ return offset === length ? bytes : bytes.subarray(0, offset)
108
127
  }
109
128
 
110
129
  /** HTTP dates carry second precision; comparing raw milliseconds makes every file look stale. */
@@ -194,7 +213,8 @@ export function servePublicDir(
194
213
  if (
195
214
  resolvedRoot === undefined ||
196
215
  resolvedFile === undefined ||
197
- (resolvedFile !== resolvedRoot && !resolvedFile.startsWith(resolvedRoot + sep))
216
+ (resolvedFile !== resolvedRoot && !resolvedFile.startsWith(resolvedRoot + sep)) ||
217
+ isHiddenPath(resolvedRoot, resolvedFile)
198
218
  ) {
199
219
  return undefined
200
220
  }
@@ -243,7 +263,8 @@ export function servePublicDir(
243
263
  return new Response(null, { status: 304, headers })
244
264
  }
245
265
 
246
- const rangeHeader = request.headers.get("range")
266
+ // Range applies to GET only (RFC 9110 section 14.2); HEAD reports the whole representation.
267
+ const rangeHeader = head ? null : request.headers.get("range")
247
268
  const range =
248
269
  rangeHeader !== null && ifRangeMatches(request.headers.get("if-range"), lastModified)
249
270
  ? parseByteRange(rangeHeader, size)
@@ -255,7 +276,16 @@ export function servePublicDir(
255
276
  return new Response(null, { status: 416, headers })
256
277
  }
257
278
 
258
- const body = (start?: number, end?: number): ReadableStream<Uint8Array> => {
279
+ const body = async (
280
+ start: number,
281
+ end: number,
282
+ ): Promise<Uint8Array<ArrayBuffer> | ReadableStream<Uint8Array>> => {
283
+ const length = end - start + 1
284
+ if (length <= BUFFERED_BODY_MAX) {
285
+ const bytes = await readPinned(handle!, start, length)
286
+ headers.set("content-length", String(bytes.byteLength))
287
+ return bytes
288
+ }
259
289
  const stream = handle!.createReadStream({ start, end })
260
290
  // FileHandle.createReadStream owns closing the descriptor after EOF/abort.
261
291
  handle = undefined
@@ -266,14 +296,14 @@ export function servePublicDir(
266
296
  const { start, end } = range.ranges[0]!
267
297
  headers.set("content-range", `bytes ${start}-${end}/${size}`)
268
298
  headers.set("content-length", String(end - start + 1))
269
- return new Response(head ? null : body(start, end), { status: 206, headers })
299
+ return new Response(head ? null : await body(start, end), { status: 206, headers })
270
300
  }
271
301
 
272
302
  // Multiple ranges would require assembling `multipart/byteranges`, and doing that for a file on
273
303
  // disk means buffering the whole representation to serve a request that asked for less of it.
274
304
  // RFC 9110 lets a server ignore Range entirely, so the full body is the conformant answer here.
275
305
  headers.set("content-length", String(size))
276
- return new Response(head ? null : body(), { headers })
306
+ return new Response(head || size === 0 ? null : await body(0, size - 1), { headers })
277
307
  } finally {
278
308
  if (handle !== undefined) {
279
309
  try {
@@ -285,3 +315,68 @@ export function servePublicDir(
285
315
  }
286
316
  }
287
317
  }
318
+
319
+ export interface PrerenderedPageReaderOptions {
320
+ /** Absolute path of the directory the build wrote the prerendered pages into. */
321
+ readonly dir: string
322
+ /** Each page path mapped to its file, relative to `dir`. */
323
+ readonly files: Readonly<Record<string, string>>
324
+ /** Bytes of HTML kept in memory once read (default 32 MiB); a page past the budget is read per request. */
325
+ readonly cacheBytes?: number
326
+ }
327
+
328
+ /**
329
+ * Read a self-hosted build's prerendered pages for `createWebApp`'s `prerenderedPages`. Only the build's
330
+ * own files are reachable, each opened only once its real path is inside `dir`, so a link planted in the
331
+ * deployed tree cannot point a page elsewhere. A deploy's files do not change while it runs, so a page
332
+ * is kept after its first read; `undefined` (a missing or refused file) renders the page on demand.
333
+ */
334
+ export function prerenderedPageReader(
335
+ options: PrerenderedPageReaderOptions,
336
+ ): (pathname: string) => Promise<string | undefined> {
337
+ const root = resolve(options.dir)
338
+ const rootReal = realpath(root).catch(() => undefined)
339
+ const files = new Map(Object.entries(options.files))
340
+ const budget = options.cacheBytes ?? 32 * 1024 * 1024
341
+ const cache = new Map<string, string>()
342
+ let cachedBytes = 0
343
+ return async (pathname) => {
344
+ const cached = cache.get(pathname)
345
+ if (cached !== undefined) return cached
346
+ const rel = files.get(pathname)
347
+ if (rel === undefined) return undefined
348
+ const abs = resolve(root, rel)
349
+ if (!abs.startsWith(root + sep) || isHiddenPath(root, abs)) return undefined
350
+ const [resolvedRoot, resolvedFile] = await Promise.all([
351
+ rootReal,
352
+ realpath(abs).catch(() => undefined),
353
+ ])
354
+ if (
355
+ resolvedRoot === undefined ||
356
+ resolvedFile === undefined ||
357
+ !resolvedFile.startsWith(resolvedRoot + sep)
358
+ ) {
359
+ return undefined
360
+ }
361
+ let handle: FileHandle
362
+ try {
363
+ handle = await openResolved(resolvedFile)
364
+ } catch {
365
+ return undefined
366
+ }
367
+ try {
368
+ if (!(await handle.stat()).isFile()) return undefined
369
+ const bytes = await handle.readFile()
370
+ const html = bytes.toString("utf8")
371
+ if (!cache.has(pathname) && cachedBytes + bytes.byteLength <= budget) {
372
+ cache.set(pathname, html)
373
+ cachedBytes += bytes.byteLength
374
+ }
375
+ return html
376
+ } catch {
377
+ return undefined
378
+ } finally {
379
+ await handle.close().catch(() => undefined)
380
+ }
381
+ }
382
+ }
@@ -33,6 +33,61 @@ export interface RenderProps {
33
33
  readonly boundaries?: BoundaryStates
34
34
  /** The rendered chain's module ids and `handle` exports, for the adapter's `useMatches`. */
35
35
  readonly matchChain?: MatchChain
36
+ /** The page's locale, when the app registers its locale routing (`createWebApp`'s `i18n`). Set on
37
+ * SSR only; the client names it from `path` (see {@link localeOfProps}). */
38
+ readonly locale?: PageLocale
39
+ }
40
+
41
+ /** A served locale as a page renders in it: from the app's locale registry. */
42
+ export interface PageLocale {
43
+ /** The registry key, which is also the URL segment (`ur`). */
44
+ readonly key: string
45
+ /** The BCP-47 tag `<html lang>` and `Content-Language` carry (`ur-PK`). */
46
+ readonly lang: string
47
+ readonly dir: "ltr" | "rtl"
48
+ }
49
+
50
+ /** The served locales, handed to the client so it names a page's locale from its path the way the
51
+ * server does. */
52
+ export interface PageLocales {
53
+ /** The default locale's key. */
54
+ readonly default: string
55
+ /** Every served locale, in registry order. */
56
+ readonly locales: readonly PageLocale[]
57
+ }
58
+
59
+ /** The handover global carrying {@link PageLocales}. */
60
+ export const LOCALES_GLOBAL = "__NIFRA_LOCALES__"
61
+
62
+ /**
63
+ * The locale a path is in, by the rule `@nifrajs/i18n/routing`'s `localeOf` follows: a first path
64
+ * segment that names a served locale, ignoring ASCII case, or else the default.
65
+ */
66
+ export function pageLocaleIn(config: PageLocales, path: string): PageLocale | undefined {
67
+ // One linear pass: separators, then the segment up to the next separator, query or hash.
68
+ const first = /^[/\\]*([^/\\?#]*)/.exec(path.replace(/[\t\n\r]/g, ""))?.[1]?.toLowerCase()
69
+ return (
70
+ config.locales.find((locale) => first !== "" && locale.key.toLowerCase() === first) ??
71
+ config.locales.find((locale) => locale.key === config.default)
72
+ )
73
+ }
74
+
75
+ const isPageLocales = (value: unknown): value is PageLocales =>
76
+ typeof value === "object" && value !== null && "locales" in value && Array.isArray(value.locales)
77
+
78
+ /** The locale of the page at `path`, from the served locales the server handed over; `undefined`
79
+ * outside a browser page, or when the app registers no locale routing. */
80
+ export function handoverLocale(path: string): PageLocale | undefined {
81
+ const config: unknown = Reflect.get(globalThis, LOCALES_GLOBAL)
82
+ return isPageLocales(config) ? pageLocaleIn(config, path) : undefined
83
+ }
84
+
85
+ /** The locale of the page `props` render: the server's answer on SSR, else the one the client names
86
+ * from `props.path`. What every adapter's `useLocale` reads. */
87
+ export function localeOfProps(props: RenderProps | undefined): PageLocale | undefined {
88
+ if (props === undefined) return undefined
89
+ if (props.locale !== undefined) return props.locale
90
+ return props.path === undefined ? undefined : handoverLocale(props.path)
36
91
  }
37
92
 
38
93
  /**
@@ -100,13 +155,16 @@ export interface RenderAdapter {
100
155
  ): ReadableStream<Uint8Array> | Promise<ReadableStream<Uint8Array>>
101
156
  /**
102
157
  * Render the chain to a complete HTML string when no content is deferred. `options.nonce` is the
103
- * document's CSP nonce, for any inline script the renderer emits (Solid's hydration data).
158
+ * document's CSP nonce, for any inline script the renderer emits (Solid's hydration data); a
159
+ * document that sends a policy keeps the nonce only when the returned HTML carries it. Return
160
+ * `undefined` when the render suspended on something only `renderToStream` waits for (React's
161
+ * `use` or `lazy` inside `<Suspense>`): the page is then streamed instead.
104
162
  */
105
163
  renderToString?(
106
164
  chain: readonly unknown[],
107
165
  props: RenderProps,
108
166
  options?: RenderStreamOptions,
109
- ): string | Promise<string>
167
+ ): string | undefined | Promise<string | undefined>
110
168
  /** Per-document bootstrap markup injected into `<head>` for client hydration. */
111
169
  hydrationHead(nonce?: string): string
112
170
  /**
@@ -151,5 +209,10 @@ export const ACTION_GLOBAL = "__NIFRA_ACTION__"
151
209
  /** Dynamic-boundary states for hydration; absent when a route declares no boundaries. */
152
210
  export const BOUNDARY_GLOBAL = "__NIFRA_BOUNDARIES__"
153
211
  export const COMPONENT_GLOBAL = "__NIFRA_COMPONENTS__"
212
+ /** Locale key -> copy chunk URL, in a production build that split copy tables by locale. The name
213
+ * `@nifrajs/i18n/copy` reads. */
214
+ export const COPY_CHUNKS_GLOBAL = "__NIFRA_COPY__"
215
+ /** The registry `@nifrajs/i18n/copy` keeps its loaded chunks and definitions in. */
216
+ export const COPY_REALM = "nifra.copy"
154
217
  /** Marker attribute used to find a non-default hydration container. */
155
218
  export const ROOT_ATTRIBUTE = "data-nifra-root"
package/src/router.ts CHANGED
@@ -310,8 +310,9 @@ export interface ClientRouterOptions {
310
310
  /** Override the loader-data fetch (tests inject a stub; defaults to a same-origin JSON GET). */
311
311
  readonly fetchData?: FetchRouteData
312
312
  /** Ensure a route's code chunk is loaded before rendering (code-splitting). Awaited in parallel
313
- * with the loader data, so `pending` covers both. Omit when the bundle isn't split. */
314
- readonly loadModule?: (routeId: string) => Promise<void>
313
+ * with the loader data, so `pending` covers both. Omit when the bundle isn't split. `path` is the URL
314
+ * the route is about to render, for code loaded per URL (a production build's copy chunks). */
315
+ readonly loadModule?: (routeId: string, path?: string) => Promise<void>
315
316
  /** Terminal status → client route id. Generated entries populate this from `_404` and
316
317
  * `_<status>` files so soft navigation renders the same boundary as a hard request. */
317
318
  readonly statusRoutes?: Readonly<Record<number, string>>
@@ -667,7 +668,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
667
668
  )
668
669
  if (mine !== generation) return
669
670
  if (loaded.terminalRouteId !== undefined) {
670
- await loadModule?.(loaded.terminalRouteId)
671
+ await loadModule?.(loaded.terminalRouteId, state.path)
671
672
  state = { ...state, routeId: loaded.terminalRouteId, params: {}, data: null }
672
673
  } else {
673
674
  state = {
@@ -788,7 +789,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
788
789
  state = { ...state, pendingPath: at }
789
790
  emit()
790
791
  }
791
- await loadModule?.(route.routeId)
792
+ await loadModule?.(route.routeId, at)
792
793
  // Prefetched server data is one-shot; a clientLoader still runs over it without another request.
793
794
  const prefetchedData = usePrefetch ? freshPrefetch(at) : undefined
794
795
  if (usePrefetch) prefetched.delete(at)
@@ -801,7 +802,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
801
802
  prefetchedData,
802
803
  )
803
804
  if (loaded.terminalRouteId !== undefined) {
804
- await loadModule?.(loaded.terminalRouteId)
805
+ await loadModule?.(loaded.terminalRouteId, at)
805
806
  if (options.layoutStatusRoutes?.[loaded.terminalRouteId] === true) {
806
807
  throw new Error(
807
808
  `[nifra/web] ${loaded.terminalRouteId} renders inside layouts; loading ${at} as a document`,
@@ -1215,7 +1216,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
1215
1216
  // Unwrapped here, so a prefetched entry is the same shape `navigate` builds state from -
1216
1217
  // otherwise a prefetch hit would publish an envelope where a miss publishes loader data.
1217
1218
  const [, loaded] = await Promise.all([
1218
- loadModule?.(matched.routeId),
1219
+ loadModule?.(matched.routeId, path),
1219
1220
  loadRouteData(path, matched),
1220
1221
  ])
1221
1222
  prefetched.delete(path) // a stale entry re-enters as the newest
package/src/search.ts CHANGED
@@ -107,6 +107,7 @@ function encodeValue(value: unknown): string {
107
107
  /** The default JSON-first codec. Repeated keys (`?t=a&t=b`) decode to an array; single keys to a value. */
108
108
  const jsonCodec: SearchCodec = {
109
109
  parse(raw, limits) {
110
+ if (raw === "" || raw === "?") return {}
110
111
  const usp = new URLSearchParams(raw.startsWith("?") ? raw.slice(1) : raw)
111
112
  // Two passes instead of the old `Set(keys) + getAll(key)` shape, which rescanned the complete
112
113
  // URLSearchParams list once per distinct key (O(keys²)). Admission must be decided BEFORE any
@@ -1,18 +1,10 @@
1
1
  import { createRequire } from "node:module"
2
2
  import type * as TS from "typescript"
3
+ import { isTypeScriptCompiler } from "../internal/typescript-api.ts"
3
4
 
4
5
  /** The code of the error a TypeScript without the in-process compiler API raises here. */
5
6
  export const UNSUPPORTED_TYPESCRIPT = "NIFRA_TS_UNSUPPORTED"
6
7
 
7
- function isCompiler(value: unknown): value is typeof TS {
8
- return (
9
- typeof value === "object" &&
10
- value !== null &&
11
- "createSourceFile" in value &&
12
- typeof value.createSourceFile === "function"
13
- )
14
- }
15
-
16
8
  /**
17
9
  * The TypeScript compiler API, resolved from `from` the way `import "typescript"` there would be.
18
10
  * TypeScript 7's package ships no in-process compiler API (its root export is only `version`), so
@@ -20,7 +12,7 @@ function isCompiler(value: unknown): value is typeof TS {
20
12
  */
21
13
  export function loadTypeScript(from: string = import.meta.url): typeof TS {
22
14
  const loaded: unknown = createRequire(from)("typescript")
23
- if (isCompiler(loaded)) return loaded
15
+ if (isTypeScriptCompiler(loaded)) return loaded
24
16
  const version =
25
17
  typeof loaded === "object" &&
26
18
  loaded !== null &&