@nifrajs/web 5.0.0 → 5.1.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/README.md +8 -0
- package/dist/client.d.ts +5 -5
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +25 -15
- 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 +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- 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.map +1 -1
- package/dist/internal/page-execution.js +60 -6
- 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 +4 -44
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +23 -58
- 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/web-app.d.ts +4 -2
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +8 -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/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 +23 -2
- package/dist/render-seam.d.ts.map +1 -1
- package/dist/render-seam.js.map +1 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +9 -5
- 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/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 +8 -3
- package/src/client.ts +27 -15
- package/src/dev-port.ts +4 -10
- package/src/index.ts +2 -4
- package/src/internal/package-entries.ts +331 -0
- package/src/internal/page-execution.ts +67 -6
- package/src/internal/parity.ts +15 -2
- package/src/internal/private-env.ts +27 -13
- package/src/internal/render-document.ts +40 -78
- package/src/internal/route-path.ts +16 -0
- package/src/internal/secret-scan.ts +101 -1
- package/src/internal/web-app.ts +10 -5
- 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/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 +28 -2
- package/src/router.ts +9 -5
- package/src/seo.ts +68 -0
- package/src/zones.ts +237 -52
|
@@ -6,15 +6,12 @@ import { DEFERRED_ERROR_CODE, DEFERRED_RUNTIME, prepareDeferred } from "../defer
|
|
|
6
6
|
import { ISR_REVALIDATE_HEADER, ISR_REVALIDATE_TAGS_HEADER, serializeISRTags } from "../isr.ts"
|
|
7
7
|
import type {
|
|
8
8
|
Loader as LayoutLoader,
|
|
9
|
-
LinkDescriptor,
|
|
10
9
|
Meta,
|
|
11
|
-
MetaDescriptor,
|
|
12
10
|
MetaInput,
|
|
13
11
|
RouteModule,
|
|
14
|
-
ScriptDescriptor,
|
|
15
12
|
UnsafeScriptDescriptor,
|
|
16
13
|
} from "../manifest.ts"
|
|
17
|
-
import type { MatchChain, RenderAdapter, RenderProps } from "../render-seam.ts"
|
|
14
|
+
import type { DocumentParts, MatchChain, RenderAdapter, RenderProps } from "../render-seam.ts"
|
|
18
15
|
import {
|
|
19
16
|
ACTION_GLOBAL,
|
|
20
17
|
BOUNDARY_GLOBAL,
|
|
@@ -201,8 +198,10 @@ export interface RenderPageOptions {
|
|
|
201
198
|
export interface RenderAssemblyCache {
|
|
202
199
|
/** Shell up to (not including) the deferred-runtime insertion point. */
|
|
203
200
|
shellPre?: string
|
|
204
|
-
/** Shell from after the deferred-runtime insertion point through
|
|
201
|
+
/** Shell from after the deferred-runtime insertion point through `<body>`. */
|
|
205
202
|
shellPost?: string
|
|
203
|
+
/** The open `#root` container. */
|
|
204
|
+
rootOpen?: string
|
|
206
205
|
/** Tail opener: the handover script open tag + the data member's key. */
|
|
207
206
|
tailPre?: string
|
|
208
207
|
/** Tail from after the per-request handover members (the route id, prerendered paths, the handover
|
|
@@ -423,7 +422,8 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
423
422
|
? `<link rel="modulepreload" href="${escapeAttr(clientEntry ?? "")}">${preloadLinks}`
|
|
424
423
|
: ""
|
|
425
424
|
slot.shellPre = `<!doctype html><html${htmlAttrs}><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${hydrationGuard}<title>${escapeHtml(head?.title ?? title)}</title>${headTags(head, nonce)}${styleLinks}${entryPreloads}`
|
|
426
|
-
slot.shellPost = `${islandPreloads}${hydrationHead}</head><body
|
|
425
|
+
slot.shellPost = `${islandPreloads}${hydrationHead}</head><body>`
|
|
426
|
+
slot.rootOpen = `<div id="${escapeAttr(rootId)}"${rootMarker}>`
|
|
427
427
|
// Island bundles load regardless of `hydrate` - a static page (hydrate:false) ships no framework
|
|
428
428
|
// client but can still mount no-framework islands (`@nifrajs/web/islands`).
|
|
429
429
|
let islandTags = ""
|
|
@@ -441,7 +441,13 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
441
441
|
// The deferred runtime rides only on a hydrating page (a static page has no client takeover), and
|
|
442
442
|
// only when something actually deferred - matching the pre-cache emission exactly.
|
|
443
443
|
const runtimeSeam = hydrate && allDeferred.length > 0 ? deferredRuntime : ""
|
|
444
|
-
const shellHtml = `${slot.shellPre}${runtimeSeam}${slot.shellPost}`
|
|
444
|
+
const shellHtml = `${slot.shellPre}${runtimeSeam}${slot.shellPost}${slot.rootOpen}`
|
|
445
|
+
const parts: DocumentParts | undefined =
|
|
446
|
+
adapter.documentParts === true ? { head: "", body: "" } : undefined
|
|
447
|
+
const shellOf = (): string =>
|
|
448
|
+
parts === undefined || (parts.head === "" && parts.body === "")
|
|
449
|
+
? shellHtml
|
|
450
|
+
: `${slot.shellPre}${runtimeSeam}${parts.head}${slot.shellPost}${parts.body}${slot.rootOpen}`
|
|
445
451
|
// Closes the hydration container; deferred resolve scripts go AFTER it (outside `#root`) so they
|
|
446
452
|
// aren't part of the adapter's hydrated tree (an inline script inside it breaks hydration).
|
|
447
453
|
const closeRootHtml = "</div>"
|
|
@@ -504,13 +510,22 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
504
510
|
// gets an automatic Content-Length. A render throw surfaces here exactly as the streaming path's
|
|
505
511
|
// shell-readiness `await` does, so the `_error` boundary still maps it to a status.
|
|
506
512
|
if (allDeferred.length === 0 && adapter.renderToString !== undefined) {
|
|
507
|
-
const out =
|
|
513
|
+
const out =
|
|
514
|
+
parts !== undefined
|
|
515
|
+
? adapter.renderToString(
|
|
516
|
+
chain,
|
|
517
|
+
renderProps,
|
|
518
|
+
nonce === undefined ? { parts } : { nonce, parts },
|
|
519
|
+
)
|
|
520
|
+
: nonce === undefined
|
|
521
|
+
? adapter.renderToString(chain, renderProps)
|
|
522
|
+
: adapter.renderToString(chain, renderProps, { nonce })
|
|
508
523
|
return typeof out === "string"
|
|
509
|
-
? new BufferedRenderedPage(
|
|
524
|
+
? new BufferedRenderedPage(shellOf() + out + closeRootHtml + tailHtml, status, headers)
|
|
510
525
|
: out.then(
|
|
511
526
|
(bodyHtml) =>
|
|
512
527
|
new BufferedRenderedPage(
|
|
513
|
-
|
|
528
|
+
shellOf() + bodyHtml + closeRootHtml + tailHtml,
|
|
514
529
|
status,
|
|
515
530
|
headers,
|
|
516
531
|
),
|
|
@@ -521,7 +536,8 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
521
536
|
adapter,
|
|
522
537
|
chain,
|
|
523
538
|
renderProps,
|
|
524
|
-
|
|
539
|
+
shellOf,
|
|
540
|
+
parts,
|
|
525
541
|
closeRootHtml,
|
|
526
542
|
allDeferred,
|
|
527
543
|
tailHtml,
|
|
@@ -536,7 +552,8 @@ async function renderStreamedPage(
|
|
|
536
552
|
adapter: RenderAdapter,
|
|
537
553
|
chain: readonly unknown[],
|
|
538
554
|
renderProps: RenderProps,
|
|
539
|
-
|
|
555
|
+
shellOf: () => string,
|
|
556
|
+
parts: DocumentParts | undefined,
|
|
540
557
|
closeRootHtml: string,
|
|
541
558
|
allDeferred: ReadonlyArray<{ readonly id: number; readonly promise: Promise<unknown> }>,
|
|
542
559
|
tailHtml: string,
|
|
@@ -550,13 +567,20 @@ async function renderStreamedPage(
|
|
|
550
567
|
// (React: on-shell-ready; Solid: synchronously), so a shell-render throw surfaces before any byte is
|
|
551
568
|
// sent. A *mid*-stream failure errors the body instead.
|
|
552
569
|
const enc = TEXT_ENCODER
|
|
553
|
-
const shell = enc.encode(shellHtml)
|
|
554
570
|
const closeRoot = enc.encode(closeRootHtml)
|
|
555
571
|
const tail = enc.encode(tailHtml)
|
|
556
572
|
const appStream =
|
|
557
|
-
|
|
558
|
-
? await adapter.renderToStream(
|
|
559
|
-
|
|
573
|
+
parts !== undefined
|
|
574
|
+
? await adapter.renderToStream(
|
|
575
|
+
chain,
|
|
576
|
+
renderProps,
|
|
577
|
+
nonce === undefined ? { parts } : { nonce, parts },
|
|
578
|
+
)
|
|
579
|
+
: nonce === undefined
|
|
580
|
+
? await adapter.renderToStream(chain, renderProps)
|
|
581
|
+
: await adapter.renderToStream(chain, renderProps, { nonce })
|
|
582
|
+
// After the render: an adapter fills its document parts before handing back the stream.
|
|
583
|
+
const shell = enc.encode(shellOf())
|
|
560
584
|
const body = streamDocument(shell, appStream, closeRoot, allDeferred, tail, enc, nonceAttr)
|
|
561
585
|
return new Response(body, { status, headers })
|
|
562
586
|
}
|
|
@@ -1123,68 +1147,6 @@ function headTags(head: Meta | undefined, documentNonce?: string): string {
|
|
|
1123
1147
|
return out
|
|
1124
1148
|
}
|
|
1125
1149
|
|
|
1126
|
-
/**
|
|
1127
|
-
* A `<link rel="canonical">` descriptor for a route's `meta.link`. The canonical URL tells search
|
|
1128
|
-
* engines which URL is authoritative for a page (deduping query-string / tracking variants).
|
|
1129
|
-
*
|
|
1130
|
-
* ```ts
|
|
1131
|
-
* export const meta = (a) => ({ link: [canonical(`https://site.com/posts/${a.params.slug}`)] })
|
|
1132
|
-
* ```
|
|
1133
|
-
*/
|
|
1134
|
-
export function canonical(href: string): LinkDescriptor {
|
|
1135
|
-
return { rel: "canonical", href }
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
/** Inputs for {@link openGraph} - the common Open Graph properties. All optional; only the provided
|
|
1139
|
-
* ones become tags. `type` defaults to `"website"`. */
|
|
1140
|
-
export interface OpenGraphInput {
|
|
1141
|
-
readonly title?: string
|
|
1142
|
-
readonly description?: string
|
|
1143
|
-
/** Absolute URL of the share image (`og:image`). */
|
|
1144
|
-
readonly image?: string
|
|
1145
|
-
/** Canonical URL of the page (`og:url`). */
|
|
1146
|
-
readonly url?: string
|
|
1147
|
-
/** Object type (`og:type`) - `"website"`, `"article"`, … Default `"website"`. */
|
|
1148
|
-
readonly type?: string
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
/**
|
|
1152
|
-
* Build the Open Graph `<meta property="og:*">` entries for a route's `meta.meta`. Returns only the
|
|
1153
|
-
* properties you supplied (plus `og:type`, defaulting to `"website"`), so it composes with other meta.
|
|
1154
|
-
*
|
|
1155
|
-
* ```ts
|
|
1156
|
-
* export const meta = { meta: [...openGraph({ title: "Nifra", image: "https://site.com/og.png" })] }
|
|
1157
|
-
* ```
|
|
1158
|
-
*/
|
|
1159
|
-
export function openGraph(input: OpenGraphInput): MetaDescriptor[] {
|
|
1160
|
-
const tags: MetaDescriptor[] = []
|
|
1161
|
-
const add = (property: string, content: string | undefined): void => {
|
|
1162
|
-
if (content !== undefined) tags.push({ property, content })
|
|
1163
|
-
}
|
|
1164
|
-
add("og:title", input.title)
|
|
1165
|
-
add("og:description", input.description)
|
|
1166
|
-
add("og:image", input.image)
|
|
1167
|
-
add("og:url", input.url)
|
|
1168
|
-
// `og:type` always present (the spec's required property) - default "website" when unset.
|
|
1169
|
-
tags.push({ property: "og:type", content: input.type ?? "website" })
|
|
1170
|
-
return tags
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
/**
|
|
1174
|
-
* Build a JSON-LD `<script type="application/ld+json">` entry for a route's `meta.script` from a plain
|
|
1175
|
-
* object. `JSON.stringify` produces the body; the head renderer breakout-escapes it (see
|
|
1176
|
-
* `escapeScriptContent`), so a string field containing `</script>` is embedded safely.
|
|
1177
|
-
*
|
|
1178
|
-
* ```ts
|
|
1179
|
-
* export const meta = {
|
|
1180
|
-
* script: [jsonLd({ "@context": "https://schema.org", "@type": "Article", headline: "Hi" })],
|
|
1181
|
-
* }
|
|
1182
|
-
* ```
|
|
1183
|
-
*/
|
|
1184
|
-
export function jsonLd(data: Record<string, unknown>): ScriptDescriptor {
|
|
1185
|
-
return { type: "application/ld+json", content: JSON.stringify(data) }
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
1150
|
/**
|
|
1189
1151
|
* Deliberately unsafe escape hatch for executable inline code. The required nonce keeps the result
|
|
1190
1152
|
* compatible with a strict CSP and makes the security-sensitive choice visible at the call site.
|
|
@@ -6,3 +6,19 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export const requestPattern = (pattern: string): string =>
|
|
8
8
|
pattern.normalize("NFC").replace(/[^!-~]/gu, encodeURIComponent)
|
|
9
|
+
|
|
10
|
+
// This module also compiles without the DOM lib; at runtime the name is the browser's own global.
|
|
11
|
+
declare const location: { readonly origin: string } | undefined
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* An app path as a URL `fetch`, `history` and `location` resolve on this document's own origin. A path
|
|
15
|
+
* starting `//` or `/\` is the path of a page here (`https://shop.example//evil.example/x`), but resolved
|
|
16
|
+
* as written it is a protocol-relative URL to another host, so it is anchored to this origin.
|
|
17
|
+
*/
|
|
18
|
+
export const appUrl = (path: string): string =>
|
|
19
|
+
path.charCodeAt(0) === 47 /* / */ &&
|
|
20
|
+
(path.charCodeAt(1) === 47 || path.charCodeAt(1) === 92) /* / or \ */ &&
|
|
21
|
+
typeof location !== "undefined" &&
|
|
22
|
+
location.origin !== "null"
|
|
23
|
+
? location.origin + path
|
|
24
|
+
: path
|
|
@@ -293,6 +293,98 @@ function formatMatches(text: string): Match[] {
|
|
|
293
293
|
return matches
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
/** Words that mark a whole `_`/`-` segment of a value as a stand-in: `your_signing_secret`. */
|
|
297
|
+
const PLACEHOLDER_SEGMENT =
|
|
298
|
+
/^(?:your|example|placeholder|sample|dummy|changeme|replace|generated|x{3,})$/
|
|
299
|
+
|
|
300
|
+
const placeholderSegments = (value: string): boolean =>
|
|
301
|
+
value
|
|
302
|
+
.toLowerCase()
|
|
303
|
+
.split(/[_-]/)
|
|
304
|
+
.some((segment) => PLACEHOLDER_SEGMENT.test(segment))
|
|
305
|
+
|
|
306
|
+
/** A word-cased label: letters and marks, hyphen-joined words, an acronym. No digits, no runs of
|
|
307
|
+
* mixed case, no credential punctuation. */
|
|
308
|
+
const LABEL = /^(?:\p{Lu}{1,4}|\p{Lu}?[\p{Ll}\p{M}]+)(?:-(?:\p{Lu}{1,4}|\p{Lu}?[\p{Ll}\p{M}]+))*$/u
|
|
309
|
+
|
|
310
|
+
/** How many string values a catalog holds at least, and the share of them that is prose. */
|
|
311
|
+
const CATALOG_VALUES = 10
|
|
312
|
+
|
|
313
|
+
/** Where a `/` starts a regular expression: after anything but a value's end. */
|
|
314
|
+
const VALUE_END = /[\w$)\]}"'`]/
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The spans of top-level object literals that read as message catalogs: at least
|
|
318
|
+
* {@link CATALOG_VALUES} string property values, most of them prose with whitespace. A lexer, not a
|
|
319
|
+
* parser, so a build scans without TypeScript; strings, comments and regular expressions are skipped.
|
|
320
|
+
*/
|
|
321
|
+
function catalogSpans(text: string): Array<readonly [number, number]> {
|
|
322
|
+
const spans: Array<readonly [number, number]> = []
|
|
323
|
+
let depth = 0
|
|
324
|
+
let start = 0
|
|
325
|
+
let values = 0
|
|
326
|
+
let prose = 0
|
|
327
|
+
// Whether the open top-level brace starts an object literal, not a block such as a function body.
|
|
328
|
+
let literal = false
|
|
329
|
+
let last = ""
|
|
330
|
+
let i = 0
|
|
331
|
+
while (i < text.length) {
|
|
332
|
+
const c = text.charAt(i)
|
|
333
|
+
if (c === "/" && text[i + 1] === "/") {
|
|
334
|
+
const end = text.indexOf("\n", i)
|
|
335
|
+
i = end === -1 ? text.length : end
|
|
336
|
+
continue
|
|
337
|
+
}
|
|
338
|
+
if (c === "/" && text[i + 1] === "*") {
|
|
339
|
+
const end = text.indexOf("*/", i + 2)
|
|
340
|
+
i = end === -1 ? text.length : end + 2
|
|
341
|
+
continue
|
|
342
|
+
}
|
|
343
|
+
if (c === '"' || c === "'" || c === "`" || (c === "/" && !VALUE_END.test(last))) {
|
|
344
|
+
let j = i + 1
|
|
345
|
+
let inClass = false
|
|
346
|
+
for (; j < text.length; j++) {
|
|
347
|
+
const d = text[j]
|
|
348
|
+
if (d === "\\") {
|
|
349
|
+
j++
|
|
350
|
+
continue
|
|
351
|
+
}
|
|
352
|
+
if (c !== "`" && d === "\n") break
|
|
353
|
+
if (c !== "/") {
|
|
354
|
+
if (d === c) break
|
|
355
|
+
continue
|
|
356
|
+
}
|
|
357
|
+
if (d === "[") inClass = true
|
|
358
|
+
else if (d === "]") inClass = false
|
|
359
|
+
else if (d === "/" && !inClass) break
|
|
360
|
+
}
|
|
361
|
+
// A string after `:` is a property's value.
|
|
362
|
+
if (depth > 0 && c !== "/" && c !== "`" && last === ":") {
|
|
363
|
+
values++
|
|
364
|
+
if (/\s/.test(text.slice(i + 1, j))) prose++
|
|
365
|
+
}
|
|
366
|
+
last = c
|
|
367
|
+
i = j + 1
|
|
368
|
+
continue
|
|
369
|
+
}
|
|
370
|
+
if (c === "{") {
|
|
371
|
+
if (depth === 0) {
|
|
372
|
+
start = i
|
|
373
|
+
values = 0
|
|
374
|
+
prose = 0
|
|
375
|
+
const word = /([\w$]+)\s*$/.exec(text.slice(Math.max(0, i - 16), i))?.[1]
|
|
376
|
+
literal = last === "" || "=(,:[?".includes(last) || word === "default"
|
|
377
|
+
}
|
|
378
|
+
depth++
|
|
379
|
+
} else if (c === "}" && depth > 0 && --depth === 0) {
|
|
380
|
+
if (literal && values >= CATALOG_VALUES && prose * 2 > values) spans.push([start, i + 1])
|
|
381
|
+
}
|
|
382
|
+
if (!/\s/.test(c)) last = c
|
|
383
|
+
i++
|
|
384
|
+
}
|
|
385
|
+
return spans
|
|
386
|
+
}
|
|
387
|
+
|
|
296
388
|
/** The credential a literal given to `name` carries, when the name is secret-like and the literal
|
|
297
389
|
* looks random. */
|
|
298
390
|
function credentialOf(name: string, value: string): string | undefined {
|
|
@@ -304,6 +396,7 @@ function credentialOf(name: string, value: string): string | undefined {
|
|
|
304
396
|
secret === undefined ||
|
|
305
397
|
PUBLIC_VALUE.test(secret) ||
|
|
306
398
|
PLACEHOLDER.test(secret) ||
|
|
399
|
+
placeholderSegments(secret) ||
|
|
307
400
|
!looksRandom(secret, 16)
|
|
308
401
|
)
|
|
309
402
|
return undefined
|
|
@@ -312,9 +405,16 @@ function credentialOf(name: string, value: string): string | undefined {
|
|
|
312
405
|
|
|
313
406
|
function assignedMatches(text: string): Match[] {
|
|
314
407
|
const matches: Match[] = []
|
|
408
|
+
let catalogs: ReadonlyArray<readonly [number, number]> | undefined
|
|
409
|
+
/** A label in a message catalog: `password: "Lykilorð"` names the field in another language. */
|
|
410
|
+
const catalogLabel = (value: string, valueAt: number): boolean => {
|
|
411
|
+
if (!LABEL.test(value)) return false
|
|
412
|
+
catalogs ??= catalogSpans(text)
|
|
413
|
+
return catalogs.some(([start, end]) => valueAt > start && valueAt < end)
|
|
414
|
+
}
|
|
315
415
|
const add = (name: string, value: string, valueAt: number): void => {
|
|
316
416
|
const secret = credentialOf(name, value)
|
|
317
|
-
if (secret === undefined) return
|
|
417
|
+
if (secret === undefined || catalogLabel(value, valueAt)) return
|
|
318
418
|
matches.push({
|
|
319
419
|
rule: "assigned-secret",
|
|
320
420
|
// The credential is the value's tail: a header value's scheme is not part of it.
|
package/src/internal/web-app.ts
CHANGED
|
@@ -199,8 +199,10 @@ export interface CreateWebAppOptions<Env = unknown> {
|
|
|
199
199
|
* team - unreleased feature names, internal hostnames, and "don't touch X yet" notes live there
|
|
200
200
|
* routinely. Turn it on only for a repo whose guidelines you would publish as a page. */
|
|
201
201
|
readonly publishLocalGuidelines?: boolean
|
|
202
|
-
/** Serve `/llms.txt` and `/llms-full.txt
|
|
203
|
-
*
|
|
202
|
+
/** Serve `/llms.txt` and `/llms-full.txt`: the page routes, and the backend routes when the backend
|
|
203
|
+
* is mounted over HTTP at {@link apiPrefix}, with their request and response schemas in the full
|
|
204
|
+
* text. **Off by default**: both are public and unauthenticated, and they list every route, admin
|
|
205
|
+
* and internal ones included. Turn it on for an app whose whole surface you would publish. */
|
|
204
206
|
readonly llmsTxt?: boolean
|
|
205
207
|
/** SSG: per dynamic route pattern, its `getStaticPaths` `fallback` (from `enumerateStaticRoutes` or
|
|
206
208
|
* the build's `prerendered.json`). A route mapped to `"404"` rejects any path NOT in
|
|
@@ -458,8 +460,10 @@ export function createWebApp<Env = unknown>(
|
|
|
458
460
|
cacheChannel,
|
|
459
461
|
})
|
|
460
462
|
|
|
463
|
+
let ownsRootCatchAll = false
|
|
461
464
|
for (const route of manifest.routes) {
|
|
462
465
|
const pattern = requestPattern(route.pattern)
|
|
466
|
+
if (pattern.startsWith("/*")) ownsRootCatchAll = true
|
|
463
467
|
app.register(
|
|
464
468
|
"GET",
|
|
465
469
|
pattern,
|
|
@@ -473,7 +477,7 @@ export function createWebApp<Env = unknown>(
|
|
|
473
477
|
// llms.txt & llms-full.txt describe what a visitor can reach: the pages, and the backend only when it
|
|
474
478
|
// is served over HTTP - a backend the loaders call in-process has no public surface to describe. An
|
|
475
479
|
// app route at either path takes precedence, and each text is built once per app.
|
|
476
|
-
if (options.llmsTxt
|
|
480
|
+
if (options.llmsTxt === true) {
|
|
477
481
|
const llmsOptions = {
|
|
478
482
|
includeLocalGuidelines: options.publishLocalGuidelines === true,
|
|
479
483
|
apiPathPrefix: apiStrip ? mountPathPrefix(apiPrefix) : "",
|
|
@@ -518,8 +522,9 @@ export function createWebApp<Env = unknown>(
|
|
|
518
522
|
}
|
|
519
523
|
|
|
520
524
|
// Wildcard catch-all: unmatched paths render the nearest `_404` (404), or a plain text 404 if
|
|
521
|
-
// absent.
|
|
522
|
-
|
|
525
|
+
// absent. A root catch-all page (`[...slug].tsx`) already answers every path, the root included,
|
|
526
|
+
// and the router refuses a second `/*` under another param name.
|
|
527
|
+
if (!ownsRootCatchAll) app.register("GET", "/*", undefined, pageExecutor.fallback)
|
|
523
528
|
|
|
524
529
|
const evidenceProvider: ComposedEvidenceProvider = async (passed) => {
|
|
525
530
|
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}`)
|