@nifrajs/web 2.4.0 → 2.6.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 (48) 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 +101 -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/dist/vite.d.ts.map +1 -1
  35. package/dist/vite.js +58 -1
  36. package/dist/vite.js.map +1 -1
  37. package/package.json +3 -2
  38. package/src/build.ts +17 -9
  39. package/src/fonts-google.ts +73 -15
  40. package/src/index.ts +135 -67
  41. package/src/internal/js-string.ts +19 -0
  42. package/src/internal/server-boundary.ts +40 -7
  43. package/src/manifest.ts +4 -1
  44. package/src/plugins/svg.ts +37 -5
  45. package/src/prerender.ts +10 -2
  46. package/src/router.ts +13 -2
  47. package/src/service-worker.ts +3 -1
  48. package/src/vite.ts +59 -1
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
  }
package/src/vite.ts CHANGED
@@ -271,6 +271,22 @@ export function normalizeRolldownPlugins(
271
271
  })
272
272
  }
273
273
 
274
+ /**
275
+ * The npm package a bare module specifier belongs to (`@nifrajs/web-react/client` →
276
+ * `@nifrajs/web-react`, `some-adapter/client` → `some-adapter`), or `undefined` for a path-shaped
277
+ * specifier (relative/absolute), which names no package.
278
+ */
279
+ function packageNameOf(specifier: string): string | undefined {
280
+ if (specifier.startsWith(".") || specifier.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(specifier))
281
+ return undefined
282
+ const parts = specifier.split("/")
283
+ return specifier.startsWith("@")
284
+ ? parts.length >= 2
285
+ ? `${parts[0]}/${parts[1]}`
286
+ : undefined
287
+ : parts[0]
288
+ }
289
+
274
290
  /**
275
291
  * Start the Vite-backed dev server: Vite serves/HMRs the client; nifra SSRs each request and Vite
276
292
  * injects its HMR client + the framework refresh preamble via `transformIndexHtml`.
@@ -349,6 +365,34 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
349
365
 
350
366
  // `conditions: ["bun"]` makes Vite resolve nifra's workspace packages (`@nifrajs/web-react/client`, …) to
351
367
  // their TS **source** - so the dev server needs no prior `dist` build of the adapter packages.
368
+ //
369
+ // That covers the CLIENT half only: Vite resolves SSR through `ssr.resolve.*`, which ignores
370
+ // `resolve.conditions`. Left at its defaults, SSR resolves a package's `default` export (`dist/…`)
371
+ // while the app's own imports of the same package run under Bun and resolve the `bun` export
372
+ // (`src/…`). Two consequences, both real bugs:
373
+ // 1. The adapter package loads TWICE - the app's Bun import (the adapter passed to `createWebApp`)
374
+ // and the route modules' Vite-SSR import - so `createContext` runs twice and the routing hooks
375
+ // (`useSearch`/`useParams`/`useLocation`) read a context the render never provided: they
376
+ // SSR-render their EMPTY defaults in dev while the loader (same request!) sees the real values.
377
+ // 2. SSR reads `dist`, which nothing in the dev loop rebuilds - a stale `dist` then 500s inside
378
+ // framework code and reads exactly like a framework regression.
379
+ // Fix, below: `ssr.resolve.{conditions,externalConditions}` mirror the client conditions (so SSR
380
+ // resolves the same files Bun does), and the adapter package + `@nifrajs/web` are `ssr.external`
381
+ // (so Bun's OWN module cache serves them to route modules - one instance, one `RouterContext`).
382
+ const resolveConditions = [
383
+ ...(options.conditions ?? []),
384
+ "bun",
385
+ "module",
386
+ "browser",
387
+ "development",
388
+ ]
389
+ // SSR must not pick `browser` builds; otherwise identical to the client list so both halves agree.
390
+ const ssrConditions = [...(options.conditions ?? []), "bun", "module", "node", "development"]
391
+ // The adapter package (from `clientModule`, e.g. `@nifrajs/web-react/client` → `@nifrajs/web-react`)
392
+ // - identity-sensitive (its `RouterContext` must be the ONE the Bun-imported adapter provides). A
393
+ // path-shaped `clientModule` (tests) names no package; there is then nothing to externalize.
394
+ const adapterPackage = packageNameOf(options.clientModule)
395
+ const ssrExternal = [...(adapterPackage !== undefined ? [adapterPackage] : []), "@nifrajs/web"]
352
396
  const usePolling = options.poll ?? process.env.CHOKIDAR_USEPOLLING === "1"
353
397
  // Never `import("vite")` directly here: the guard in `importVite` has to run first, and the dev
354
398
  // server importing vite unguarded is precisely what poisoned the module for the whole process.
@@ -376,7 +420,7 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
376
420
  // reads it, and the dev server is a client bundler like any other.
377
421
  plugins: [viteServerFnStub(), viteServerOnlyEmpty(), ...plugins],
378
422
  resolve: {
379
- conditions: [...(options.conditions ?? []), "bun", "module", "browser", "development"],
423
+ conditions: resolveConditions,
380
424
  // Dedupe React to ONE copy. In a multi-root workspace a shared package can pull react/react-dom
381
425
  // from a SIBLING app's node_modules, so the dev server loads two React cores → a second hook
382
426
  // dispatcher → `resolveDispatcher().useState` null on any hook-using route (the error points at the
@@ -384,6 +428,20 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
384
428
  // dev matches prod. No-op for non-React apps (the package simply isn't present to dedupe).
385
429
  dedupe: ["react", "react-dom"],
386
430
  },
431
+ ssr: {
432
+ // Explicit listing forces external even for workspace-LINKED packages (Vite's default keeps
433
+ // linked deps internal), so a monorepo dev run gets the same single-instance guarantee as an
434
+ // npm-installed one.
435
+ external: ssrExternal,
436
+ resolve: {
437
+ // Non-externalized deps (a linked `@platform/*`, say): Vite's runner evaluates them, but from
438
+ // the same files Bun would pick - `bun`-conditioned source, never a stale `dist`.
439
+ conditions: ssrConditions,
440
+ // Externalized deps: Vite resolves the specifier, Bun imports the result. `bun` first so the
441
+ // resolved file IS the one already in Bun's module cache (one evaluation, shared context).
442
+ externalConditions: ssrConditions,
443
+ },
444
+ },
387
445
  ...(options.define ? { define: options.define } : {}),
388
446
  })
389
447