@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.
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +18 -9
- package/dist/build.js.map +1 -1
- package/dist/fonts-google.d.ts.map +1 -1
- package/dist/fonts-google.js +84 -16
- package/dist/fonts-google.js.map +1 -1
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +104 -58
- package/dist/index.js.map +1 -1
- package/dist/internal/js-string.d.ts +9 -0
- package/dist/internal/js-string.d.ts.map +1 -0
- package/dist/internal/js-string.js +18 -0
- package/dist/internal/js-string.js.map +1 -0
- package/dist/internal/server-boundary.d.ts.map +1 -1
- package/dist/internal/server-boundary.js +41 -7
- package/dist/internal/server-boundary.js.map +1 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +4 -1
- package/dist/manifest.js.map +1 -1
- package/dist/plugins/svg.d.ts +9 -0
- package/dist/plugins/svg.d.ts.map +1 -1
- package/dist/plugins/svg.js +39 -5
- package/dist/plugins/svg.js.map +1 -1
- package/dist/prerender.d.ts.map +1 -1
- package/dist/prerender.js +10 -2
- package/dist/prerender.js.map +1 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +13 -2
- package/dist/router.js.map +1 -1
- package/dist/service-worker.d.ts.map +1 -1
- package/dist/service-worker.js +2 -1
- package/dist/service-worker.js.map +1 -1
- package/package.json +2 -2
- package/src/build.ts +17 -9
- package/src/fonts-google.ts +73 -15
- package/src/index.ts +138 -67
- package/src/internal/js-string.ts +19 -0
- package/src/internal/server-boundary.ts +40 -7
- package/src/manifest.ts +4 -1
- package/src/plugins/svg.ts +37 -5
- package/src/prerender.ts +10 -2
- package/src/router.ts +13 -2
- package/src/service-worker.ts +3 -1
package/src/fonts-google.ts
CHANGED
|
@@ -212,30 +212,87 @@ export function googleFontsCssUrl(options: GoogleFontOptions): string {
|
|
|
212
212
|
return `${CSS2_ENDPOINT}?${params.join("&")}`
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
220
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
233
|
-
const src
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
|
|
575
|
-
|
|
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
|
|
582
|
-
//
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
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(${
|
|
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) => ` ${
|
|
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
|
-
|
|
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(
|
|
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
|
-
/**
|
|
101
|
-
|
|
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
|
|
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(${
|
|
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 = ${
|
|
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
|
-
|
|
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
|
/**
|
package/src/plugins/svg.ts
CHANGED
|
@@ -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(/^\/+/, "")
|
|
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
|
|
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
|
|
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
|
-
|
|
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)
|
package/src/service-worker.ts
CHANGED
|
@@ -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(${
|
|
166
|
+
return `if("serviceWorker"in navigator){addEventListener("load",function(){navigator.serviceWorker.register(${jsStringLiteral(scriptUrl)}).catch(function(){})})}`
|
|
165
167
|
}
|