@nifrajs/web 5.0.0 → 5.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/CHANGELOG.md +2970 -0
- package/README.md +8 -0
- package/dist/build-plan.d.ts +7 -0
- package/dist/build-plan.d.ts.map +1 -1
- package/dist/build-plan.js +19 -0
- package/dist/build-plan.js.map +1 -1
- package/dist/build-vite.d.ts +1 -0
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +51 -3
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +12 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +43 -6
- package/dist/build.js.map +1 -1
- package/dist/client.d.ts +7 -6
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +39 -22
- package/dist/client.js.map +1 -1
- package/dist/dev-port.d.ts +3 -2
- package/dist/dev-port.d.ts.map +1 -1
- package/dist/dev-port.js +2 -6
- package/dist/dev-port.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/codegen.d.ts +6 -0
- package/dist/internal/codegen.d.ts.map +1 -1
- package/dist/internal/codegen.js +23 -5
- package/dist/internal/codegen.js.map +1 -1
- package/dist/internal/copy-split.d.ts +27 -0
- package/dist/internal/copy-split.d.ts.map +1 -0
- package/dist/internal/copy-split.js +173 -0
- package/dist/internal/copy-split.js.map +1 -0
- package/dist/internal/package-entries.d.ts +34 -0
- package/dist/internal/package-entries.d.ts.map +1 -0
- package/dist/internal/package-entries.js +324 -0
- package/dist/internal/package-entries.js.map +1 -0
- package/dist/internal/page-execution.d.ts +19 -1
- package/dist/internal/page-execution.d.ts.map +1 -1
- package/dist/internal/page-execution.js +123 -14
- package/dist/internal/page-execution.js.map +1 -1
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +15 -2
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/private-env.d.ts +8 -0
- package/dist/internal/private-env.d.ts.map +1 -1
- package/dist/internal/private-env.js +25 -10
- package/dist/internal/private-env.js.map +1 -1
- package/dist/internal/render-document.d.ts +15 -46
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +59 -62
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/route-path.d.ts +6 -0
- package/dist/internal/route-path.d.ts.map +1 -1
- package/dist/internal/route-path.js +11 -0
- package/dist/internal/route-path.js.map +1 -1
- package/dist/internal/secret-scan.d.ts.map +1 -1
- package/dist/internal/secret-scan.js +103 -1
- package/dist/internal/secret-scan.js.map +1 -1
- package/dist/internal/typescript-api.d.ts +4 -0
- package/dist/internal/typescript-api.d.ts.map +1 -0
- package/dist/internal/typescript-api.js +8 -0
- package/dist/internal/typescript-api.js.map +1 -0
- package/dist/internal/vite-plugin-compat.d.ts +28 -0
- package/dist/internal/vite-plugin-compat.d.ts.map +1 -0
- package/dist/internal/vite-plugin-compat.js +82 -0
- package/dist/internal/vite-plugin-compat.js.map +1 -0
- package/dist/internal/web-app.d.ts +38 -2
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +30 -3
- package/dist/internal/web-app.js.map +1 -1
- package/dist/internal/without-query.d.ts +4 -0
- package/dist/internal/without-query.d.ts.map +1 -0
- package/dist/internal/without-query.js +9 -0
- package/dist/internal/without-query.js.map +1 -0
- package/dist/internal/zone-graph.d.ts.map +1 -1
- package/dist/internal/zone-graph.js +17 -6
- package/dist/internal/zone-graph.js.map +1 -1
- package/dist/isr.d.ts +1 -1
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +15 -2
- package/dist/isr.js.map +1 -1
- package/dist/manifest.d.ts +9 -0
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +1 -0
- package/dist/manifest.js.map +1 -1
- package/dist/plugins/import-glob.d.ts +20 -0
- package/dist/plugins/import-glob.d.ts.map +1 -0
- package/dist/plugins/import-glob.js +401 -0
- package/dist/plugins/import-glob.js.map +1 -0
- package/dist/plugins/kit.d.ts +11 -0
- package/dist/plugins/kit.d.ts.map +1 -1
- package/dist/plugins/kit.js +32 -0
- package/dist/plugins/kit.js.map +1 -1
- package/dist/plugins/stylex.d.ts.map +1 -1
- package/dist/plugins/stylex.js +2 -2
- package/dist/plugins/stylex.js.map +1 -1
- package/dist/plugins/vite-leak-guard.d.ts +6 -0
- package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
- package/dist/plugins/vite-leak-guard.js +80 -7
- package/dist/plugins/vite-leak-guard.js.map +1 -1
- package/dist/plugins/vite-zone-guard.d.ts.map +1 -1
- package/dist/plugins/vite-zone-guard.js +17 -21
- package/dist/plugins/vite-zone-guard.js.map +1 -1
- package/dist/plugins/zone-guard.d.ts.map +1 -1
- package/dist/plugins/zone-guard.js +44 -3
- package/dist/plugins/zone-guard.js.map +1 -1
- package/dist/prerender.d.ts +5 -1
- package/dist/prerender.d.ts.map +1 -1
- package/dist/prerender.js +41 -19
- package/dist/prerender.js.map +1 -1
- package/dist/render-seam.d.ts +60 -2
- package/dist/render-seam.d.ts.map +1 -1
- package/dist/render-seam.js +33 -0
- package/dist/render-seam.js.map +1 -1
- package/dist/router.d.ts +3 -2
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +13 -9
- package/dist/router.js.map +1 -1
- package/dist/seo.d.ts +49 -0
- package/dist/seo.d.ts.map +1 -0
- package/dist/seo.js +48 -0
- package/dist/seo.js.map +1 -0
- package/dist/server-components/typescript.d.ts.map +1 -1
- package/dist/server-components/typescript.js +2 -7
- package/dist/server-components/typescript.js.map +1 -1
- package/dist/vite.d.ts +1 -28
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +3 -69
- package/dist/vite.js.map +1 -1
- package/dist/zones.d.ts +30 -8
- package/dist/zones.d.ts.map +1 -1
- package/dist/zones.js +185 -46
- package/dist/zones.js.map +1 -1
- package/package.json +10 -4
- package/src/build-plan.ts +21 -0
- package/src/build-vite.ts +60 -3
- package/src/build.ts +57 -4
- package/src/client.ts +42 -22
- package/src/dev-port.ts +4 -10
- package/src/index.ts +8 -4
- package/src/internal/codegen.ts +32 -3
- package/src/internal/copy-split.ts +220 -0
- package/src/internal/package-entries.ts +331 -0
- package/src/internal/page-execution.ts +150 -13
- package/src/internal/parity.ts +15 -2
- package/src/internal/private-env.ts +27 -13
- package/src/internal/render-document.ts +100 -81
- package/src/internal/route-path.ts +16 -0
- package/src/internal/secret-scan.ts +101 -1
- package/src/internal/typescript-api.ts +11 -0
- package/src/internal/vite-plugin-compat.ts +81 -0
- package/src/internal/web-app.ts +71 -6
- package/src/internal/without-query.ts +8 -0
- package/src/internal/zone-graph.ts +19 -6
- package/src/isr.ts +13 -2
- package/src/manifest.ts +10 -0
- package/src/plugins/import-glob.ts +392 -0
- package/src/plugins/kit.ts +36 -0
- package/src/plugins/stylex.ts +2 -1
- package/src/plugins/vite-leak-guard.ts +99 -7
- package/src/plugins/vite-zone-guard.ts +21 -19
- package/src/plugins/zone-guard.ts +41 -2
- package/src/prerender.ts +39 -18
- package/src/render-seam.ts +88 -2
- package/src/router.ts +16 -11
- package/src/seo.ts +68 -0
- package/src/server-components/typescript.ts +2 -10
- package/src/vite.ts +3 -81
- package/src/zones.ts +237 -52
package/src/internal/web-app.ts
CHANGED
|
@@ -21,10 +21,54 @@ import type { RenderAdapter } from "../render-seam.ts"
|
|
|
21
21
|
import { createMatcher, PRERENDERED_LIST_PATH } from "../router.ts"
|
|
22
22
|
import type { EvidenceAncestry } from "./evidence-traversal.ts"
|
|
23
23
|
import { formatShadowedPages, shadowedPages } from "./mount-shadow.ts"
|
|
24
|
-
import { createPageRequestExecutor, type NonceResolver } from "./page-execution.ts"
|
|
24
|
+
import { createPageRequestExecutor, type NonceResolver, type PageI18n } from "./page-execution.ts"
|
|
25
25
|
import { requestPattern } from "./route-path.ts"
|
|
26
26
|
|
|
27
27
|
export type { NonceResolver } from "./page-execution.ts"
|
|
28
|
+
|
|
29
|
+
/** An app's locale routing as `createWebApp` reads it: what `defineI18nRouting()` from
|
|
30
|
+
* `@nifrajs/i18n/routing` returns. */
|
|
31
|
+
export interface WebAppI18n {
|
|
32
|
+
readonly locales: {
|
|
33
|
+
readonly default: string
|
|
34
|
+
/** Served locale keys, in registry order. */
|
|
35
|
+
readonly served: readonly string[]
|
|
36
|
+
documentMeta(key: string): { readonly lang: string; readonly dir: "ltr" | "rtl" }
|
|
37
|
+
}
|
|
38
|
+
localeOf(path: string): string
|
|
39
|
+
matchSegment(
|
|
40
|
+
value: string | undefined,
|
|
41
|
+
pathname: string,
|
|
42
|
+
):
|
|
43
|
+
| { readonly kind: "ok" | "not-found" }
|
|
44
|
+
| { readonly kind: "redirect"; readonly location: string }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function pageI18n(routing: WebAppI18n): PageI18n {
|
|
48
|
+
const { locales } = routing
|
|
49
|
+
if (
|
|
50
|
+
typeof routing.localeOf !== "function" ||
|
|
51
|
+
typeof routing.matchSegment !== "function" ||
|
|
52
|
+
typeof locales?.documentMeta !== "function" ||
|
|
53
|
+
!Array.isArray(locales.served)
|
|
54
|
+
) {
|
|
55
|
+
throw new TypeError(
|
|
56
|
+
"[nifra/web] createWebApp `i18n` must be the router defineI18nRouting() from @nifrajs/i18n/routing returns",
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
locales: {
|
|
61
|
+
default: locales.default,
|
|
62
|
+
locales: locales.served.map((key) => {
|
|
63
|
+
const { lang, dir } = locales.documentMeta(key)
|
|
64
|
+
return { key, lang, dir }
|
|
65
|
+
}),
|
|
66
|
+
},
|
|
67
|
+
localeOf: (path) => routing.localeOf(path),
|
|
68
|
+
matchSegment: (value, pathname) => routing.matchSegment(value, pathname),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
28
72
|
export interface CreateWebAppOptions<Env = unknown> {
|
|
29
73
|
readonly serverComponents?: import("../server-components/types.ts").ServerComponentRuntime
|
|
30
74
|
readonly adapter: RenderAdapter
|
|
@@ -181,6 +225,20 @@ export interface CreateWebAppOptions<Env = unknown> {
|
|
|
181
225
|
* of the aggregate `styles`, so a page ships only the CSS it uses. An empty array ⇒ no `<link>` (the
|
|
182
226
|
* page imports no CSS). Routes absent here fall back to `styles`. Omit ⇒ always use `styles`. */
|
|
183
227
|
readonly routeStyles?: Readonly<Record<string, readonly string[]>>
|
|
228
|
+
/** Locale key -> copy chunk URL (`buildClient`'s `BuildManifest.copyChunks`, the server manifest's
|
|
229
|
+
* `copyChunks`). A production client build moves copy tables (`@nifrajs/i18n/copy`) into one chunk per
|
|
230
|
+
* locale; each page lists them for the browser and preloads its own. Omit ⇒ tables keep their values. */
|
|
231
|
+
readonly copyChunks?: Readonly<Record<string, string>>
|
|
232
|
+
/**
|
|
233
|
+
* The app's locale routing, `defineI18nRouting()` from `@nifrajs/i18n/routing`, registered once:
|
|
234
|
+
* export it as `i18n` from `backend/framework.ts`. Every document then takes its locale from the
|
|
235
|
+
* URL: `<html lang dir>` from the registry unless the route's meta sets `lang`, a `Content-Language`
|
|
236
|
+
* header, and the locale each adapter's `useLocale()` and `<I18nProvider>` read. A route under a
|
|
237
|
+
* `[lang]` or `[[lang]]` directory is checked with its `matchSegment()` before any middleware: an
|
|
238
|
+
* unserved locale, or a page missing from it, is a 404, and a non-canonical prefix (`/en/x` when the
|
|
239
|
+
* default is unprefixed) redirects with a 308.
|
|
240
|
+
*/
|
|
241
|
+
readonly i18n?: WebAppI18n
|
|
184
242
|
/**
|
|
185
243
|
* Activation policy for framework-owned stylesheet links (default `"blocking"`). `"deferred"`
|
|
186
244
|
* emits `media="print"` links; the generated client waits for them and switches them to `all` before
|
|
@@ -199,8 +257,10 @@ export interface CreateWebAppOptions<Env = unknown> {
|
|
|
199
257
|
* team - unreleased feature names, internal hostnames, and "don't touch X yet" notes live there
|
|
200
258
|
* routinely. Turn it on only for a repo whose guidelines you would publish as a page. */
|
|
201
259
|
readonly publishLocalGuidelines?: boolean
|
|
202
|
-
/** Serve `/llms.txt` and `/llms-full.txt
|
|
203
|
-
*
|
|
260
|
+
/** Serve `/llms.txt` and `/llms-full.txt`: the page routes, and the backend routes when the backend
|
|
261
|
+
* is mounted over HTTP at {@link apiPrefix}, with their request and response schemas in the full
|
|
262
|
+
* text. **Off by default**: both are public and unauthenticated, and they list every route, admin
|
|
263
|
+
* and internal ones included. Turn it on for an app whose whole surface you would publish. */
|
|
204
264
|
readonly llmsTxt?: boolean
|
|
205
265
|
/** SSG: per dynamic route pattern, its `getStaticPaths` `fallback` (from `enumerateStaticRoutes` or
|
|
206
266
|
* the build's `prerendered.json`). A route mapped to `"404"` rejects any path NOT in
|
|
@@ -440,6 +500,8 @@ export function createWebApp<Env = unknown>(
|
|
|
440
500
|
...(options.routePreload === undefined ? {} : { routePreload: options.routePreload }),
|
|
441
501
|
...(options.styles === undefined ? {} : { styles: options.styles }),
|
|
442
502
|
...(options.routeStyles === undefined ? {} : { routeStyles: options.routeStyles }),
|
|
503
|
+
...(options.copyChunks === undefined ? {} : { copyChunks: options.copyChunks }),
|
|
504
|
+
...(options.i18n === undefined ? {} : { i18n: pageI18n(options.i18n) }),
|
|
443
505
|
...(options.cssLoading === undefined ? {} : { cssLoading: options.cssLoading }),
|
|
444
506
|
...(options.prerenderedPaths === undefined
|
|
445
507
|
? {}
|
|
@@ -458,8 +520,10 @@ export function createWebApp<Env = unknown>(
|
|
|
458
520
|
cacheChannel,
|
|
459
521
|
})
|
|
460
522
|
|
|
523
|
+
let ownsRootCatchAll = false
|
|
461
524
|
for (const route of manifest.routes) {
|
|
462
525
|
const pattern = requestPattern(route.pattern)
|
|
526
|
+
if (pattern.startsWith("/*")) ownsRootCatchAll = true
|
|
463
527
|
app.register(
|
|
464
528
|
"GET",
|
|
465
529
|
pattern,
|
|
@@ -473,7 +537,7 @@ export function createWebApp<Env = unknown>(
|
|
|
473
537
|
// llms.txt & llms-full.txt describe what a visitor can reach: the pages, and the backend only when it
|
|
474
538
|
// is served over HTTP - a backend the loaders call in-process has no public surface to describe. An
|
|
475
539
|
// app route at either path takes precedence, and each text is built once per app.
|
|
476
|
-
if (options.llmsTxt
|
|
540
|
+
if (options.llmsTxt === true) {
|
|
477
541
|
const llmsOptions = {
|
|
478
542
|
includeLocalGuidelines: options.publishLocalGuidelines === true,
|
|
479
543
|
apiPathPrefix: apiStrip ? mountPathPrefix(apiPrefix) : "",
|
|
@@ -518,8 +582,9 @@ export function createWebApp<Env = unknown>(
|
|
|
518
582
|
}
|
|
519
583
|
|
|
520
584
|
// Wildcard catch-all: unmatched paths render the nearest `_404` (404), or a plain text 404 if
|
|
521
|
-
// absent.
|
|
522
|
-
|
|
585
|
+
// absent. A root catch-all page (`[...slug].tsx`) already answers every path, the root included,
|
|
586
|
+
// and the router refuses a second `/*` under another param name.
|
|
587
|
+
if (!ownsRootCatchAll) app.register("GET", "/*", undefined, pageExecutor.fallback)
|
|
523
588
|
|
|
524
589
|
const evidenceProvider: ComposedEvidenceProvider = async (passed) => {
|
|
525
590
|
const { traverseEvidence } = await import("./evidence-traversal.ts")
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** A module id or specifier up to its first `?` or `#`. An index scan, since `/[?#].*$/` retries
|
|
2
|
+
* from every `#` and runs in quadratic time on a long run of them before a line break. */
|
|
3
|
+
export function withoutQuery(value: string): string {
|
|
4
|
+
const query = value.indexOf("?")
|
|
5
|
+
const hash = value.indexOf("#")
|
|
6
|
+
const cut = query === -1 ? hash : hash === -1 ? query : Math.min(query, hash)
|
|
7
|
+
return cut === -1 ? value : value.slice(0, cut)
|
|
8
|
+
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from "../zones.ts"
|
|
20
20
|
import { codeUnitOrder } from "./code-unit-order.ts"
|
|
21
21
|
import { isBareNodeBuiltin } from "./node-builtins.ts"
|
|
22
|
+
import { withoutQuery } from "./without-query.ts"
|
|
22
23
|
|
|
23
24
|
/** What a graph module id names. */
|
|
24
25
|
export type ModuleSource =
|
|
@@ -30,9 +31,8 @@ export type ModuleSource =
|
|
|
30
31
|
|
|
31
32
|
/** A namespace prefix needs two characters, so a Windows drive letter still reads as a path. */
|
|
32
33
|
const NAMESPACED = /^([a-z][\w.+-]+):(.*)$/i
|
|
33
|
-
const stripQuery = (id: string): string => id.replace(/[?#].*$/, "")
|
|
34
34
|
const virtualOf = (rest: string): ModuleSource => {
|
|
35
|
-
const file =
|
|
35
|
+
const file = withoutQuery(rest)
|
|
36
36
|
return isAbsolute(file) && existsSync(file) ? { kind: "virtual", file } : { kind: "virtual" }
|
|
37
37
|
}
|
|
38
38
|
|
|
@@ -42,7 +42,7 @@ export function bunModuleSource(cwd: string): (id: string) => ModuleSource {
|
|
|
42
42
|
if (id.startsWith("node:") || id.startsWith("bun:")) return { kind: "builtin" }
|
|
43
43
|
const namespaced = NAMESPACED.exec(id)
|
|
44
44
|
if (namespaced !== null && namespaced[1] !== "file") return virtualOf(namespaced[2] ?? "")
|
|
45
|
-
return { kind: "file", file: resolve(cwd,
|
|
45
|
+
return { kind: "file", file: resolve(cwd, withoutQuery(namespaced?.[2] ?? id)) }
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
@@ -50,7 +50,7 @@ export function bunModuleSource(cwd: string): (id: string) => ModuleSource {
|
|
|
50
50
|
export function rollupModuleSource(id: string): ModuleSource {
|
|
51
51
|
if (id.startsWith("node:") || id.includes("__vite-browser-external")) return { kind: "builtin" }
|
|
52
52
|
if (id.startsWith("\0")) return virtualOf(id.slice(1).replace(/^[\w-]+:/, ""))
|
|
53
|
-
const file =
|
|
53
|
+
const file = withoutQuery(id)
|
|
54
54
|
return isAbsolute(file) ? { kind: "file", file } : { kind: "virtual" }
|
|
55
55
|
}
|
|
56
56
|
|
|
@@ -233,7 +233,9 @@ export function verifyClientGraph(
|
|
|
233
233
|
if (file === undefined) continue
|
|
234
234
|
const classification = classifier.classify(file)
|
|
235
235
|
let reason = browserDenial(classification)
|
|
236
|
-
|
|
236
|
+
// An import that names a package entry is judged by that entry's own declared side.
|
|
237
|
+
for (const spec of specifiers.get(id) ?? [])
|
|
238
|
+
reason ??= browserDenial(classifier.classify(file, spec), spec)
|
|
237
239
|
if (reason === undefined && BROWSER_CODE.has(classification.zone))
|
|
238
240
|
reason = options.privateEnv?.(file)
|
|
239
241
|
if (reason !== undefined) denied.set(id, reason)
|
|
@@ -406,7 +408,18 @@ export function verifyServerGraph(
|
|
|
406
408
|
const target = resolveEdge(im)
|
|
407
409
|
if (target === undefined) continue
|
|
408
410
|
out.push({ to: target, label: im.original ?? labelOf(target) })
|
|
409
|
-
const
|
|
411
|
+
const targetFile = fileOf(target)
|
|
412
|
+
const to =
|
|
413
|
+
im.original === undefined || targetFile === undefined
|
|
414
|
+
? classOf(target)
|
|
415
|
+
: classifier.classify(targetFile, im.original)
|
|
416
|
+
if (to?.zone === "error" && to.entry !== undefined)
|
|
417
|
+
found.push({
|
|
418
|
+
at: id,
|
|
419
|
+
module: labelOf(target),
|
|
420
|
+
reason: to.reason,
|
|
421
|
+
tail: im.original ?? labelOf(target),
|
|
422
|
+
})
|
|
410
423
|
if (from === undefined || to === undefined || from.zone === "error" || to.zone === "error")
|
|
411
424
|
continue
|
|
412
425
|
const componentBridge =
|
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 { isAuthenticatedRequest, writableResponse } from "@nifrajs/core/server"
|
|
10
|
+
import { readBoundedBytes, TransportCodecError } from "@nifrajs/core/transport-codec"
|
|
10
11
|
import { isDraftEnabled } from "./draft.ts"
|
|
11
12
|
import { assertTokenSecret, timingSafeEqual } from "./internal/timing-safe-equal.ts"
|
|
12
13
|
|
|
@@ -1139,6 +1140,8 @@ export interface CdnPurgeTarget {
|
|
|
1139
1140
|
/** Most paths and tags one revalidation request may name, so a leaked token cannot fan out. */
|
|
1140
1141
|
export const MAX_REVALIDATE_PATHS = 100
|
|
1141
1142
|
export const MAX_REVALIDATE_TAGS = MAX_ISR_TAGS
|
|
1143
|
+
// Room for a full batch of long paths; a purge body past it is refused before it is buffered.
|
|
1144
|
+
const MAX_REVALIDATE_BODY_BYTES = 1024 * 1024
|
|
1142
1145
|
|
|
1143
1146
|
export interface RevalidateEndpointOptions {
|
|
1144
1147
|
readonly store: CacheStore
|
|
@@ -1180,7 +1183,7 @@ const stringList = (value: unknown): string[] | null | undefined => {
|
|
|
1180
1183
|
* `?path=/blog/x`, `?tag=products`, a JSON `{ "path": "/blog/x" }` / `{ "tag": "products" }` body, or a
|
|
1181
1184
|
* batch `{ "paths": [...], "tags": [...] }` of at most 100 paths and 32 tags.
|
|
1182
1185
|
* The token is checked in **constant time** (wrong/missing → `401`); malformed targets → `400`;
|
|
1183
|
-
* non-POST → `405`. A store without tag support returns `501` for tag requests. Mount it on a nifra route, e.g.
|
|
1186
|
+
* a JSON body over 1 MiB → `413`; non-POST → `405`. A store without tag support returns `501` for tag requests. Mount it on a nifra route, e.g.
|
|
1184
1187
|
* `app.post("/__nifra/revalidate", (c) => handler(c.req))`.
|
|
1185
1188
|
*/
|
|
1186
1189
|
export function revalidateEndpoint(
|
|
@@ -1205,7 +1208,15 @@ export function revalidateEndpoint(
|
|
|
1205
1208
|
let tag = url.searchParams.get("tag")
|
|
1206
1209
|
let batch: { paths: string[]; tags: string[] } | undefined
|
|
1207
1210
|
if (path === null && tag === null) {
|
|
1208
|
-
|
|
1211
|
+
let body: unknown = null
|
|
1212
|
+
try {
|
|
1213
|
+
const bytes = await readBoundedBytes(new Response(req.body, { headers: req.headers }), {
|
|
1214
|
+
maxBytes: MAX_REVALIDATE_BODY_BYTES,
|
|
1215
|
+
})
|
|
1216
|
+
body = JSON.parse(new TextDecoder().decode(bytes))
|
|
1217
|
+
} catch (error) {
|
|
1218
|
+
if (error instanceof TransportCodecError) return jsonError(413, "body_too_large")
|
|
1219
|
+
}
|
|
1209
1220
|
if (typeof body === "object" && body !== null) {
|
|
1210
1221
|
const candidate = body as { path?: unknown; tag?: unknown; paths?: unknown; tags?: unknown }
|
|
1211
1222
|
if (candidate.paths !== undefined || candidate.tags !== undefined) {
|
package/src/manifest.ts
CHANGED
|
@@ -337,6 +337,15 @@ export interface RouteModule {
|
|
|
337
337
|
/** Layout loader that must run before actions and is not retained across the action's render. */
|
|
338
338
|
readonly gate?: boolean
|
|
339
339
|
readonly action?: Action
|
|
340
|
+
/**
|
|
341
|
+
* Backend half only: which browser origins may POST to {@link action}. When omitted, a request
|
|
342
|
+
* whose `Origin` is another site's is refused with 403 before middleware, gates or the action run;
|
|
343
|
+
* requests without an `Origin` (non-browser clients) pass. An array allows exactly those origins
|
|
344
|
+
* (and refuses a request without one); a function decides, receiving `null` when there is none.
|
|
345
|
+
* Use it for a callback a third party posts to, such as an identity provider's `form_post`.
|
|
346
|
+
* `Origin` is browser-enforced, not authentication: keep the action's own auth checks.
|
|
347
|
+
*/
|
|
348
|
+
readonly allowedOrigins?: ReadonlyArray<string> | ((origin: string | null) => boolean)
|
|
340
349
|
/**
|
|
341
350
|
* Backend half only: the schema of what {@link loader} sends to the browser. Required whenever the
|
|
342
351
|
* loader returns data. Keys it does not declare are dropped before the page renders; a declared
|
|
@@ -642,6 +651,7 @@ export const BACKEND_ROUTE_EXPORTS: ReadonlySet<string> = new Set([
|
|
|
642
651
|
"middleware",
|
|
643
652
|
"gate",
|
|
644
653
|
"shouldRevalidate",
|
|
654
|
+
"allowedOrigins",
|
|
645
655
|
])
|
|
646
656
|
|
|
647
657
|
const routeError = (message: string): Error => new Error(`[nifra/web] ${message}`)
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `import.meta.glob` for the Bun build. Vite expands it at build time and Bun has no equivalent, so
|
|
3
|
+
* without this a module that loads its siblings through a glob bundles with the call left in place:
|
|
4
|
+
* `import.meta.glob` is `undefined` in the browser and every file it named is missing from the build.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync } from "node:fs"
|
|
7
|
+
import { dirname, extname, relative, resolve, sep } from "node:path"
|
|
8
|
+
import type { BunPlugin } from "bun"
|
|
9
|
+
import { readSource } from "./kit.ts"
|
|
10
|
+
|
|
11
|
+
const SOURCE = /\.[cm]?[jt]sx?$/
|
|
12
|
+
const CALL = /\bimport\.meta\.glob\b/g
|
|
13
|
+
const LOADERS: Readonly<Record<string, "ts" | "tsx" | "jsx" | "js">> = {
|
|
14
|
+
".ts": "ts",
|
|
15
|
+
".mts": "ts",
|
|
16
|
+
".cts": "ts",
|
|
17
|
+
".tsx": "tsx",
|
|
18
|
+
".jsx": "jsx",
|
|
19
|
+
// Bun's own default: a `.js` module may hold JSX, a `.mjs` or `.cjs` one may not.
|
|
20
|
+
".js": "jsx",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface GlobOptions {
|
|
24
|
+
readonly eager: boolean
|
|
25
|
+
/** The export each entry takes: `"default"`, a name, or the whole module when unset. */
|
|
26
|
+
readonly pick: string | undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A literal the call's arguments may hold. */
|
|
30
|
+
type Literal = string | boolean | readonly string[] | Readonly<Record<string, string | boolean>>
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `source` with every comment, string body and template text blanked to spaces, offsets kept, so a
|
|
34
|
+
* glob named in prose or inside a string is never read as a call. A template's `${}` stays code.
|
|
35
|
+
*/
|
|
36
|
+
function codeOnly(source: string): string {
|
|
37
|
+
const out = source.split("")
|
|
38
|
+
const blank = (from: number, to: number): void => {
|
|
39
|
+
for (let index = from; index < to; index++) if (out[index] !== "\n") out[index] = " "
|
|
40
|
+
}
|
|
41
|
+
// Reads code from `start`; inside a template's `${`, returns past the `}` that closes it.
|
|
42
|
+
const code = (start: number, nested: boolean): number => {
|
|
43
|
+
let index = start
|
|
44
|
+
let depth = 0
|
|
45
|
+
while (index < source.length) {
|
|
46
|
+
const char = source[index]
|
|
47
|
+
if (source.startsWith("//", index)) {
|
|
48
|
+
const end = source.indexOf("\n", index)
|
|
49
|
+
const stop = end === -1 ? source.length : end
|
|
50
|
+
blank(index, stop)
|
|
51
|
+
index = stop
|
|
52
|
+
} else if (source.startsWith("/*", index)) {
|
|
53
|
+
const end = source.indexOf("*/", index + 2)
|
|
54
|
+
const stop = end === -1 ? source.length : end + 2
|
|
55
|
+
blank(index, stop)
|
|
56
|
+
index = stop
|
|
57
|
+
} else if (char === '"' || char === "'") {
|
|
58
|
+
let end = index + 1
|
|
59
|
+
while (end < source.length && source[end] !== char) end += source[end] === "\\" ? 2 : 1
|
|
60
|
+
blank(index + 1, Math.min(end, source.length))
|
|
61
|
+
index = end + 1
|
|
62
|
+
} else if (char === "`") {
|
|
63
|
+
index = template(index + 1)
|
|
64
|
+
} else if (nested && char === "}" && depth === 0) {
|
|
65
|
+
return index + 1
|
|
66
|
+
} else {
|
|
67
|
+
if (nested && char === "{") depth += 1
|
|
68
|
+
if (nested && char === "}") depth -= 1
|
|
69
|
+
index += 1
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return index
|
|
73
|
+
}
|
|
74
|
+
const template = (start: number): number => {
|
|
75
|
+
let index = start
|
|
76
|
+
while (index < source.length && source[index] !== "`") {
|
|
77
|
+
if (source.startsWith("${", index)) {
|
|
78
|
+
index = code(index + 2, true)
|
|
79
|
+
} else {
|
|
80
|
+
const step = source[index] === "\\" ? 2 : 1
|
|
81
|
+
blank(index, index + step)
|
|
82
|
+
index += step
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return index + 1
|
|
86
|
+
}
|
|
87
|
+
code(0, false)
|
|
88
|
+
return out.join("")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A reader over the call's argument text: string, array of strings, `{ key: literal }`, booleans. */
|
|
92
|
+
function argumentReader(text: string, start: number) {
|
|
93
|
+
let at = start
|
|
94
|
+
const space = (): void => {
|
|
95
|
+
while (at < text.length && /\s/.test(text[at] ?? "")) at += 1
|
|
96
|
+
}
|
|
97
|
+
const fail = (): never => {
|
|
98
|
+
throw new SyntaxError("not a literal")
|
|
99
|
+
}
|
|
100
|
+
const string = (): string => {
|
|
101
|
+
const quote = text[at]
|
|
102
|
+
if (quote !== '"' && quote !== "'" && quote !== "`") return fail()
|
|
103
|
+
let value = ""
|
|
104
|
+
at += 1
|
|
105
|
+
while (at < text.length && text[at] !== quote) {
|
|
106
|
+
const char = text[at] ?? ""
|
|
107
|
+
if (quote === "`" && char === "$" && text[at + 1] === "{") return fail()
|
|
108
|
+
if (char === "\\") {
|
|
109
|
+
value += text[at + 1] ?? ""
|
|
110
|
+
at += 2
|
|
111
|
+
} else {
|
|
112
|
+
value += char
|
|
113
|
+
at += 1
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
at += 1
|
|
117
|
+
return value
|
|
118
|
+
}
|
|
119
|
+
const literal = (): Literal => {
|
|
120
|
+
space()
|
|
121
|
+
const char = text[at]
|
|
122
|
+
if (char === "[") {
|
|
123
|
+
at += 1
|
|
124
|
+
const items: string[] = []
|
|
125
|
+
for (;;) {
|
|
126
|
+
space()
|
|
127
|
+
if (text[at] === "]") break
|
|
128
|
+
items.push(string())
|
|
129
|
+
space()
|
|
130
|
+
if (text[at] === ",") at += 1
|
|
131
|
+
else if (text[at] !== "]") return fail()
|
|
132
|
+
}
|
|
133
|
+
at += 1
|
|
134
|
+
return items
|
|
135
|
+
}
|
|
136
|
+
if (char === "{") {
|
|
137
|
+
at += 1
|
|
138
|
+
const entries: Record<string, string | boolean> = {}
|
|
139
|
+
for (;;) {
|
|
140
|
+
space()
|
|
141
|
+
if (text[at] === "}") break
|
|
142
|
+
const key = /^[A-Za-z_$][\w$]*/.exec(text.slice(at))?.[0]
|
|
143
|
+
const name = key ?? string()
|
|
144
|
+
if (key !== undefined) at += key.length
|
|
145
|
+
space()
|
|
146
|
+
if (text[at] !== ":") return fail()
|
|
147
|
+
at += 1
|
|
148
|
+
const value = literal()
|
|
149
|
+
if (typeof value !== "string" && typeof value !== "boolean") return fail()
|
|
150
|
+
entries[name] = value
|
|
151
|
+
space()
|
|
152
|
+
if (text[at] === ",") at += 1
|
|
153
|
+
else if (text[at] !== "}") return fail()
|
|
154
|
+
}
|
|
155
|
+
at += 1
|
|
156
|
+
return entries
|
|
157
|
+
}
|
|
158
|
+
if (text.startsWith("true", at) || text.startsWith("false", at)) {
|
|
159
|
+
const value = text.startsWith("true", at)
|
|
160
|
+
at += value ? 4 : 5
|
|
161
|
+
return value
|
|
162
|
+
}
|
|
163
|
+
return string()
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
literal,
|
|
167
|
+
space,
|
|
168
|
+
get at() {
|
|
169
|
+
return at
|
|
170
|
+
},
|
|
171
|
+
expect(char: string): void {
|
|
172
|
+
space()
|
|
173
|
+
if (text[at] !== char) fail()
|
|
174
|
+
at += 1
|
|
175
|
+
},
|
|
176
|
+
peek(): string | undefined {
|
|
177
|
+
space()
|
|
178
|
+
return text[at]
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const OPTION_NAMES = new Set(["eager", "import"])
|
|
184
|
+
|
|
185
|
+
// `Array.isArray` does not narrow a readonly array out of a union.
|
|
186
|
+
const isList = (value: Literal): value is readonly string[] => Array.isArray(value)
|
|
187
|
+
|
|
188
|
+
function readOptions(value: Literal | undefined): GlobOptions {
|
|
189
|
+
if (value === undefined) return { eager: false, pick: undefined }
|
|
190
|
+
if (typeof value !== "object" || isList(value)) throw new SyntaxError("options")
|
|
191
|
+
const options: Readonly<Record<string, string | boolean>> = value
|
|
192
|
+
for (const name of Object.keys(options))
|
|
193
|
+
if (!OPTION_NAMES.has(name)) throw new SyntaxError(`option ${name}`)
|
|
194
|
+
const eager = options.eager ?? false
|
|
195
|
+
const pick = options.import
|
|
196
|
+
if (typeof eager !== "boolean" || (pick !== undefined && typeof pick !== "string"))
|
|
197
|
+
throw new SyntaxError("option types")
|
|
198
|
+
return { eager, pick }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The files a set of patterns matches from `importer`, by Vite's rules, sorted; `!` excludes. */
|
|
202
|
+
function matchGlob(importer: string, root: string, patterns: readonly string[]): string[] {
|
|
203
|
+
// Globs read `\` as an escape, so on Windows every path is matched with forward slashes.
|
|
204
|
+
const slash = (path: string): string => (sep === "\\" ? path.replaceAll("\\", "/") : path)
|
|
205
|
+
const absolute = (pattern: string): string => {
|
|
206
|
+
if (pattern.startsWith("/")) return slash(resolve(root, `.${pattern}`))
|
|
207
|
+
if (pattern.startsWith("./") || pattern.startsWith("../"))
|
|
208
|
+
return slash(resolve(dirname(importer), pattern))
|
|
209
|
+
throw new SyntaxError(`pattern ${pattern}`)
|
|
210
|
+
}
|
|
211
|
+
const include = patterns.filter((pattern) => !pattern.startsWith("!")).map(absolute)
|
|
212
|
+
const exclude = patterns
|
|
213
|
+
.filter((pattern) => pattern.startsWith("!"))
|
|
214
|
+
.map((pattern) => new Bun.Glob(absolute(pattern.slice(1))))
|
|
215
|
+
const found = new Set<string>()
|
|
216
|
+
for (const pattern of include) {
|
|
217
|
+
const segments = pattern.split("/")
|
|
218
|
+
const wild = segments.findIndex((segment) => /[*?[\]{}]/.test(segment))
|
|
219
|
+
if (wild === -1) {
|
|
220
|
+
if (existsSync(pattern)) found.add(pattern)
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
const base = segments.slice(0, wild).join("/") || "/"
|
|
224
|
+
const rest = segments.slice(wild).join("/")
|
|
225
|
+
for (const file of new Bun.Glob(rest).scanSync({ cwd: base, onlyFiles: true })) {
|
|
226
|
+
const path = slash(resolve(base, file))
|
|
227
|
+
if (!rest.includes("node_modules") && path.split("/").includes("node_modules")) continue
|
|
228
|
+
found.add(path)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const self = slash(importer)
|
|
232
|
+
return [...found]
|
|
233
|
+
.filter((path) => path !== self && !exclude.some((glob) => glob.match(path)))
|
|
234
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** `value` as a JS string literal that stays on one line for every parser: before ES2019, U+2028 and
|
|
238
|
+
* U+2029 end a line even inside a string. */
|
|
239
|
+
const stringLiteral = (value: string): string =>
|
|
240
|
+
JSON.stringify(value).replace(
|
|
241
|
+
/[\u2028\u2029]/g,
|
|
242
|
+
(char) => `\\u${char.charCodeAt(0).toString(16)}`,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
/** How a matched file is keyed, as Vite keys it: from the importer (`./x.ts`) or the root (`/x.ts`). */
|
|
246
|
+
function keyOf(importer: string, root: string, file: string, rooted: boolean): string {
|
|
247
|
+
if (rooted) return `/${relative(root, file).replaceAll("\\", "/")}`
|
|
248
|
+
const path = relative(dirname(importer), file).replaceAll("\\", "/")
|
|
249
|
+
return path.startsWith(".") ? path : `./${path}`
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* How often `import.meta.glob` appears in the module's code as Bun reads it: types stripped and JSX
|
|
254
|
+
* text turned into strings, neither of which {@link codeOnly} can see in the source.
|
|
255
|
+
*/
|
|
256
|
+
function globsInCode(source: string, file: string): number {
|
|
257
|
+
const js = new Bun.Transpiler({ loader: LOADERS[extname(file)] ?? "js" }).transformSync(source)
|
|
258
|
+
return codeOnly(js).match(CALL)?.length ?? 0
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const unclear = (file: string): Error =>
|
|
262
|
+
new Error(
|
|
263
|
+
`[nifra/web] ${file}: the Bun build cannot tell this module's import.meta.glob calls from text that names it (JSX text, or a quote inside a regular expression). Move the calls into a .ts module, or build with --vite.`,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* `source` (the module at `file`) with each `import.meta.glob(...)` call expanded into an object of
|
|
268
|
+
* the files it matches. Throws, naming the line, on a call this cannot read.
|
|
269
|
+
*/
|
|
270
|
+
export function expandImportGlobs(source: string, file: string, root: string): string {
|
|
271
|
+
const real = globsInCode(source, file)
|
|
272
|
+
// A docs page that shows the call in JSX text has no call to expand, and rewriting its text is wrong.
|
|
273
|
+
if (real === 0) return source
|
|
274
|
+
// JSX text is not a string to `codeOnly`: an apostrophe there flips what it reads as code.
|
|
275
|
+
const code = codeOnly(source)
|
|
276
|
+
if ((code.match(CALL)?.length ?? 0) !== real) throw unclear(file)
|
|
277
|
+
const edits: Array<{ start: number; end: number; text: string }> = []
|
|
278
|
+
const hoisted: string[] = []
|
|
279
|
+
let call = 0
|
|
280
|
+
for (const match of code.matchAll(CALL)) {
|
|
281
|
+
let start = match.index
|
|
282
|
+
const line = source.slice(0, start).split("\n").length
|
|
283
|
+
// A bundle that expands the call has the function: a `typeof` guard around it reads so.
|
|
284
|
+
const guard = /\btypeof\s+$/.exec(code.slice(Math.max(0, start - 32), start))
|
|
285
|
+
if (guard !== null) {
|
|
286
|
+
start -= guard[0].length
|
|
287
|
+
edits.push({ start, end: match.index + match[0].length, text: '"function"' })
|
|
288
|
+
continue
|
|
289
|
+
}
|
|
290
|
+
const refuse = (why: string): never => {
|
|
291
|
+
throw new Error(
|
|
292
|
+
`[nifra/web] ${file}:${line}: import.meta.glob ${why}. The Bun build reads a literal pattern (a string or an array of strings starting with ./, ../ or /, "!" excluding) and the options eager and import; write it that way, or build with --vite.`,
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
let after = start + match[0].length
|
|
296
|
+
if (!/^\s*[(<]/.test(code.slice(after))) refuse("is read as a value, not called")
|
|
297
|
+
// `import.meta.glob<T>(...)`: TypeScript's type argument, dropped with the call.
|
|
298
|
+
if (code[after] === "<") {
|
|
299
|
+
const close = code.indexOf(">(", after)
|
|
300
|
+
if (close === -1) refuse("has a type argument this cannot read")
|
|
301
|
+
after = close + 1
|
|
302
|
+
}
|
|
303
|
+
const reader = argumentReader(source, after)
|
|
304
|
+
let patterns: readonly string[]
|
|
305
|
+
let options: GlobOptions
|
|
306
|
+
try {
|
|
307
|
+
reader.expect("(")
|
|
308
|
+
const first = reader.literal()
|
|
309
|
+
patterns = typeof first === "string" ? [first] : isList(first) ? first : []
|
|
310
|
+
if (patterns.length === 0) throw new SyntaxError("patterns")
|
|
311
|
+
let second: Literal | undefined
|
|
312
|
+
if (reader.peek() === ",") {
|
|
313
|
+
reader.expect(",")
|
|
314
|
+
if (reader.peek() !== ")") second = reader.literal()
|
|
315
|
+
}
|
|
316
|
+
options = readOptions(second)
|
|
317
|
+
if (reader.peek() === ",") reader.expect(",")
|
|
318
|
+
reader.expect(")")
|
|
319
|
+
} catch (error) {
|
|
320
|
+
return refuse(
|
|
321
|
+
error instanceof SyntaxError && error.message.startsWith("option ")
|
|
322
|
+
? `takes the ${error.message.slice(7)} option, which the Bun build does not support`
|
|
323
|
+
: "takes arguments that are not literals",
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
let files: string[]
|
|
327
|
+
try {
|
|
328
|
+
files = matchGlob(file, root, patterns)
|
|
329
|
+
} catch {
|
|
330
|
+
return refuse("takes a pattern that does not start with ./, ../ or /")
|
|
331
|
+
}
|
|
332
|
+
const rooted = patterns.some((pattern) => pattern.replace(/^!/, "").startsWith("/"))
|
|
333
|
+
const entries = files.map((target, index) => {
|
|
334
|
+
const key = stringLiteral(keyOf(file, root, target, rooted))
|
|
335
|
+
const specifier = stringLiteral(keyOf(file, root, target, false))
|
|
336
|
+
if (options.eager) {
|
|
337
|
+
const name = `__nifra_glob_${call}_${index}`
|
|
338
|
+
hoisted.push(`import * as ${name} from ${specifier}`)
|
|
339
|
+
return `${key}: ${options.pick === undefined ? name : `${name}[${stringLiteral(options.pick)}]`}`
|
|
340
|
+
}
|
|
341
|
+
const load = `import(${specifier})`
|
|
342
|
+
return `${key}: () => ${options.pick === undefined ? load : `${load}.then((m) => m[${stringLiteral(options.pick)}])`}`
|
|
343
|
+
})
|
|
344
|
+
edits.push({ start, end: reader.at, text: `({${entries.join(", ")}})` })
|
|
345
|
+
call += 1
|
|
346
|
+
}
|
|
347
|
+
let next = source
|
|
348
|
+
for (const edit of edits.reverse())
|
|
349
|
+
next = next.slice(0, edit.start) + edit.text + next.slice(edit.end)
|
|
350
|
+
// Equal counts can still pair a text mention with a missed call; one left in the code is that.
|
|
351
|
+
if (globsInCode(next, file) !== 0) throw unclear(file)
|
|
352
|
+
// Import declarations are hoisted wherever they sit, so appending keeps any directive prologue first.
|
|
353
|
+
return hoisted.length === 0 ? next : `${next}\n${hoisted.join("\n")}\n`
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Expand `import.meta.glob` in the app's own modules, for `Bun.build`. `root` is the app root, which a
|
|
358
|
+
* pattern starting with `/` is read from. Installed packages are left as published. Register it after
|
|
359
|
+
* every plugin that loads app modules itself (a server-function stub must win over it); a module such
|
|
360
|
+
* a plugin loads keeps its call, which {@link assertNoImportGlob} then refuses in the output. Register
|
|
361
|
+
* it before a plugin that rewrites source through `addSourceTransform`: it reads through that rewrite.
|
|
362
|
+
*/
|
|
363
|
+
export function importGlobPlugin(options: { readonly root: string }): BunPlugin {
|
|
364
|
+
return {
|
|
365
|
+
name: "nifra-import-glob",
|
|
366
|
+
setup(build) {
|
|
367
|
+
build.onLoad({ filter: SOURCE }, async (args) => {
|
|
368
|
+
if (args.namespace !== "file" || args.path.split("/").includes("node_modules"))
|
|
369
|
+
return undefined
|
|
370
|
+
if (!(await Bun.file(args.path).text()).includes("import.meta.glob")) return undefined
|
|
371
|
+
// Claiming the module would skip a later plugin's rewrite of it (the copy split), so read
|
|
372
|
+
// through it.
|
|
373
|
+
const source = await readSource(build, args.path)
|
|
374
|
+
const contents = expandImportGlobs(source, args.path, options.root)
|
|
375
|
+
if (contents === source) return undefined
|
|
376
|
+
return { contents, loader: LOADERS[extname(args.path)] ?? "js" }
|
|
377
|
+
})
|
|
378
|
+
},
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Refuse a bundle that still calls `import.meta.glob`: the browser has no such function, so every
|
|
383
|
+
* file the call named would be missing at run time. `outputs` are the emitted chunks, path to text. */
|
|
384
|
+
export function assertNoImportGlob(outputs: ReadonlyArray<readonly [string, string]>): void {
|
|
385
|
+
const left = outputs
|
|
386
|
+
.filter(([, text]) => /\bimport\.meta\.glob\s*\(/.test(text))
|
|
387
|
+
.map(([path]) => path)
|
|
388
|
+
if (left.length === 0) return
|
|
389
|
+
throw new Error(
|
|
390
|
+
`[nifra/web] ${left.join(", ")} still call${left.length === 1 ? "s" : ""} import.meta.glob, which no browser or server runtime defines. A plugin that loads a module itself runs before the glob expansion and keeps its call; expand it in that plugin, or build with --vite.`,
|
|
391
|
+
)
|
|
392
|
+
}
|