@nifrajs/web 2.5.0 → 2.6.1

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 (44) hide show
  1. package/dist/build.d.ts.map +1 -1
  2. package/dist/build.js +18 -9
  3. package/dist/build.js.map +1 -1
  4. package/dist/fonts-google.d.ts.map +1 -1
  5. package/dist/fonts-google.js +84 -16
  6. package/dist/fonts-google.js.map +1 -1
  7. package/dist/index.d.ts +27 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +104 -58
  10. package/dist/index.js.map +1 -1
  11. package/dist/internal/js-string.d.ts +9 -0
  12. package/dist/internal/js-string.d.ts.map +1 -0
  13. package/dist/internal/js-string.js +18 -0
  14. package/dist/internal/js-string.js.map +1 -0
  15. package/dist/internal/server-boundary.d.ts.map +1 -1
  16. package/dist/internal/server-boundary.js +41 -7
  17. package/dist/internal/server-boundary.js.map +1 -1
  18. package/dist/manifest.d.ts.map +1 -1
  19. package/dist/manifest.js +4 -1
  20. package/dist/manifest.js.map +1 -1
  21. package/dist/plugins/svg.d.ts +9 -0
  22. package/dist/plugins/svg.d.ts.map +1 -1
  23. package/dist/plugins/svg.js +39 -5
  24. package/dist/plugins/svg.js.map +1 -1
  25. package/dist/prerender.d.ts.map +1 -1
  26. package/dist/prerender.js +10 -2
  27. package/dist/prerender.js.map +1 -1
  28. package/dist/router.d.ts.map +1 -1
  29. package/dist/router.js +13 -2
  30. package/dist/router.js.map +1 -1
  31. package/dist/service-worker.d.ts.map +1 -1
  32. package/dist/service-worker.js +2 -1
  33. package/dist/service-worker.js.map +1 -1
  34. package/package.json +2 -2
  35. package/src/build.ts +17 -9
  36. package/src/fonts-google.ts +73 -15
  37. package/src/index.ts +138 -67
  38. package/src/internal/js-string.ts +19 -0
  39. package/src/internal/server-boundary.ts +40 -7
  40. package/src/manifest.ts +4 -1
  41. package/src/plugins/svg.ts +37 -5
  42. package/src/prerender.ts +10 -2
  43. package/src/router.ts +13 -2
  44. package/src/service-worker.ts +3 -1
@@ -212,30 +212,87 @@ export function googleFontsCssUrl(options: GoogleFontOptions): string {
212
212
  return `${CSS2_ENDPOINT}?${params.join("&")}`
213
213
  }
214
214
 
