@nifrajs/web 2.0.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-vite.d.ts +68 -0
- package/dist/build-vite.d.ts.map +1 -0
- package/dist/build-vite.js +487 -0
- package/dist/build-vite.js.map +1 -0
- package/dist/build.d.ts +112 -25
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +283 -64
- package/dist/build.js.map +1 -1
- package/dist/bun-dev-entry.d.ts +83 -0
- package/dist/bun-dev-entry.d.ts.map +1 -0
- package/dist/bun-dev-entry.js +136 -0
- package/dist/bun-dev-entry.js.map +1 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +10 -0
- package/dist/client.js.map +1 -1
- package/dist/deferred.d.ts +3 -2
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +11 -8
- package/dist/deferred.js.map +1 -1
- package/dist/dev-port.d.ts +36 -0
- package/dist/dev-port.d.ts.map +1 -0
- package/dist/dev-port.js +48 -0
- package/dist/dev-port.js.map +1 -0
- package/dist/dev.d.ts +61 -4
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +419 -60
- package/dist/dev.js.map +1 -1
- package/dist/draft.d.ts +37 -0
- package/dist/draft.d.ts.map +1 -1
- package/dist/draft.js +87 -4
- package/dist/draft.js.map +1 -1
- package/dist/fs.d.ts +14 -1
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +7 -1
- package/dist/fs.js.map +1 -1
- package/dist/index.d.ts +116 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +565 -53
- package/dist/index.js.map +1 -1
- package/dist/internal/timing-safe-equal.d.ts +16 -0
- package/dist/internal/timing-safe-equal.d.ts.map +1 -0
- package/dist/internal/timing-safe-equal.js +26 -0
- package/dist/internal/timing-safe-equal.js.map +1 -0
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +1 -14
- package/dist/isr.js.map +1 -1
- package/dist/llms-txt.d.ts.map +1 -1
- package/dist/llms-txt.js +12 -6
- package/dist/llms-txt.js.map +1 -1
- package/dist/manifest.d.ts +51 -8
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +130 -14
- package/dist/manifest.js.map +1 -1
- package/dist/module-graph.d.ts +91 -0
- package/dist/module-graph.d.ts.map +1 -0
- package/dist/module-graph.js +92 -0
- package/dist/module-graph.js.map +1 -0
- package/dist/plugins/css-modules.d.ts.map +1 -1
- package/dist/plugins/css-modules.js +0 -0
- package/dist/plugins/css-modules.js.map +1 -1
- package/dist/plugins/vite-leak-guard.d.ts +45 -0
- package/dist/plugins/vite-leak-guard.d.ts.map +1 -0
- package/dist/plugins/vite-leak-guard.js +67 -0
- package/dist/plugins/vite-leak-guard.js.map +1 -0
- package/dist/public-dir.d.ts +36 -0
- package/dist/public-dir.d.ts.map +1 -0
- package/dist/public-dir.js +89 -0
- package/dist/public-dir.js.map +1 -0
- package/dist/route-manifest.d.ts +91 -0
- package/dist/route-manifest.d.ts.map +1 -0
- package/dist/route-manifest.js +128 -0
- package/dist/route-manifest.js.map +1 -0
- package/dist/router.d.ts +64 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +114 -31
- package/dist/router.js.map +1 -1
- package/dist/vite.d.ts +10 -4
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +44 -12
- package/dist/vite.js.map +1 -1
- package/package.json +17 -2
- package/src/build-vite.ts +606 -0
- package/src/build.ts +432 -73
- package/src/bun-dev-entry.ts +184 -0
- package/src/client.ts +8 -0
- package/src/deferred.ts +13 -6
- package/src/dev-port.ts +63 -0
- package/src/dev.ts +467 -65
- package/src/draft.ts +116 -4
- package/src/fs.ts +22 -4
- package/src/index.ts +727 -57
- package/src/internal/timing-safe-equal.ts +24 -0
- package/src/isr.ts +1 -13
- package/src/llms-txt.ts +13 -5
- package/src/manifest.ts +188 -13
- package/src/module-graph.ts +146 -0
- package/src/plugins/css-modules.ts +0 -0
- package/src/plugins/vite-leak-guard.ts +110 -0
- package/src/public-dir.ts +108 -0
- package/src/route-manifest.ts +240 -0
- package/src/router.ts +198 -34
- package/src/vite.ts +62 -20
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constant-time string equality, shared by every secret check in `@nifrajs/web`. Internal: no
|
|
3
|
+
* `exports` entry points here, so it never becomes public API.
|
|
4
|
+
*
|
|
5
|
+
* One owner per package rather than a copy per call site — this is the primitive whose whole value
|
|
6
|
+
* lies in a detail (no early exit) that a well-meaning "simplification" silently removes, and a bug
|
|
7
|
+
* in it is invisible in review and in tests, since a timing-unsafe compare returns the same answers.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Compare two strings without leaking, via timing, where they first differ. A length mismatch returns
|
|
12
|
+
* `false` up front: the length isn't the secret, and different-length buffers can't be compared in
|
|
13
|
+
* constant time anyway. Encodes to bytes first so multi-byte characters compare by content, not by
|
|
14
|
+
* UTF-16 code unit.
|
|
15
|
+
*/
|
|
16
|
+
export function timingSafeEqual(a: string, b: string): boolean {
|
|
17
|
+
const enc = new TextEncoder()
|
|
18
|
+
const ba = enc.encode(a)
|
|
19
|
+
const bb = enc.encode(b)
|
|
20
|
+
if (ba.length !== bb.length) return false
|
|
21
|
+
let diff = 0
|
|
22
|
+
for (let i = 0; i < ba.length; i++) diff |= (ba[i] as number) ^ (bb[i] as number)
|
|
23
|
+
return diff === 0
|
|
24
|
+
}
|
package/src/isr.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* This module is the store primitive; the SWR wrapper (`withISR`) builds on it next.
|
|
8
8
|
*/
|
|
9
9
|
import { isDraftEnabled } from "./draft.ts"
|
|
10
|
+
import { timingSafeEqual } from "./internal/timing-safe-equal.ts"
|
|
10
11
|
|
|
11
12
|
/** A cached SSR response — the bytes + metadata a {@link CacheStore} persists. */
|
|
12
13
|
export interface CachedResponse {
|
|
@@ -406,19 +407,6 @@ export function withISR(
|
|
|
406
407
|
const jsonError = (status: number, error: string): Response =>
|
|
407
408
|
Response.json({ ok: false, error }, { status })
|
|
408
409
|
|
|
409
|
-
/** Constant-time string equality (XOR-accumulate, no early exit). A length mismatch returns `false`
|
|
410
|
-
* up front — the length isn't the secret, and different-length buffers can't be compared in constant
|
|
411
|
-
* time anyway. Used to check the on-demand revalidation token without leaking it via timing. */
|
|
412
|
-
function timingSafeEqual(a: string, b: string): boolean {
|
|
413
|
-
const enc = new TextEncoder()
|
|
414
|
-
const ba = enc.encode(a)
|
|
415
|
-
const bb = enc.encode(b)
|
|
416
|
-
if (ba.length !== bb.length) return false
|
|
417
|
-
let diff = 0
|
|
418
|
-
for (let i = 0; i < ba.length; i++) diff |= (ba[i] as number) ^ (bb[i] as number)
|
|
419
|
-
return diff === 0
|
|
420
|
-
}
|
|
421
|
-
|
|
422
410
|
export interface RevalidateEndpointOptions {
|
|
423
411
|
readonly store: CacheStore
|
|
424
412
|
/** Shared secret; the request's token must match it (constant-time). */
|
package/src/llms-txt.ts
CHANGED
|
@@ -91,11 +91,19 @@ export async function generateLlmsTxt(
|
|
|
91
91
|
// 1. Project Guidelines
|
|
92
92
|
let agentsMd = ""
|
|
93
93
|
try {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
// Edge bundles define this flag and dead-code-eliminate the filesystem branch. Keeping the import
|
|
95
|
+
// behind a runtime-only `typeof Bun` check is insufficient: Rollup must still emit an unresolved
|
|
96
|
+
// `node:` dependency, which workerd cannot load.
|
|
97
|
+
const edgeRuntime = (
|
|
98
|
+
globalThis as typeof globalThis & { readonly __NIFRA_EDGE_RUNTIME__?: boolean }
|
|
99
|
+
).__NIFRA_EDGE_RUNTIME__
|
|
100
|
+
if (edgeRuntime !== true) {
|
|
101
|
+
if (typeof Bun !== "undefined") {
|
|
102
|
+
agentsMd = await Bun.file("AGENTS.md").text()
|
|
103
|
+
} else {
|
|
104
|
+
const fs = await import("node:fs/promises")
|
|
105
|
+
agentsMd = await fs.readFile("AGENTS.md", "utf-8")
|
|
106
|
+
}
|
|
99
107
|
}
|
|
100
108
|
} catch {
|
|
101
109
|
// ignore
|
package/src/manifest.ts
CHANGED
|
@@ -89,6 +89,23 @@ export interface Meta {
|
|
|
89
89
|
readonly link?: readonly LinkDescriptor[]
|
|
90
90
|
/** Inert head `<script>`s (JSON-LD structured data, etc.). See {@link ScriptDescriptor}. */
|
|
91
91
|
readonly script?: readonly ScriptDescriptor[]
|
|
92
|
+
/**
|
|
93
|
+
* The document language — `<html lang="...">`. Nearest-wins like `title`, so a localized route can
|
|
94
|
+
* override a layout's default. Defaults to `"en"` when no head in the chain sets it.
|
|
95
|
+
*
|
|
96
|
+
* This is the ONLY way to set it: the shell's `<html>` is framework-owned, so a multilingual app
|
|
97
|
+
* otherwise serves every URL as `lang="en"` — which tells a screen reader to pronounce every locale
|
|
98
|
+
* with an English voice, and suppresses the browser's translation offer.
|
|
99
|
+
*/
|
|
100
|
+
readonly lang?: string
|
|
101
|
+
/**
|
|
102
|
+
* The document writing direction — `<html dir="...">`. Nearest-wins like `title`. Omitted from the
|
|
103
|
+
* shell when unset, which is HTML's `ltr` default, so an LTR app's output is byte-identical.
|
|
104
|
+
*
|
|
105
|
+
* Required for Arabic/Hebrew/Urdu/Persian: without it the browser lays the page out left-to-right
|
|
106
|
+
* and mis-orders trailing punctuation, so the text renders wrong rather than merely unstyled.
|
|
107
|
+
*/
|
|
108
|
+
readonly dir?: "ltr" | "rtl" | "auto"
|
|
92
109
|
}
|
|
93
110
|
|
|
94
111
|
/**
|
|
@@ -195,6 +212,16 @@ export interface RouteEntry {
|
|
|
195
212
|
readonly id: string
|
|
196
213
|
readonly pattern: string
|
|
197
214
|
readonly layoutIds: readonly string[]
|
|
215
|
+
/**
|
|
216
|
+
* Param names each layout in {@link layoutIds} owns, aligned by index.
|
|
217
|
+
*
|
|
218
|
+
* A layout wraps a URL prefix, so these are the params it may read and the ones whose change should
|
|
219
|
+
* re-run its loader - everything deeper belongs to routes beneath it. Derived per (route, layout)
|
|
220
|
+
* pair rather than per layout, because one layout can own different params on different expanded
|
|
221
|
+
* patterns: `[[lang]]/docs/_layout` owns nothing on `/docs/:slug` and `{lang}` on `/:lang/docs/:slug`.
|
|
222
|
+
* Optional only so hand-built test manifests may omit it.
|
|
223
|
+
*/
|
|
224
|
+
readonly layoutParams?: ReadonlyArray<readonly string[]>
|
|
198
225
|
/** `_error` boundary ids in this route's ancestor chain (outermost → innermost). The last is the
|
|
199
226
|
* **nearest** boundary, rendered when the route's loader throws. Always set by `buildManifest`
|
|
200
227
|
* (optional only so hand-built test manifests may omit it); absent/empty ⇒ no boundary (error 500s). */
|
|
@@ -211,6 +238,11 @@ export interface Manifest {
|
|
|
211
238
|
* `buildManifest` (optional only so hand-built test manifests may omit it). */
|
|
212
239
|
readonly errors?: Readonly<Record<string, LayoutEntry>>
|
|
213
240
|
readonly notFound?: LayoutEntry
|
|
241
|
+
/** Per-status terminal pages from `_<status>.tsx` at the routes root (`_410`, `_451`, …), keyed by
|
|
242
|
+
* the status as a string. Rendered by a loader's `gone()` / `statusPage(n)`; a status with no page
|
|
243
|
+
* falls back to `_404`, and then to plain text. `_404` itself stays on {@link notFound} - it is
|
|
244
|
+
* reached by unmatched paths too, which these are not. */
|
|
245
|
+
readonly statusPages?: Readonly<Record<string, LayoutEntry>>
|
|
214
246
|
}
|
|
215
247
|
|
|
216
248
|
// `.svelte` and `.vue` routes are supported too: their `default` export is the component and
|
|
@@ -226,6 +258,10 @@ const OPTIONAL = /^\[\[([A-Za-z_][A-Za-z0-9_]*)\]\]$/
|
|
|
226
258
|
// A route group: a `(name)` folder organizes routes (and can hold its own `_layout`) without
|
|
227
259
|
// contributing a URL segment — mirrors Next/Remix. Requires content between the parens.
|
|
228
260
|
const GROUP = /^\(.+\)$/
|
|
261
|
+
// A terminal status page: `_410.tsx`, `_451.tsx`. `_404` is matched before this and stays its own
|
|
262
|
+
// thing. Restricted to 3 digits so `_401k` or `_4` is treated as an ordinary underscore-prefixed
|
|
263
|
+
// file (ignored) rather than silently becoming a status page.
|
|
264
|
+
const STATUS_PAGE = /^_[1-5][0-9][0-9]$/
|
|
229
265
|
|
|
230
266
|
const stripExt = (file: string): string => file.replace(ROUTE_EXT, "")
|
|
231
267
|
const baseName = (file: string): string => file.slice(file.lastIndexOf("/") + 1)
|
|
@@ -242,14 +278,112 @@ const errorIdFor = (dir: string): string => (dir === "" ? "_error" : `${dir}/_er
|
|
|
242
278
|
* with no optionals yields exactly one pattern. Throws on an invalid param or a catch-all that isn't
|
|
243
279
|
* the last segment.
|
|
244
280
|
*/
|
|
281
|
+
/** Every `[…]` marker in a file segment, so a part-literal segment can be spliced into a pattern. */
|
|
282
|
+
const FILE_MARKER = /\[([^\]]*)\]/g
|
|
283
|
+
const FILE_PARAM_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Translate a part-literal-part-`[param]` file segment into its route pattern, or `undefined` when
|
|
287
|
+
* the segment is wholly literal or wholly a marker (both already handled by the caller).
|
|
288
|
+
*
|
|
289
|
+
* `[inKey].txt` becomes `:inKey.txt`. Needed because IndexNow-style verification files, per-locale
|
|
290
|
+
* sitemaps, and static-site URL parity (`post-[id].html`) all put a variable INSIDE a filename, and
|
|
291
|
+
* `stripExt` already reduces `[inKey].txt.tsx` to `[inKey].txt` - `robots.txt.tsx` proves literal
|
|
292
|
+
* dots survive into a segment, so this was only ever a parser gap.
|
|
293
|
+
*
|
|
294
|
+
* `[[optional]]` and `[...catchAll]` are **rejected** inside a mixed segment rather than given a
|
|
295
|
+
* meaning. There is no sensible absent form for `/[[locale]]-feed.xml` - `/-feed.xml` and dropping
|
|
296
|
+
* the segment are both surprising - and a catch-all captures the remaining path verbatim, which
|
|
297
|
+
* conflicts with a trailing literal. A clear rejection beats semantics nobody can predict.
|
|
298
|
+
*/
|
|
299
|
+
function mixedFileSegment(seg: string, file: string): string | undefined {
|
|
300
|
+
// A segment that is ENTIRELY one of the existing whole-segment forms belongs to the caller, not
|
|
301
|
+
// here. Checked against the real patterns rather than by comparing the first marker to the
|
|
302
|
+
// segment: `[[lang]]` does not equal its own first `[…]` match, so a naive comparison sends every
|
|
303
|
+
// optional segment down the mixed path and rejects it.
|
|
304
|
+
if (PARAM.test(seg) || OPTIONAL.test(seg) || CATCH_ALL.test(seg)) return undefined
|
|
305
|
+
const markers = [...seg.matchAll(FILE_MARKER)]
|
|
306
|
+
if (markers.length === 0) return undefined
|
|
307
|
+
|
|
308
|
+
let pattern = ""
|
|
309
|
+
let cursor = 0
|
|
310
|
+
for (const marker of markers) {
|
|
311
|
+
const inner = marker[1] ?? ""
|
|
312
|
+
if (inner.startsWith("...")) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`[nifra/web] catch-all "[${inner}]" cannot be combined with literal text in "${file}" (segment "${seg}") — a catch-all captures the rest of the path, so a trailing literal can never match. Give it its own segment.`,
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
if (inner.startsWith("[")) {
|
|
318
|
+
throw new Error(
|
|
319
|
+
`[nifra/web] optional "[${inner}]]" cannot be combined with literal text in "${file}" (segment "${seg}") — there is no sensible form for the segment when it is absent. Use a required [name], or give the optional its own segment.`,
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
if (!FILE_PARAM_NAME.test(inner)) {
|
|
323
|
+
throw new Error(
|
|
324
|
+
`[nifra/web] invalid route param in "${file}": "[${inner}]" must be [name], [[name]], [...name], or a (group) folder`,
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
pattern += `${seg.slice(cursor, marker.index)}:${inner}`
|
|
328
|
+
cursor = (marker.index ?? 0) + marker[0].length
|
|
329
|
+
}
|
|
330
|
+
return pattern + seg.slice(cursor)
|
|
331
|
+
}
|
|
332
|
+
|
|
245
333
|
export function filePathToPatterns(file: string): string[] {
|
|
246
|
-
|
|
247
|
-
|
|
334
|
+
return filePathToRoutes(file).map((route) => route.pattern)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** One URL pattern a route file expands to, plus the scope information layouts need. */
|
|
338
|
+
export interface FileRoutePattern {
|
|
339
|
+
readonly pattern: string
|
|
340
|
+
/**
|
|
341
|
+
* URL segments contributed by the first *k* path parts of the file, indexed by *k*.
|
|
342
|
+
*
|
|
343
|
+
* This is what makes a layout's scope derivable without turning layouts into router nodes. A layout
|
|
344
|
+
* lives at a directory; the segments that directory contributes are exactly the prefix of the URL it
|
|
345
|
+
* wraps, so `depths[partsIn(layoutDir)]` is how many leading pattern segments it owns - and the
|
|
346
|
+
* params inside that prefix are the ones it may read.
|
|
347
|
+
*
|
|
348
|
+
* Per EXPANDED pattern, not per file, because the two diverge: `[[lang]]/docs/_layout` owns nothing
|
|
349
|
+
* on `/docs/:slug` and `{lang}` on `/:lang/docs/:slug`. A route group contributes no segment, so
|
|
350
|
+
* `(marketing)/_layout` owns nothing at all.
|
|
351
|
+
*/
|
|
352
|
+
readonly depths: readonly number[]
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Expand a route file into its URL patterns, each carrying per-directory segment counts. */
|
|
356
|
+
export function filePathToRoutes(file: string): FileRoutePattern[] {
|
|
357
|
+
// Each combo is the URL segments built so far, plus the running per-path-part segment count.
|
|
358
|
+
let combos: Array<{ segs: string[]; depths: number[] }> = [{ segs: [], depths: [0] }]
|
|
248
359
|
const segments = stripExt(file).split("/")
|
|
360
|
+
// Record the running segment count after each path part, on every combo. Called once per part so
|
|
361
|
+
// `depths` stays index-aligned with the file's path parts even for parts that emit nothing.
|
|
362
|
+
const mark = (): void => {
|
|
363
|
+
for (const combo of combos) combo.depths.push(combo.segs.length)
|
|
364
|
+
}
|
|
365
|
+
const push = (segment: string): void => {
|
|
366
|
+
for (const combo of combos) combo.segs.push(segment)
|
|
367
|
+
}
|
|
249
368
|
for (let i = 0; i < segments.length; i++) {
|
|
250
369
|
const seg = segments[i]!
|
|
251
|
-
if (seg === "index")
|
|
252
|
-
|
|
370
|
+
if (seg === "index") {
|
|
371
|
+
mark()
|
|
372
|
+
continue
|
|
373
|
+
}
|
|
374
|
+
if (GROUP.test(seg)) {
|
|
375
|
+
// Route group — no URL segment (its `_layout` still applies, keyed by dir).
|
|
376
|
+
mark()
|
|
377
|
+
continue
|
|
378
|
+
}
|
|
379
|
+
// A segment that is part literal, part `[param]` — `[inKey].txt`, `post-[id].html`. Checked
|
|
380
|
+
// before the whole-segment forms below, which only handle a marker spanning the entire segment.
|
|
381
|
+
const mixed = mixedFileSegment(seg, file)
|
|
382
|
+
if (mixed !== undefined) {
|
|
383
|
+
push(mixed)
|
|
384
|
+
mark()
|
|
385
|
+
continue
|
|
386
|
+
}
|
|
253
387
|
if (seg.startsWith("[")) {
|
|
254
388
|
const catchAll = CATCH_ALL.exec(seg)
|
|
255
389
|
if (catchAll !== null) {
|
|
@@ -261,13 +395,18 @@ export function filePathToPatterns(file: string): string[] {
|
|
|
261
395
|
`[nifra/web] catch-all "${seg}" must be the last segment in "${file}" (found "${after.join("/")}" after it)`,
|
|
262
396
|
)
|
|
263
397
|
}
|
|
264
|
-
|
|
398
|
+
push(`*${catchAll[1]}`)
|
|
399
|
+
mark()
|
|
265
400
|
continue
|
|
266
401
|
}
|
|
267
402
|
const optional = OPTIONAL.exec(seg)
|
|
268
403
|
if (optional !== null) {
|
|
269
404
|
// Optional segment: keep each existing combo (absent) AND a copy with `:name` appended (present).
|
|
270
|
-
combos = combos.flatMap((c) => [
|
|
405
|
+
combos = combos.flatMap((c) => [
|
|
406
|
+
{ segs: c.segs, depths: c.depths },
|
|
407
|
+
{ segs: [...c.segs, `:${optional[1]}`], depths: [...c.depths] },
|
|
408
|
+
])
|
|
409
|
+
mark()
|
|
271
410
|
continue
|
|
272
411
|
}
|
|
273
412
|
const match = PARAM.exec(seg)
|
|
@@ -276,12 +415,13 @@ export function filePathToPatterns(file: string): string[] {
|
|
|
276
415
|
`[nifra/web] invalid route param in "${file}": "${seg}" must be [name], [[name]], [...name], or a (group) folder`,
|
|
277
416
|
)
|
|
278
417
|
}
|
|
279
|
-
|
|
418
|
+
push(`:${match[1]}`)
|
|
280
419
|
} else {
|
|
281
|
-
|
|
420
|
+
push(seg)
|
|
282
421
|
}
|
|
422
|
+
mark()
|
|
283
423
|
}
|
|
284
|
-
return combos.map((c) => `/${c.join("/")}
|
|
424
|
+
return combos.map((c) => ({ pattern: `/${c.segs.join("/")}`, depths: c.depths }))
|
|
285
425
|
}
|
|
286
426
|
|
|
287
427
|
/**
|
|
@@ -295,6 +435,26 @@ export function filePathToPattern(file: string): string {
|
|
|
295
435
|
}
|
|
296
436
|
|
|
297
437
|
/** The dir chain from root → the file's own dir, e.g. "a/b/p.tsx" → ["", "a", "a/b"]. */
|
|
438
|
+
/**
|
|
439
|
+
* Param names in the first `count` segments of a pattern - the params a layout at that depth owns.
|
|
440
|
+
*
|
|
441
|
+
* This is the whole scoping mechanism: a layout wraps a URL prefix, so the params inside that prefix
|
|
442
|
+
* are the ones it may read, and the ones whose change should re-run its loader. Everything deeper
|
|
443
|
+
* belongs to routes below it and must not invalidate it.
|
|
444
|
+
*/
|
|
445
|
+
function paramsInPrefix(pattern: string, count: number): string[] {
|
|
446
|
+
if (count === 0) return []
|
|
447
|
+
const names: string[] = []
|
|
448
|
+
const segments = pattern === "/" ? [] : pattern.slice(1).split("/")
|
|
449
|
+
for (const segment of segments.slice(0, count)) {
|
|
450
|
+
// Mirrors the router's own grammar: `:name`, `*name`, and a mixed segment's embedded `:name`s.
|
|
451
|
+
for (const match of segment.matchAll(/[:*]([A-Za-z_][A-Za-z0-9_]*)/g)) {
|
|
452
|
+
names.push(match[1] as string)
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return names
|
|
456
|
+
}
|
|
457
|
+
|
|
298
458
|
const ancestorDirs = (file: string): string[] => {
|
|
299
459
|
const dirs = [""]
|
|
300
460
|
const dir = dirOf(file)
|
|
@@ -322,6 +482,7 @@ export function buildManifest(
|
|
|
322
482
|
const errorDirs = new Set<string>()
|
|
323
483
|
const errors: Record<string, LayoutEntry> = {}
|
|
324
484
|
let notFound: LayoutEntry | undefined
|
|
485
|
+
const statusPages: Record<string, LayoutEntry> = {}
|
|
325
486
|
const routeFiles: string[] = []
|
|
326
487
|
|
|
327
488
|
for (const file of files) {
|
|
@@ -336,6 +497,11 @@ export function buildManifest(
|
|
|
336
497
|
errors[errorIdFor(dir)] = { file, load: importer(file) }
|
|
337
498
|
} else if (stem === "_404") {
|
|
338
499
|
notFound = { file, load: importer(file) }
|
|
500
|
+
} else if (STATUS_PAGE.test(stem) && dirOf(file) === "") {
|
|
501
|
+
// `_410.tsx`, `_451.tsx`, … at the routes root. Root-only: unlike `_error`, these are not
|
|
502
|
+
// resolved per segment - a terminal status is a property of the outcome, not of where in the
|
|
503
|
+
// tree the route lives, and a per-segment variant would need a precedence rule nobody asked for.
|
|
504
|
+
statusPages[stem.slice(1)] = { file, load: importer(file) }
|
|
339
505
|
} else if (!stem.startsWith("_")) {
|
|
340
506
|
routeFiles.push(file)
|
|
341
507
|
}
|
|
@@ -345,13 +511,14 @@ export function buildManifest(
|
|
|
345
511
|
const routes: RouteEntry[] = []
|
|
346
512
|
for (const file of routeFiles) {
|
|
347
513
|
const dirs = ancestorDirs(file)
|
|
348
|
-
const
|
|
514
|
+
const layoutDirsForFile = dirs.filter((dir) => layoutDirs.has(dir))
|
|
515
|
+
const layoutIds = layoutDirsForFile.map(layoutIdFor)
|
|
349
516
|
const errorIds = dirs.filter((dir) => errorDirs.has(dir)).map(errorIdFor)
|
|
350
517
|
const id = stripExt(file)
|
|
351
518
|
const load = importer(file) // one lazy loader per file, shared by its (possibly expanded) patterns
|
|
352
519
|
// An optional `[[x]]` segment expands a file into multiple patterns, all pointing at the same
|
|
353
520
|
// module (same id/load/layout chain). Distinct patterns ⇒ no match ambiguity (different lengths).
|
|
354
|
-
for (const pattern of
|
|
521
|
+
for (const { pattern, depths } of filePathToRoutes(file)) {
|
|
355
522
|
const existing = byPattern.get(pattern)
|
|
356
523
|
if (existing !== undefined) {
|
|
357
524
|
throw new Error(
|
|
@@ -359,11 +526,19 @@ export function buildManifest(
|
|
|
359
526
|
)
|
|
360
527
|
}
|
|
361
528
|
byPattern.set(pattern, file)
|
|
362
|
-
|
|
529
|
+
const layoutParams = layoutDirsForFile.map((dir) =>
|
|
530
|
+
paramsInPrefix(pattern, depths[dir === "" ? 0 : dir.split("/").length] ?? 0),
|
|
531
|
+
)
|
|
532
|
+
routes.push({ id, pattern, layoutIds, layoutParams, errorIds, file, load })
|
|
363
533
|
}
|
|
364
534
|
}
|
|
365
535
|
|
|
366
|
-
const base: Manifest = {
|
|
536
|
+
const base: Manifest = {
|
|
537
|
+
routes,
|
|
538
|
+
layouts,
|
|
539
|
+
errors,
|
|
540
|
+
...(Object.keys(statusPages).length > 0 ? { statusPages } : {}),
|
|
541
|
+
}
|
|
367
542
|
return notFound === undefined ? base : { ...base, notFound }
|
|
368
543
|
}
|
|
369
544
|
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A bundler-neutral view of a client build, for the guards that must run on every pipeline.
|
|
3
|
+
*
|
|
4
|
+
* nifra's two client-leak guards - server-only code reaching the browser, and `node:` builtins in
|
|
5
|
+
* client code - are the reason a pipeline choice is safe. They are security guards, not lints: one
|
|
6
|
+
* stops secrets and database access shipping to a browser. Today both read Bun's metafile directly,
|
|
7
|
+
* so a second production pipeline would either arrive without them or need them hastily ported, and
|
|
8
|
+
* "mostly ported" is the wrong outcome for a guard of that kind.
|
|
9
|
+
*
|
|
10
|
+
* This is the seam that prevents that. It is introduced while Bun is still the ONLY producer, so the
|
|
11
|
+
* adapter can be verified against the existing behaviour rather than written under pressure beside a
|
|
12
|
+
* new bundler. Adding Rollup later becomes one more `from…` function rather than a second copy of the
|
|
13
|
+
* detection logic.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately minimal - exactly what the guards read, nothing more:
|
|
16
|
+
* - which modules exist and what each imports (to walk the import chain to a sink)
|
|
17
|
+
* - which output chunk each module landed in (to name the chunk a leak reached)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** One import edge, as the bundler recorded it. */
|
|
21
|
+
export interface GraphImport {
|
|
22
|
+
/** The resolved path, when the bundler resolved it. */
|
|
23
|
+
readonly path?: string
|
|
24
|
+
/** The specifier as written in source - the only form that survives an unresolved import. */
|
|
25
|
+
readonly original?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface GraphModule {
|
|
29
|
+
readonly imports: readonly GraphImport[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface GraphChunk {
|
|
33
|
+
/** Source module this chunk is the entry for, if it is one. */
|
|
34
|
+
readonly entryPoint?: string
|
|
35
|
+
/** Module ids that landed in this chunk. */
|
|
36
|
+
readonly modules: readonly string[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** What a client build looks like to the guards, whichever bundler produced it. */
|
|
40
|
+
export interface ClientModuleGraph {
|
|
41
|
+
/** Module id → its imports. Ids are bundler-native (Bun graph keys, Rollup module ids). */
|
|
42
|
+
readonly modules: Readonly<Record<string, GraphModule>>
|
|
43
|
+
/** Output path → what it contains. */
|
|
44
|
+
readonly chunks: Readonly<Record<string, GraphChunk>>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The slice of Bun's metafile this seam consumes. Not yet in `@types/bun`; shape per the docs. */
|
|
48
|
+
export interface BunMetafileLike {
|
|
49
|
+
readonly inputs?: Readonly<
|
|
50
|
+
Record<string, { readonly imports?: ReadonlyArray<{ path?: string; original?: string }> }>
|
|
51
|
+
>
|
|
52
|
+
readonly outputs?: Readonly<
|
|
53
|
+
Record<
|
|
54
|
+
string,
|
|
55
|
+
{ readonly entryPoint?: string; readonly inputs?: Readonly<Record<string, unknown>> }
|
|
56
|
+
>
|
|
57
|
+
>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Adapt a `Bun.build` metafile to the neutral graph.
|
|
62
|
+
*
|
|
63
|
+
* A total function: an absent or partial metafile yields an empty graph rather than throwing, because
|
|
64
|
+
* a guard that crashes on an unexpected build shape fails the build for the wrong reason. An empty
|
|
65
|
+
* graph reports no findings, which matches the existing behaviour when the metafile is missing.
|
|
66
|
+
*/
|
|
67
|
+
export function fromBunMetafile(meta: BunMetafileLike | undefined): ClientModuleGraph {
|
|
68
|
+
const modules: Record<string, GraphModule> = {}
|
|
69
|
+
for (const [id, input] of Object.entries(meta?.inputs ?? {})) {
|
|
70
|
+
modules[id] = { imports: input.imports ?? [] }
|
|
71
|
+
}
|
|
72
|
+
const chunks: Record<string, GraphChunk> = {}
|
|
73
|
+
for (const [path, output] of Object.entries(meta?.outputs ?? {})) {
|
|
74
|
+
chunks[path] = {
|
|
75
|
+
...(output.entryPoint !== undefined ? { entryPoint: output.entryPoint } : {}),
|
|
76
|
+
modules: Object.keys(output.inputs ?? {}),
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { modules, chunks }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The slice of a Rollup/Vite output bundle this seam consumes. `OutputBundle` is
|
|
84
|
+
* `Record<fileName, OutputChunk | OutputAsset>`; only chunks carry a module graph. Typed structurally
|
|
85
|
+
* (no `rollup`/`vite` type dependency) — the fields are stable Rollup output API.
|
|
86
|
+
*/
|
|
87
|
+
export interface RollupChunkLike {
|
|
88
|
+
/** `"chunk"` for JS output, `"asset"` for CSS/static — assets have no module graph. */
|
|
89
|
+
readonly type?: string
|
|
90
|
+
/** The entry module id this chunk was built for, if it is an entry. Rollup's `facadeModuleId`. */
|
|
91
|
+
readonly facadeModuleId?: string | null
|
|
92
|
+
/** Every module id that landed in this chunk. Rollup's `moduleIds`. */
|
|
93
|
+
readonly moduleIds?: readonly string[]
|
|
94
|
+
}
|
|
95
|
+
export type RollupBundleLike = Readonly<Record<string, RollupChunkLike>>
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Adapt a Rollup/Vite output bundle to the neutral graph — the second producer the seam was built for.
|
|
99
|
+
*
|
|
100
|
+
* The bundle records which modules landed in which chunk (`moduleIds`) but NOT each module's import
|
|
101
|
+
* edges, so those come from `importsOf`, which the caller backs with `this.getModuleInfo(id).importedIds`
|
|
102
|
+
* inside a plugin hook (a test backs it with a plain map). Edges carry only the RESOLVED id — Rollup does
|
|
103
|
+
* not keep the as-written specifier per edge — so `path` is set and `original` is left undefined. Both
|
|
104
|
+
* guards read the resolved `path` as their fallback (a `node:` prefix, the `server-only` basename), so
|
|
105
|
+
* detection is unaffected; only the human-readable chain shows resolved paths instead of as-written ones.
|
|
106
|
+
*
|
|
107
|
+
* Total, like {@link fromBunMetafile}: an empty bundle yields an empty graph (no findings), never a throw
|
|
108
|
+
* that would fail a build for the wrong reason.
|
|
109
|
+
*/
|
|
110
|
+
export function fromRollupBundle(
|
|
111
|
+
bundle: RollupBundleLike,
|
|
112
|
+
importsOf: (moduleId: string) => readonly string[],
|
|
113
|
+
): ClientModuleGraph {
|
|
114
|
+
const modules: Record<string, GraphModule> = {}
|
|
115
|
+
const chunks: Record<string, GraphChunk> = {}
|
|
116
|
+
for (const [fileName, output] of Object.entries(bundle)) {
|
|
117
|
+
// Assets (CSS, copied files) have no module graph; `type` is absent only on hand-built test inputs,
|
|
118
|
+
// which are always chunks, so treat "not explicitly an asset" as a chunk.
|
|
119
|
+
if (output.type === "asset") continue
|
|
120
|
+
const moduleIds = output.moduleIds ?? []
|
|
121
|
+
// Start from the real modules; `node:` builtins get appended below.
|
|
122
|
+
const chunkModules: string[] = [...moduleIds]
|
|
123
|
+
for (const id of moduleIds) {
|
|
124
|
+
const imports = importsOf(id).map((path) => ({ path }))
|
|
125
|
+
if (modules[id] === undefined) modules[id] = { imports }
|
|
126
|
+
// The guards locate a leak by finding the builtin IN a chunk's module list - Bun BUNDLES the `node:`
|
|
127
|
+
// polyfill, so the builtin appears there as an input. Rollup EXTERNALIZES `node:`, so it never lands
|
|
128
|
+
// in `moduleIds` and the guard would see nothing. Synthesize the Bun shape: register each imported
|
|
129
|
+
// builtin as a member of THIS chunk (where the leak is) and as a leaf module, so the neutral graph
|
|
130
|
+
// is identical whichever bundler produced it. Only `node:` needs this; a server-only-marked module
|
|
131
|
+
// is a real user module and is already a chunk member.
|
|
132
|
+
for (const im of imports) {
|
|
133
|
+
if (!im.path.startsWith("node:")) continue
|
|
134
|
+
if (!chunkModules.includes(im.path)) chunkModules.push(im.path)
|
|
135
|
+
if (modules[im.path] === undefined) modules[im.path] = { imports: [] }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
chunks[fileName] = {
|
|
139
|
+
// `facadeModuleId` is null for a shared/common chunk (no single entry) — omit it then, matching
|
|
140
|
+
// how a non-entry Bun output omits `entryPoint`, so the guards' entry set stays the real entries.
|
|
141
|
+
...(output.facadeModuleId ? { entryPoint: output.facadeModuleId } : {}),
|
|
142
|
+
modules: chunkModules,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { modules, chunks }
|
|
146
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/web/plugins/vite-leak-guard` — nifra's two client-leak guards, for a Vite/Rollup production
|
|
3
|
+
* build.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this exists
|
|
6
|
+
*
|
|
7
|
+
* nifra's production default is Bun, and that is not changing: a Vite/Rollup production build inherits
|
|
8
|
+
* Rollup's build profile and gives up the Bun-native advantage nifra competes on. But some apps depend on
|
|
9
|
+
* a Vite-only transform that has no Bun equivalent, and for those a Vite production build is the escape
|
|
10
|
+
* hatch. The moment that hatch exists, the two client-leak guards have to come with it - they are security
|
|
11
|
+
* guards, not lints: one stops secrets and database access from shipping to a browser. A second production
|
|
12
|
+
* pipeline arriving WITHOUT them, or with a hastily re-implemented "mostly ported" copy, is the failure the
|
|
13
|
+
* neutral module-graph seam was built to prevent.
|
|
14
|
+
*
|
|
15
|
+
* So this is not a second implementation. The detection logic (`detectNodeBuiltinsInClient`,
|
|
16
|
+
* `detectServerOnlyInClient`) and the failure MESSAGES (`formatNodeBuiltinLeak`, `formatServerOnlyLeak`)
|
|
17
|
+
* are the exact same functions the Bun build calls. This plugin only adapts Rollup's bundle shape into the
|
|
18
|
+
* neutral `ClientModuleGraph` (`fromRollupBundle`) and runs them. A leak reads identically whichever
|
|
19
|
+
* bundler produced it, and there is one place to change if a guard changes.
|
|
20
|
+
*
|
|
21
|
+
* ## How
|
|
22
|
+
*
|
|
23
|
+
* In `generateBundle` the whole client graph is available: each output chunk lists its `moduleIds`, and
|
|
24
|
+
* `this.getModuleInfo(id)` gives each module's resolved import edges. That is exactly what
|
|
25
|
+
* `fromRollupBundle` needs. Findings fail the build through Rollup's `this.error`, so the message lands in
|
|
26
|
+
* the build output the same way a Bun-build throw does.
|
|
27
|
+
*
|
|
28
|
+
* Add it to the `plugins` of a Vite production build (the LAST plugin, so it sees the final graph):
|
|
29
|
+
*
|
|
30
|
+
* // vite.config.ts (production client build)
|
|
31
|
+
* import { viteLeakGuard } from "@nifrajs/web/plugins/vite-leak-guard"
|
|
32
|
+
* export default { build: { rollupOptions: { plugins: [viteLeakGuard()] } } }
|
|
33
|
+
*/
|
|
34
|
+
import {
|
|
35
|
+
detectNodeBuiltinsInClient,
|
|
36
|
+
detectServerOnlyInClient,
|
|
37
|
+
formatNodeBuiltinLeak,
|
|
38
|
+
formatServerOnlyLeak,
|
|
39
|
+
} from "../build.ts"
|
|
40
|
+
import { fromRollupBundle, type RollupBundleLike } from "../module-graph.ts"
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The Rollup plugin-context slice this uses: `getModuleInfo` for a module's resolved imports, and `error`
|
|
44
|
+
* to fail the build. Typed structurally so the file needs no `rollup`/`vite` type dependency.
|
|
45
|
+
*/
|
|
46
|
+
interface RollupPluginContext {
|
|
47
|
+
getModuleInfo(id: string): {
|
|
48
|
+
readonly importedIds?: readonly string[]
|
|
49
|
+
readonly dynamicallyImportedIds?: readonly string[]
|
|
50
|
+
} | null
|
|
51
|
+
/**
|
|
52
|
+
* Takes an `Error`, never a string.
|
|
53
|
+
*
|
|
54
|
+
* Handed a string, rolldown synthesizes its own error type and calls `Error.captureStackTrace` on a
|
|
55
|
+
* plain object - which Bun rejects with "First argument must be an Error object", and THAT becomes the
|
|
56
|
+
* build failure. The guard still fires, but its message is replaced by an internal one naming nothing,
|
|
57
|
+
* so a real client leak reports as a stack-trace complaint. Passing an Error skips that construction
|
|
58
|
+
* entirely and the message survives.
|
|
59
|
+
*/
|
|
60
|
+
error(error: Error): never
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The minimal Rollup plugin shape this returns — `generateBundle` bound to the plugin context. */
|
|
64
|
+
export interface LeakGuardPlugin {
|
|
65
|
+
readonly name: string
|
|
66
|
+
/**
|
|
67
|
+
* The finding that failed the build, or `undefined` when the bundle was clean.
|
|
68
|
+
*
|
|
69
|
+
* Recorded as well as thrown because the throw does not reliably survive. A plugin error is handed to
|
|
70
|
+
* the bundler, which builds its own error to report it, and on some Bun + rolldown combinations that
|
|
71
|
+
* construction itself fails (`Error.captureStackTrace` rejecting a non-Error) - so what surfaces is an
|
|
72
|
+
* internal complaint naming neither the leaked module nor its import chain. A caller that owns the
|
|
73
|
+
* build can read this afterwards and raise the real message from its own code, where nothing can
|
|
74
|
+
* rewrite it. A leak is the one failure that must stay legible: the whole point is naming what
|
|
75
|
+
* reached the browser.
|
|
76
|
+
*/
|
|
77
|
+
leak?: string
|
|
78
|
+
generateBundle(this: RollupPluginContext, options: unknown, bundle: RollupBundleLike): void
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A Vite/Rollup plugin that fails the build when server-only code or a `node:` builtin reaches the client
|
|
83
|
+
* bundle - the same two guards, and the same error messages, as nifra's Bun production build.
|
|
84
|
+
*/
|
|
85
|
+
export function viteLeakGuard(): LeakGuardPlugin {
|
|
86
|
+
const plugin: LeakGuardPlugin = {
|
|
87
|
+
name: "nifra:leak-guard",
|
|
88
|
+
generateBundle(_options, bundle) {
|
|
89
|
+
// Resolved import edges per module, straight from Rollup's graph. Dynamic imports count too: a
|
|
90
|
+
// `node:`/server-only module reached only via `import()` still ships to the browser.
|
|
91
|
+
const importsOf = (id: string): readonly string[] => {
|
|
92
|
+
const info = this.getModuleInfo(id)
|
|
93
|
+
if (info === null) return []
|
|
94
|
+
return [...(info.importedIds ?? []), ...(info.dynamicallyImportedIds ?? [])]
|
|
95
|
+
}
|
|
96
|
+
const graph = fromRollupBundle(bundle, importsOf)
|
|
97
|
+
// Node-builtin guard first, then server-only - the same order as the Bun build, so the first error a
|
|
98
|
+
// dev sees is the same across pipelines when a module trips both.
|
|
99
|
+
const leak =
|
|
100
|
+
formatNodeBuiltinLeak(detectNodeBuiltinsInClient(graph)) ??
|
|
101
|
+
formatServerOnlyLeak(detectServerOnlyInClient(graph))
|
|
102
|
+
if (leak === undefined) return
|
|
103
|
+
// Record before throwing: the throw is what fails the build for a standalone user of this plugin,
|
|
104
|
+
// and the record is what lets a caller that owns the build re-raise the real message (see `leak`).
|
|
105
|
+
plugin.leak = leak
|
|
106
|
+
this.error(new Error(leak))
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
return plugin
|
|
110
|
+
}
|