215
- const FACE_RE = /(?:\/\*([^*]*)\*\/\s*)?@font-face\s*\{([^}]*)\}/g
216
- const SRC_RE = /url\(\s*(['"]?)([^'")]+)\1\s*\)(?:\s*format\(\s*(['"]?)([^'")]+)\3\s*\))?/g
215
+ // The stylesheet is REMOTE input (Google's CDN, but still an upstream), so the whole parse is
216
+ // forward index scans - no unanchored regex runs on it, and no shape can backtrack polynomially on
217
+ // an adversarial response.
218
+
219
+ /** The `/* subset *​/` label immediately preceding a `@font-face` block (only whitespace between),
220
+ * scanned by index in the gap since the previous block. */
221
+ function subsetLabelBefore(css: string, faceStart: number, prevEnd: number): string | undefined {
222
+ const between = css.slice(prevEnd, faceStart)
223
+ const open = between.lastIndexOf("/*")
224
+ if (open === -1) return undefined
225
+ const close = between.indexOf("*/", open + 2)
226
+ if (close === -1 || between.slice(close + 2).trim() !== "") return undefined
227
+ return between.slice(open + 2, close)
228
+ }
217
229
 
230
+ /** The value of `prop: value;` inside a declaration body, by case-insensitive index scan. */
218
231
  function declOf(body: string, prop: string): string | undefined {
219
- const m = body.match(new RegExp(`${prop}\\s*:\\s*([^;]+);`, "i"))
220
- return m?.[1]?.trim()
232
+ const lower = body.toLowerCase()
233
+ const needle = prop.toLowerCase()
234
+ for (let at = lower.indexOf(needle); at !== -1; at = lower.indexOf(needle, at + 1)) {
235
+ let i = at + needle.length
236
+ while (i < body.length && (body[i] === " " || body[i] === "\t" || body[i] === "\n")) i++
237
+ if (body[i] !== ":") continue
238
+ i++
239
+ const end = body.indexOf(";", i)
240
+ if (end === -1) continue // unterminated declaration - same as the semicolon-requiring regex
241
+ return body.slice(i, end).trim()
242
+ }
243
+ return undefined
244
+ }
245
+
246
+ /** Strip one level of matching quotes. */
247
+ function unquote(value: string): string {
248
+ const first = value[0]
249
+ return (first === '"' || first === "'") && value.endsWith(first) && value.length > 1
250
+ ? value.slice(1, -1)
251
+ : value
252
+ }
253
+
254
+ /** Every `url(…)` (with optional trailing `format(…)`) in a declaration body, by index scan. */
255
+ function parseSrc(body: string): { url: string; format?: string }[] {
256
+ const src: { url: string; format?: string }[] = []
257
+ for (let at = body.indexOf("url("); at !== -1; at = body.indexOf("url(", at + 1)) {
258
+ const close = body.indexOf(")", at + 4)
259
+ if (close === -1) break
260
+ const url = unquote(body.slice(at + 4, close).trim())
261
+ if (url === "") continue
262
+ let cursor = close + 1
263
+ while (cursor < body.length && (body[cursor] === " " || body[cursor] === "\t")) cursor++
264
+ let format: string | undefined
265
+ if (body.startsWith("format(", cursor)) {
266
+ const formatClose = body.indexOf(")", cursor + 7)
267
+ if (formatClose !== -1) format = unquote(body.slice(cursor + 7, formatClose).trim())
268
+ }
269
+ src.push(format === undefined || format === "" ? { url } : { url, format })
270
+ }
271
+ return src
221
272
  }
222
273
 
223
274
  /** Parse Google's stylesheet into structured faces, capturing the `/* subset *​/` label that precedes
224
275
  * each `@font-face`. Pure - exported so callers can run their own download/write pipeline. */
225
276
  export function parseGoogleFontCss(css: string): ParsedFontFace[] {
226
277
  const faces: ParsedFontFace[] = []
227
- for (const match of css.matchAll(FACE_RE)) {
228
- const subset = (match[1] ?? "").trim() || "default"
229
- const body = match[2] ?? ""
278
+ let prevEnd = 0
279
+ // The next-`{` position is memoized: without it, a crafted response of N at-rules ahead of one
280
+ // late brace re-scans for that brace N times - the quadratic shape this parser exists to avoid.
281
+ let openMemo = -1
282
+ for (let at = css.indexOf("@font-face"); at !== -1; at = css.indexOf("@font-face", at + 1)) {
283
+ if (openMemo < at + 10) openMemo = css.indexOf("{", at + 10)
284
+ const open = openMemo
285
+ if (open === -1) break
286
+ if (css.slice(at + 10, open).trim() !== "") continue // not this at-rule's block
287
+ const closeBrace = css.indexOf("}", open + 1)
288
+ if (closeBrace === -1) break
289
+ const subset = (subsetLabelBefore(css, at, prevEnd) ?? "").trim() || "default"
290
+ prevEnd = closeBrace + 1
291
+ const body = css.slice(open + 1, closeBrace)
230
292
  const familyRaw = declOf(body, "font-family")
231
293
  if (familyRaw === undefined) continue
232
- const family = familyRaw.replace(/^['"]|['"]$/g, "")
233
- const src: { url: string; format?: string }[] = []
234
- for (const s of body.matchAll(SRC_RE)) {
235
- const url = s[2]
236
- if (url === undefined) continue
237
- src.push(s[4] === undefined ? { url } : { url, format: s[4] })
238
- }
294
+ const family = unquote(familyRaw)
295
+ const src = parseSrc(body)
239
296
  if (src.length === 0) continue
240
297
  const unicodeRange = declOf(body, "unicode-range")
241
298
  faces.push({
@@ -316,7 +373,8 @@ export async function loadGoogleFont(
316
373
  const family = validateFamily(options.family)
317
374
  const fetchImpl = io.fetch ?? fetch
318
375
  const writeFile = io.writeFile ?? defaultWriteFile
319
- const publicPath = (io.publicPath ?? "/fonts").replace(/\/+$/, "")
376
+ let publicPath = io.publicPath ?? "/fonts"
377
+ while (publicPath.endsWith("/")) publicPath = publicPath.slice(0, -1)
320
378
  const maxBytes = io.maxBytesPerFile ?? DEFAULT_MAX_FONT_BYTES
321
379
  const wantedSubsets = options.subsets?.map(validateSubset)
322
380
 
package/src/index.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  } from "./deferred.ts"
26
26
  import { isDraftEnabled } from "./draft.ts"
27
27
  import { trustedHeadAttributes } from "./internal/head-attributes.ts"
28
+ import { jsStringLiteral } from "./internal/js-string.ts"
28
29
  import { EXECUTABLE_SCRIPT_TYPES, INERT_SCRIPT_TYPES } from "./internal/script-types.ts"
29
30
  import { ISR_REVALIDATE_HEADER } from "./isr.ts"
30
31
  import { generateLlmsTxt } from "./llms-txt.ts"
@@ -478,6 +479,34 @@ export interface RenderPageOptions {
478
479
  readonly islandScripts?: readonly string[]
479
480
  /** CSP nonce applied to every framework-owned executable script in this document. */
480
481
  readonly nonce?: string
482
+ /**
483
+ * Advanced: a per-route slot the renderer fills with the request-invariant document pieces (shell
484
+ * prefix/suffix, tail statics) on the first render and reuses afterwards, skipping their re-assembly.
485
+ *
486
+ * Only pass a cache when every shell-shaping input is IDENTICAL across the requests sharing it:
487
+ * same `head` content (a static meta chain - never a `meta(data)` function), `title`, `styles`,
488
+ * `preload`, `islandScripts`, `clientEntry`, `rootId`, `hydrate`, and `prerenderedPaths`. Per-request
489
+ * values (`data`, `params`, `search`, `actionData`, `layoutData`, deferred state) are always assembled
490
+ * fresh and safe. A per-request `nonce` disables the cache automatically. `createWebApp` wires this
491
+ * per route, gated on the route + layout metas being static.
492
+ */
493
+ readonly assemblyCache?: RenderAssemblyCache
494
+ }
495
+
496
+ /** The mutable per-route slot {@link RenderPageOptions.assemblyCache} fills. Opaque - create as `{}`. */
497
+ export interface RenderAssemblyCache {
498
+ /** Shell up to (not including) the deferred-runtime insertion point. */
499
+ shellPre?: string
500
+ /** Shell from after the deferred-runtime insertion point through the open `#root` container. */
501
+ shellPost?: string
502
+ /** Tail opener: the inline script open tag + the route-id global (before the action global). */
503
+ tailPre?: string
504
+ /** Tail mid: the prerendered-paths global (between the action and layout-data globals). */
505
+ tailMid?: string
506
+ /** Tail data prefix: the `window.<data global>=` assignment head. */
507
+ tailData?: string
508
+ /** Tail from after the serialized data through `</html>`. */
509
+ tailPost?: string
481
510
  }
482
511
 
483
512
  /**
@@ -554,7 +583,12 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
554
583
  actionData === undefined
555
584
  ? undefined
556
585
  : prepareDeferred(actionData, deferred.length + layoutDeferred.length)
557
- const allDeferred = [...deferred, ...layoutDeferred, ...(actionSplit ? actionSplit.deferred : [])]
586
+ // On the common page-only path (no layout loader, no action) reuse `deferred` directly instead of
587
+ // allocating a fresh spread array every request.
588
+ const allDeferred =
589
+ layoutDeferred.length === 0 && actionSplit === undefined
590
+ ? deferred
591
+ : [...deferred, ...layoutDeferred, ...(actionSplit ? actionSplit.deferred : [])]
558
592
  // Omitted entirely when no layout has a loader - a page-only app emits exactly what it did before.
559
593
  const layoutTail =
560
594
  options.layoutData === undefined
@@ -568,71 +602,86 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
568
602
  : `window.${ACTION_GLOBAL}=${serializeData(actionSplit.forClient)};`
569
603
  const deferredRuntime =
570
604
  allDeferred.length > 0 ? `<script${nonceAttr}>${DEFERRED_RUNTIME}</script>` : ""
571
- // Matched-route chunk preloads, concatenated directly (skip the `filter().map()`
572
- // intermediate arrays - and the whole loop - on the common no-extra-preload render). De-duped
573
- // against the entry, which is preloaded separately below.
574
- let preloadLinks = ""
575
- if (preload.length > 0) {
605
+ // The regex only injects the CSP nonce into the (constant) hydration head; with no nonce it's a
606
+ // no-op that still scans the whole script every request. Skip it on the common no-nonce path.
607
+ // The request-invariant document pieces. With a caller-supplied per-route cache (and no per-request
608
+ // nonce, which would bake into them) they're built once and reused; the per-request seams - the
609
+ // deferred runtime in the shell, the action/layout-data globals and the serialized loader data in
610
+ // the tail - are always assembled fresh. Byte-identical to building the whole document inline.
611
+ const slot: RenderAssemblyCache =
612
+ nonce === undefined && options.assemblyCache !== undefined ? options.assemblyCache : {}
613
+ if (slot.shellPre === undefined) {
614
+ // Matched-route chunk preloads, concatenated directly. De-duped against the entry, which is
615
+ // preloaded separately below.
616
+ let preloadLinks = ""
576
617
  for (const url of preload) {
577
618
  if (url !== clientEntry)
578
619
  preloadLinks += `<link rel="modulepreload" href="${escapeAttr(url)}">`
579
620
  }
621
+ // The matched route's stylesheets - `<link rel="stylesheet">` in `<head>` so CSS arrives with the
622
+ // first paint (no FOUC). Render-blocking by design, and emitted regardless of `hydrate` (a static
623
+ // or `_error` page still wants its styles). In dev (Vite) CSS is injected client-side instead.
624
+ let styleLinks = ""
625
+ for (const url of styles) styleLinks += `<link rel="stylesheet" href="${escapeAttr(url)}">`
626
+ // Island bundles are referenced only by a `<script type="module">` at the END of `<body>`, so the
627
+ // browser doesn't discover them until the whole page is parsed. `modulepreload` them in `<head>`
628
+ // so the fetch starts immediately, in parallel with parsing - regardless of `hydrate`.
629
+ let islandPreloads = ""
630
+ for (const src of islandScripts)
631
+ islandPreloads += `<link rel="modulepreload" href="${escapeAttr(src)}">`
632
+ // Runs in the first-paint→hydration window to swallow a JS-only form's broken native submit. Only
633
+ // on a hydrating page (a static/_error page has no client handlers, so no footgun).
634
+ const hydrationGuard = hydrate ? `<script${nonceAttr}>${PRE_HYDRATION_GUARD}</script>` : ""
635
+ // `<html>` attributes. `lang` defaults to `"en"`; `dir` is omitted entirely when unset, which IS
636
+ // HTML's `ltr` default. Both attribute-escaped. `client.ts`'s `applyHead` mirrors this exact
637
+ // defaulting on soft-nav, so a hard load and a client navigation produce the same `<html>`.
638
+ const htmlAttrs = ` lang="${escapeAttr(head?.lang ?? "en")}"${head?.dir === undefined ? "" : ` dir="${escapeAttr(head.dir)}"`}`
639
+ // Marks the container for the client entry when the id is not the one the entry falls back to.
640
+ const rootMarker = rootId === "root" ? "" : ` ${ROOT_ATTRIBUTE}`
641
+ // A non-hydrated page omits the adapter's hydration bootstrap entirely (Solid's `_$HY` registry
642
+ // script, etc.) - there is no client takeover to feed, so it's dead bytes on a static document.
643
+ // The nonce rewrite only runs when a nonce is set; with none it was a whole-string no-op scan.
644
+ const rawHydrationHead = hydrate ? adapter.hydrationHead(nonce) : ""
645
+ const hydrationHead =
646
+ nonce === undefined
647
+ ? rawHydrationHead
648
+ : rawHydrationHead.replace(/<script(?![^>]*\bnonce=)(?=[\s>])/g, `<script${nonceAttr}`)
649
+ // Shell up to the deferred-runtime seam: on a hydrating page that seam sits after the entry +
650
+ // route-chunk preloads; a non-hydrating page has no runtime insertion at all.
651
+ const entryPreloads = hydrate
652
+ ? `<link rel="modulepreload" href="${escapeAttr(clientEntry ?? "")}">${preloadLinks}`
653
+ : ""
654
+ slot.shellPre = `<!doctype html><html${htmlAttrs}><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${hydrationGuard}<title>${escapeHtml(head?.title ?? title)}</title>${headTags(head)}${styleLinks}${entryPreloads}`
655
+ slot.shellPost = `${islandPreloads}${hydrationHead}</head><body><div id="${escapeAttr(rootId)}"${rootMarker}>`
656
+ // Island bundles load regardless of `hydrate` - a static page (hydrate:false) ships no framework
657
+ // client but can still mount no-framework islands (`@nifrajs/web/islands`).
658
+ let islandTags = ""
659
+ for (const src of islandScripts)
660
+ islandTags += `<script type="module" src="${escapeAttr(src)}"${nonceAttr}></script>`
661
+ if (hydrate) {
662
+ slot.tailPre = `<script${nonceAttr}>${route}`
663
+ slot.tailMid = prerendered
664
+ slot.tailData = `window.${DATA_GLOBAL}=`
665
+ slot.tailPost = `</script><script type="module" src="${escapeAttr(clientEntry ?? "")}"${nonceAttr}></script>${islandTags}</body></html>`
666
+ } else {
667
+ slot.tailPre = ""
668
+ slot.tailMid = ""
669
+ slot.tailData = ""
670
+ slot.tailPost = `${islandTags}</body></html>`
671
+ }
580
672
  }
581
- // The matched route's stylesheets - `<link rel="stylesheet">` in `<head>` so CSS arrives with the
582
- // first paint (no FOUC). Render-blocking by design, and emitted regardless of `hydrate` (a static or
583
- // `_error` page still wants its styles). In dev (Vite) CSS is injected client-side instead, so
584
- // `styles` is empty there.
585
- let styleLinks = ""
586
- for (const url of styles) styleLinks += `<link rel="stylesheet" href="${escapeAttr(url)}">`
587
- // Island bundles are referenced only by a `<script type="module">` at the END of `<body>` (below), so
588
- // the browser doesn't discover them until the whole page is parsed. `modulepreload` them in `<head>`
589
- // so the fetch starts immediately, in parallel with parsing - regardless of `hydrate` (an island page
590
- // is typically hydrate:false). Without this, a heavy island bundle on a cold first load leaves its
591
- // server-rendered placeholder visible until that late, un-prefetched fetch lands ("stuck loading").
592
- let islandPreloads = ""
593
- for (const src of islandScripts)
594
- islandPreloads += `<link rel="modulepreload" href="${escapeAttr(src)}">`
595
- // Shell - flushed before the app finishes rendering: `<head>` (title, meta, hydration head) + the
596
- // open container. `modulepreload` of the client entry - plus the matched route's own chunks
597
- // (`preloadLinks`) - lets the JS download while the body streams. One template literal (no
598
- // intermediate array + `join`) - byte-identical to the prior output.
599
- // A non-hydrated page (e.g. an `_error` boundary) omits the client-entry preload, route-chunk
600
- // preloads, and deferred runtime - there's no client takeover to feed.
601
- const hydrationLinks = hydrate
602
- ? `<link rel="modulepreload" href="${escapeAttr(clientEntry ?? "")}">${preloadLinks}${deferredRuntime}`
603
- : ""
604
- // Runs in the first-paint→hydration window to swallow a JS-only form's broken native submit. Only on a
605
- // hydrating page (a static/_error page has no client handlers, so no footgun).
606
- const hydrationGuard = hydrate ? `<script${nonceAttr}>${PRE_HYDRATION_GUARD}</script>` : ""
607
- // `<html>` attributes. `lang` defaults to `"en"` (so an app that never sets it is byte-identical to
608
- // before); `dir` is omitted entirely when unset, which IS HTML's `ltr` default - emitting it
609
- // unconditionally would change every existing app's output for no gain. Both attribute-escaped.
610
- // `client.ts`'s `applyHead` mirrors this exact defaulting on soft-nav, so a hard load and a client
611
- // navigation to the same URL produce the same `<html>` (no drift on a multilingual site).
612
- const htmlAttrs = ` lang="${escapeAttr(head?.lang ?? "en")}"${head?.dir === undefined ? "" : ` dir="${escapeAttr(head.dir)}"`}`
613
- // Marks the container for the client entry when the id is not the one the entry falls back to. A
614
- // static attribute name with no value, so `rootId` never reaches the markup twice and there is
615
- // nothing here to escape. Omitted on the default so existing output is unchanged.
616
- const rootMarker = rootId === "root" ? "" : ` ${ROOT_ATTRIBUTE}`
617
- const hydrationHead = adapter
618
- .hydrationHead(nonce)
619
- .replace(/<script(?![^>]*\bnonce=)(?=[\s>])/g, `<script${nonceAttr}`)
620
- const shellHtml = `<!doctype html><html${htmlAttrs}><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${hydrationGuard}<title>${escapeHtml(head?.title ?? title)}</title>${headTags(head)}${styleLinks}${hydrationLinks}${islandPreloads}${hydrationHead}</head><body><div id="${escapeAttr(rootId)}"${rootMarker}>`
673
+ // The deferred runtime rides only on a hydrating page (a static page has no client takeover), and
674
+ // only when something actually deferred - matching the pre-cache emission exactly.
675
+ const runtimeSeam = hydrate && allDeferred.length > 0 ? deferredRuntime : ""
676
+ const shellHtml = `${slot.shellPre}${runtimeSeam}${slot.shellPost}`
621
677
  // Closes the hydration container; deferred resolve scripts go AFTER it (outside `#root`) so they
622
678
  // aren't part of the adapter's hydrated tree (an inline script inside it breaks hydration).
623
679
  const closeRootHtml = "</div>"
624
680
  // Tail - the loader-data globals + the client module. Module scripts defer (run after parse), so
625
681
  // the data global + every streamed deferred resolution are set before the entry hydrates.
626
- // Island bundles load regardless of `hydrate` - a static page (hydrate:false) ships no framework
627
- // client but can still mount no-framework islands (`@nifrajs/web/islands`).
628
- let islandTags = ""
629
- for (const src of islandScripts)
630
- islandTags += `<script type="module" src="${escapeAttr(src)}"${nonceAttr}></script>`
631
- const tailHtml = `${
632
- hydrate
633
- ? `<script${nonceAttr}>${route}${action}${prerendered}${layoutTail}window.${DATA_GLOBAL}=${serializeData(forClient)}</script><script type="module" src="${escapeAttr(clientEntry ?? "")}"${nonceAttr}></script>`
634
- : ""
635
- }${islandTags}</body></html>`
682
+ const tailHtml = hydrate
683
+ ? `${slot.tailPre}${action}${slot.tailMid}${layoutTail}${slot.tailData}${serializeData(forClient)}${slot.tailPost}`
684
+ : (slot.tailPost as string)
636
685
  const headers: Record<string, string> = { "content-type": "text/html; charset=utf-8" }
637
686
  // Caller-supplied headers (a terminal status page's `cache-control`, say). Applied before the
638
687
  // framework's own below, so `content-type` and the ISR channel stay authoritative - a caller must
@@ -1987,6 +2036,30 @@ export function createWebApp<Env = unknown>(
1987
2036
  // A dynamic route whose `getStaticPaths` declared `fallback: "404"` - only its prerendered paths
1988
2037
  // exist; anything else 404s (computed once per route, not per request).
1989
2038
  const is404Fallback = options.staticFallbacks?.[route.pattern] === "404"
2039
+ // Per-route document-assembly cache (see RenderAssemblyCache): filled on the first GET render and
2040
+ // reused while the route + layout MODULE IDENTITIES are unchanged. Attached only when every meta
2041
+ // in the chain is static - a `meta(data)` function makes the head per-request, so those routes
2042
+ // always assemble fresh. A module reload (dev HMR) yields new module objects, dropping the slot.
2043
+ let assemblySlot: RenderAssemblyCache | undefined
2044
+ let assemblySlotMod: unknown
2045
+ let assemblySlotLayouts: LoadedLayoutModules | undefined
2046
+ const assemblyCacheFor = (
2047
+ mod: RouteModule,
2048
+ lms: LoadedLayoutModules,
2049
+ ): { assemblyCache: RenderAssemblyCache } | Record<string, never> => {
2050
+ if (typeof mod.meta === "function") return {}
2051
+ for (const m of lms) if (typeof m.meta === "function") return {}
2052
+ const layoutsUnchanged =
2053
+ assemblySlotLayouts !== undefined &&
2054
+ assemblySlotLayouts.length === lms.length &&
2055
+ assemblySlotLayouts.every((m, i) => m === lms[i])
2056
+ if (assemblySlotMod !== mod || !layoutsUnchanged) {
2057
+ assemblySlot = {}
2058
+ assemblySlotMod = mod
2059
+ assemblySlotLayouts = lms
2060
+ }
2061
+ return { assemblyCache: assemblySlot as RenderAssemblyCache }
2062
+ }
1990
2063
  app.register("GET", route.pattern, undefined, async (c: RouteContext) => {
1991
2064
  // Enforce `fallback: "404"` before any work: an unlisted path under this route doesn't exist.
1992
2065
  // Covers hard navigation directly; a client soft-nav's data fetch gets the 404, throws, and the
@@ -2095,15 +2168,12 @@ export function createWebApp<Env = unknown>(
2095
2168
  headers: { "content-type": "application/x-ndjson; charset=utf-8" },
2096
2169
  })
2097
2170
  }
2098
- const { chain, head } = resolveChainAndHead(
2099
- layoutModules ?? (await loadLayoutModules(route)),
2100
- mod,
2101
- {
2102
- data,
2103
- params: c.params,
2104
- origin: originOf(c.req),
2105
- },
2106
- )
2171
+ const chainLayoutModules = layoutModules ?? (await loadLayoutModules(route))
2172
+ const { chain, head } = resolveChainAndHead(chainLayoutModules, mod, {
2173
+ data,
2174
+ params: c.params,
2175
+ origin: originOf(c.req),
2176
+ })
2107
2177
  const hydrateRoute = mod.hydrate !== false
2108
2178
  try {
2109
2179
  // `await` so a shell-render throw (renderToStream rejects before any byte) is caught here and
@@ -2130,6 +2200,7 @@ export function createWebApp<Env = unknown>(
2130
2200
  ...(mod.revalidate !== undefined ? { revalidate: mod.revalidate } : {}),
2131
2201
  ...(mod.islandScripts !== undefined ? { islandScripts: mod.islandScripts } : {}),
2132
2202
  ...titleOption,
2203
+ ...assemblyCacheFor(mod, chainLayoutModules),
2133
2204
  })
2134
2205
  } catch (err) {
2135
2206
  // Same precedence as the loader catch above - a `meta()` may signal too.
@@ -2309,7 +2380,7 @@ export function generateClientEntry(
2309
2380
  const errorRouteIds: string[] = []
2310
2381
  // Lazy loader returns the raw modules (for both the component chain + the page's `meta` export).
2311
2382
  const lazyLoader = (files: readonly string[]): string => {
2312
- const imports = files.map((f) => `import(${JSON.stringify(resolve(f))})`).join(", ")
2383
+ const imports = files.map((f) => `import(${jsStringLiteral(resolve(f))})`).join(", ")
2313
2384
  return `() => Promise.all([${imports}])`
2314
2385
  }
2315
2386
  for (const route of manifest.routes) {
@@ -2577,7 +2648,7 @@ export function generateServerManifest(
2577
2648
  // code-splitting bundler, loaded on first request). The map keys are the route-relative paths
2578
2649
  // `buildManifest` expects; the importer it builds calls the per-file loader.
2579
2650
  const loaders = files.map(
2580
- (file) => ` ${JSON.stringify(file)}: () => import(${JSON.stringify(resolve(file))}),`,
2651
+ (file) => ` ${jsStringLiteral(file)}: () => import(${jsStringLiteral(resolve(file))}),`,
2581
2652
  )
2582
2653
  return `${[
2583
2654
  ...header,
@@ -0,0 +1,19 @@
1
+ /**
2
+ * A JS string literal for embedding a runtime value in GENERATED source. `JSON.stringify` alone is
3
+ * not a code sanitizer: its output can still contain `<` (so a value holding `</script>` breaks out
4
+ * of an inline-script embedding of the generated code) and the U+2028/U+2029 line separators (legal
5
+ * inside JSON strings, line terminators to pre-ES2019 parsers). Escape all three so the emitted
6
+ * literal is inert in every context the generated source can land in.
7
+ */
8
+
9
+ // Built with fromCharCode so this file stays pure ASCII - a literal U+2028/U+2029 in source is
10
+ // invisible in review and one save-with-normalization away from silently vanishing.
11
+ const LINE_SEPARATOR = String.fromCharCode(0x2028)
12
+ const PARAGRAPH_SEPARATOR = String.fromCharCode(0x2029)
13
+
14
+ export function jsStringLiteral(value: string): string {
15
+ return JSON.stringify(value)
16
+ .replaceAll("<", "\\u003C")
17
+ .replaceAll(LINE_SEPARATOR, "\\u2028")
18
+ .replaceAll(PARAGRAPH_SEPARATOR, "\\u2029")
19
+ }
@@ -41,6 +41,8 @@ export const SERVER_FN_MODULE = /\.fn(\.[cm]?[jt]sx?)?$/
41
41
  */
42
42
  export const SERVER_ONLY_REPLACEMENT = "module.exports = new Proxy({}, { get: () => undefined })"
43
43
 
44
+ import { jsStringLiteral } from "./js-string.ts"
45
+
44
46
  const EXPORTED_DECLARATION =
45
47
  /\bexport\s+(?:declare\s+)?(?:async\s+)?(?:const|let|var|function|class|enum)\s+([A-Za-z_$][\w$]*)/g
46
48
  const EXPORTED_LIST = /\bexport\s*\{([^}]*)\}(?:\s*from\s*["'][^"']+["'])?/g
@@ -57,9 +59,15 @@ export function viteServerOnlyReplacement(source: string): string {
57
59
  for (const match of source.matchAll(EXPORTED_DECLARATION)) names.add(match[1] as string)
58
60
  for (const match of source.matchAll(EXPORTED_LIST)) {
59
61
  for (const raw of (match[1] ?? "").split(",")) {
60
- const item = raw.trim().replace(/^type\s+/, "")
62
+ // Collapse runs of whitespace first (linear), so the ` as ` split below needs no `\s+as\s+`
63
+ // regex - adjacent unbounded quantifiers around a literal backtrack quadratically on crafted
64
+ // all-whitespace input.
65
+ const item = raw
66
+ .trim()
67
+ .replace(/\s+/g, " ")
68
+ .replace(/^type /, "")
61
69
  if (item === "") continue
62
- const parts = item.split(/\s+as\s+/)
70
+ const parts = item.split(" as ")
63
71
  const exported = (parts[1] ?? parts[0] ?? "").trim()
64
72
  if (exported === "default") hasDefault = true
65
73
  else if (/^[A-Za-z_$][\w$]*$/.test(exported)) names.add(exported)
@@ -97,8 +105,33 @@ export function viteServerOnlyReplacement(source: string): string {
97
105
  const DECLARATION = /^[\t ]*export\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*serverFn\s*\(/gm
98
106
  /** Any mention of `serverFn(` as a call, used to prove nothing was missed. */
99
107
  const ANY_CALL = /\bserverFn\s*\(/g
100
- /** Line comments and block comments, stripped before scanning so prose cannot register as code. */
101
- const COMMENTS = /\/\*[\s\S]*?\*\/|(^|[^:])\/\/.*$/gm
108
+ /** Strip line and block comments by forward index scan, so prose cannot register as code. Not a
109
+ * regex: every regex shape for comment stripping either backtracks polynomially on adversarial
110
+ * source or is opaque enough that reviewers cannot tell. A `//` preceded by `:` is kept (URL in a
111
+ * string - the same heuristic the previous implementation used); an unterminated block comment
112
+ * swallows the rest of the source, matching a real parser. */
113
+ function stripComments(source: string): string {
114
+ const parts: string[] = []
115
+ let keepFrom = 0
116
+ let i = 0
117
+ while (i < source.length) {
118
+ if (source.startsWith("/*", i)) {
119
+ const close = source.indexOf("*/", i + 2)
120
+ parts.push(source.slice(keepFrom, i))
121
+ i = close === -1 ? source.length : close + 2
122
+ keepFrom = i
123
+ } else if (source.startsWith("//", i) && source[i - 1] !== ":") {
124
+ const newline = source.indexOf("\n", i + 2)
125
+ parts.push(source.slice(keepFrom, i))
126
+ i = newline === -1 ? source.length : newline // keep the newline itself
127
+ keepFrom = i
128
+ } else {
129
+ i++
130
+ }
131
+ }
132
+ parts.push(source.slice(keepFrom))
133
+ return parts.join("")
134
+ }
102
135
 
103
136
  /**
104
137
  * The namespace a `*.fn.ts` module is mounted under: its filename without the `.fn` suffix.
@@ -115,7 +148,7 @@ export function serverFnNamespace(filePath: string): string {
115
148
 
116
149
  /** The server functions a module declares, in source order. */
117
150
  export function scanServerFnExports(source: string): string[] {
118
- const code = source.replaceAll(COMMENTS, "$1")
151
+ const code = stripComments(source)
119
152
  const names = [...code.matchAll(DECLARATION)].map((m) => m[1] as string)
120
153
  const calls = [...code.matchAll(ANY_CALL)].length
121
154
  if (calls > names.length) {
@@ -140,10 +173,10 @@ export function scanServerFnExports(source: string): string[] {
140
173
  export function generateServerFnStub(source: string, namespace: string): string {
141
174
  const names = scanServerFnExports(source)
142
175
  const calls = names
143
- .map((name) => `export const ${name} = (input) => __nifraCall(${JSON.stringify(name)}, input)`)
176
+ .map((name) => `export const ${name} = (input) => __nifraCall(${jsStringLiteral(name)}, input)`)
144
177
  .join("\n")
145
178
  return `// Generated by @nifrajs/web: the server half of this module never reaches the browser.
146
- const __nifraBase = ${JSON.stringify(`/_nifra/fn/${namespace}`)}
179
+ const __nifraBase = ${jsStringLiteral(`/_nifra/fn/${namespace}`)}
147
180
 
148
181
  async function __nifraCall(name, input) {
149
182
  const response = await fetch(\`\${__nifraBase}/\${name}\`, {
package/src/manifest.ts CHANGED
@@ -320,7 +320,10 @@ const errorIdFor = (dir: string): string => (dir === "" ? "_error" : `${dir}/_er
320
320
  * the last segment.
321
321
  */
322
322
  /** Every `[…]` marker in a file segment, so a part-literal segment can be spliced into a pattern. */
323
- const FILE_MARKER = /\[([^\]]*)\]/g
323
+ // `[` is excluded from the inner class as well as `]`: marker names are identifiers, and letting the
324
+ // class swallow `[` makes a run of unclosed brackets backtrack quadratically (each start position
325
+ // re-scans the rest of the segment before failing).
326
+ const FILE_MARKER = /\[([^\][]*)\]/g
324
327
  const FILE_PARAM_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
325
328
 
326
329
  /**
@@ -77,14 +77,46 @@ export interface SvgToJsxOptions {
77
77
  readonly classProp?: string
78
78
  }
79
79
 
80
+ /**
81
+ * Strip the XML declaration, comments, and DOCTYPE from an SVG source by index scan. Shared by every
82
+ * adapter's SVG-component transform instead of chained regex `replace`s, for two reasons: the lazy
83
+ * `[\s\S]*?` scans backtrack quadratically on an unterminated marker, and sequential single-pass
84
+ * replaces can splice removed delimiters back together (`<<!---->!DOCTYPE …>` survives them). The
85
+ * scan removes each block in one forward pass, so neither failure mode exists. An unterminated
86
+ * marker swallows the rest of the input - the correct reading, matching an XML parser.
87
+ */
88
+ export function stripSvgPreamble(xml: string): string {
89
+ const parts: string[] = []
90
+ let keepFrom = 0
91
+ let i = 0
92
+ while (i < xml.length) {
93
+ let skipEnd = -1
94
+ if (xml.startsWith("<?", i)) {
95
+ const close = xml.indexOf("?>", i + 2)
96
+ skipEnd = close === -1 ? xml.length : close + 2
97
+ } else if (xml.startsWith("<!--", i)) {
98
+ const close = xml.indexOf("-->", i + 4)
99
+ skipEnd = close === -1 ? xml.length : close + 3
100
+ } else if (xml.slice(i, i + 9).toLowerCase() === "<!doctype") {
101
+ const close = xml.indexOf(">", i + 9)
102
+ skipEnd = close === -1 ? xml.length : close + 1
103
+ }
104
+ if (skipEnd === -1) {
105
+ i++
106
+ } else {
107
+ parts.push(xml.slice(keepFrom, i))
108
+ i = skipEnd
109
+ keepFrom = skipEnd
110
+ }
111
+ }
112
+ parts.push(xml.slice(keepFrom))
113
+ return parts.join("").trim()
114
+ }
115
+
80
116
  /** Convert an SVG XML string into a JSX-safe `<svg>…</svg>` element with `{...props}` spread on the root. */
81
117
  export function svgToJsx(xml: string, options: SvgToJsxOptions = {}): string {
82
118
  const classProp = options.classProp ?? "className"
83
- let out = xml
84
- .replace(/<\?xml[\s\S]*?\?>/g, "") // XML declaration
85
- .replace(/<!--[\s\S]*?-->/g, "") // comments
86
- .replace(/<!DOCTYPE[^>]*>/gi, "")
87
- .trim()
119
+ let out = stripSvgPreamble(xml)
88
120
 
89
121
  // Namespaced attributes → camelCase (xlink:href → xlinkHref, xmlns:xlink → xmlnsXlink).
90
122
  out = out.replace(/\b([a-z]+):([a-z]+)=/gi, (_m, ns: string, name: string) => {
package/src/prerender.ts CHANGED
@@ -52,9 +52,17 @@ export interface PrerenderResult {
52
52
  readonly fallbacks: Readonly<Record<string, "ssr" | "404">>
53
53
  }
54
54
 
55
+ /** Strip trailing `/` runs by index scan - an unanchored-start `/\/+$/` replace backtracks
56
+ * quadratically on a long slash run, and route patterns are input here. */
57
+ function trimTrailingSlashes(path: string): string {
58
+ let end = path.length
59
+ while (end > 0 && path.charCodeAt(end - 1) === 47 /* '/' */) end--
60
+ return path.slice(0, end)
61
+ }
62
+
55
63
  /** Map a route path to its output file: `/` → `index.html`, `/a/b` → `a/b/index.html`. */
56
64
  export function htmlFileFor(pattern: string): string {
57
- const trimmed = pattern.replace(/^\/+/, "").replace(/\/+$/, "")
65
+ const trimmed = trimTrailingSlashes(pattern.replace(/^\/+/, ""))
58
66
  return trimmed === "" ? "index.html" : `${trimmed}/index.html`
59
67
  }
60
68
 
@@ -203,7 +211,7 @@ export function cloudflarePagesRoutes(
203
211
  const exclude = [...(options.staticGlobs ?? ["/assets/*"])]
204
212
  for (const path of options.prerendered) {
205
213
  exclude.push(path) // the prerendered document
206
- exclude.push(path === "/" ? "/_data.json" : `${path.replace(/\/+$/, "")}/_data.json`) // its data
214
+ exclude.push(path === "/" ? "/_data.json" : `${trimTrailingSlashes(path)}/_data.json`) // its data
207
215
  }
208
216
  return { version: 1, include: ["/*"], exclude }
209
217
  }
package/src/router.ts CHANGED
@@ -322,10 +322,18 @@ const readResponseData = (res: Response, signal?: AbortSignal): Promise<unknown>
322
322
  ? parseNdjsonData(res.body, signal)
323
323
  : res.json()
324
324
 
325
+ // Index scan, not `/\/+$/` - the unanchored-start trailing-run replace backtracks quadratically and
326
+ // the pathname is caller-controlled.
327
+ const trimTrailingSlashes = (path: string): string => {
328
+ let end = path.length
329
+ while (end > 0 && path.charCodeAt(end - 1) === 47 /* '/' */) end--
330
+ return path.slice(0, end)
331
+ }
332
+
325
333
  // The static `_data.json` URL for a prerendered path: `/` → `/_data.json`, `/users/7` →
326
334
  // `/users/7/_data.json` (mirrors the build's `dataFileFor`).
327
335
  const dataUrlFor = (pathname: string): string =>
328
- pathname === "/" ? "/_data.json" : `${pathname.replace(/\/+$/, "")}/_data.json`
336
+ pathname === "/" ? "/_data.json" : `${trimTrailingSlashes(pathname)}/_data.json`
329
337
 
330
338
  const defaultFetchData: FetchRouteData = async (path, _match, signal, navigation) => {
331
339
  // SSG fast path: if this path was prerendered, its loader data is a static file - fetch that (no
@@ -333,7 +341,10 @@ const defaultFetchData: FetchRouteData = async (path, _match, signal, navigation
333
341
  // or a stale set), so it's always safe. Non-SSG apps have no global → the dynamic path, unchanged.
334
342
  const prerendered = (globalThis as { [PRERENDERED_GLOBAL]?: unknown })[PRERENDERED_GLOBAL]
335
343
  if (Array.isArray(prerendered)) {
336
- const pathname = path.replace(/[?#].*$/, "")
344
+ // `search` + `slice` rather than `replace(/[?#].*$/)`: the trailing `.*$` backtracks
345
+ // quadratically on adversarial input, and the URL is caller-controlled.
346
+ const cut = path.search(/[?#]/)
347
+ const pathname = cut === -1 ? path : path.slice(0, cut)
337
348
  if (prerendered.includes(pathname)) {
338
349
  const staticRes = await fetch(dataUrlFor(pathname), { signal: signal ?? null })
339
350
  if (staticRes.ok) return readResponseData(staticRes, signal)
@@ -45,6 +45,8 @@ export interface ServiceWorkerOptions {
45
45
  const SW_URL = /^\/[\w\-./@%]*$/
46
46
 
47
47
  /** A precache URL has to be same-origin, rooted, and free of anything that could break out of a list. */
48
+ import { jsStringLiteral } from "./internal/js-string.ts"
49
+
48
50
  function assertPrecachable(url: string, label: string): void {
49
51
  if (typeof url !== "string" || url === "") {
50
52
  throw new Error(`[nifra/service-worker] ${label} must be a non-empty string`)
@@ -161,5 +163,5 @@ self.addEventListener("fetch", (event) => {
161
163
  */
162
164
  export function serviceWorkerRegistration(scriptUrl = "/sw.js"): string {
163
165
  assertPrecachable(scriptUrl, "scriptUrl")
164
- return `if("serviceWorker"in navigator){addEventListener("load",function(){navigator.serviceWorker.register(${JSON.stringify(scriptUrl)}).catch(function(){})})}`
166
+ return `if("serviceWorker"in navigator){addEventListener("load",function(){navigator.serviceWorker.register(${jsStringLiteral(scriptUrl)}).catch(function(){})})}`
165
167
  }