@nifrajs/web 3.2.0 → 3.5.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-plan.d.ts +17 -0
- package/dist/build-plan.d.ts.map +1 -1
- package/dist/build-plan.js +7 -1
- package/dist/build-plan.js.map +1 -1
- package/dist/build-vite.d.ts +14 -0
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +29 -4
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +15 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +57 -15
- package/dist/build.js.map +1 -1
- package/dist/client.d.ts +18 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +115 -0
- package/dist/client.js.map +1 -1
- package/dist/css-contract.d.ts +21 -0
- package/dist/css-contract.d.ts.map +1 -0
- package/dist/css-contract.js +28 -0
- package/dist/css-contract.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/codegen.d.ts +3 -0
- package/dist/internal/codegen.d.ts.map +1 -1
- package/dist/internal/codegen.js +11 -6
- package/dist/internal/codegen.js.map +1 -1
- package/dist/internal/page-execution.d.ts +57 -0
- package/dist/internal/page-execution.d.ts.map +1 -0
- package/dist/internal/page-execution.js +555 -0
- package/dist/internal/page-execution.js.map +1 -0
- package/dist/internal/render-document.d.ts +12 -2
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +28 -5
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/request-url.d.ts +3 -0
- package/dist/internal/request-url.d.ts.map +1 -0
- package/dist/internal/request-url.js +14 -0
- package/dist/internal/request-url.js.map +1 -0
- package/dist/internal/web-app.d.ts +20 -1
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +37 -734
- package/dist/internal/web-app.js.map +1 -1
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +39 -4
- package/dist/isr.js.map +1 -1
- package/dist/llms-txt.d.ts +3 -0
- package/dist/llms-txt.d.ts.map +1 -1
- package/dist/llms-txt.js +6 -3
- package/dist/llms-txt.js.map +1 -1
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/public-dir.d.ts.map +1 -1
- package/dist/public-dir.js +69 -44
- package/dist/public-dir.js.map +1 -1
- package/dist/trusted-html.d.ts +34 -0
- package/dist/trusted-html.d.ts.map +1 -0
- package/dist/trusted-html.js +35 -0
- package/dist/trusted-html.js.map +1 -0
- package/package.json +3 -3
- package/src/build-plan.ts +23 -1
- package/src/build-vite.ts +47 -4
- package/src/build.ts +69 -6
- package/src/client.ts +140 -0
- package/src/css-contract.ts +44 -0
- package/src/index.ts +15 -0
- package/src/internal/codegen.ts +20 -6
- package/src/internal/page-execution.ts +761 -0
- package/src/internal/render-document.ts +39 -6
- package/src/internal/request-url.ts +14 -0
- package/src/internal/web-app.ts +68 -835
- package/src/isr.ts +42 -3
- package/src/llms-txt.ts +16 -4
- package/src/manifest.ts +1 -1
- package/src/public-dir.ts +70 -45
- package/src/trusted-html.ts +44 -0
package/src/isr.ts
CHANGED
|
@@ -439,12 +439,21 @@ const requestCarriesPrivateState = (req: Request): boolean =>
|
|
|
439
439
|
const responseIsExplicitlyPublic = (res: Response): boolean =>
|
|
440
440
|
cacheControlHas(res.headers, ["public"])
|
|
441
441
|
|
|
442
|
+
const responseDeclaresVary = (res: Response): boolean => {
|
|
443
|
+
const value = res.headers.get("vary")
|
|
444
|
+
return value !== null && value.trim() !== ""
|
|
445
|
+
}
|
|
446
|
+
|
|
442
447
|
const isCacheablePage = (req: Request, res: Response): boolean => {
|
|
443
448
|
if (req.method !== "GET") return false
|
|
444
449
|
if (req.headers.get("x-nifra-data") !== null) return false
|
|
445
450
|
if (res.status !== 200) return false
|
|
446
451
|
if (!(res.headers.get("content-type") ?? "").includes("text/html")) return false
|
|
447
452
|
if (hasSetCookie(res.headers)) return false
|
|
453
|
+
// The default ISR key is URL-only. A response that varies on request headers must either use a
|
|
454
|
+
// caller-supplied key that includes those headers or bypass ISR; otherwise one language/tenant
|
|
455
|
+
// representation can be served to every request for the URL.
|
|
456
|
+
if (responseDeclaresVary(res)) return false
|
|
448
457
|
if (cacheControlHas(res.headers, ["private", "no-store"])) return false
|
|
449
458
|
// Cookie/Authorization requests often personalize HTML without setting a new cookie. Cache them only
|
|
450
459
|
// when the route explicitly declares the response public.
|
|
@@ -452,22 +461,45 @@ const isCacheablePage = (req: Request, res: Response): boolean => {
|
|
|
452
461
|
return true
|
|
453
462
|
}
|
|
454
463
|
|
|
464
|
+
// Keep representation and security policy headers, plus `Vary` for legacy entries. Transport,
|
|
465
|
+
// request-specific, framework-control, and unknown internal headers stay out of the shared cache.
|
|
455
466
|
const CACHEABLE_RESPONSE_HEADERS = new Set([
|
|
456
467
|
"cache-control",
|
|
457
468
|
"content-language",
|
|
469
|
+
"content-security-policy",
|
|
458
470
|
"content-type",
|
|
471
|
+
"cross-origin-embedder-policy",
|
|
472
|
+
"cross-origin-opener-policy",
|
|
473
|
+
"cross-origin-resource-policy",
|
|
459
474
|
"etag",
|
|
460
475
|
"last-modified",
|
|
461
476
|
"link",
|
|
477
|
+
"origin-agent-cluster",
|
|
478
|
+
"permissions-policy",
|
|
479
|
+
"referrer-policy",
|
|
480
|
+
"strict-transport-security",
|
|
481
|
+
"vary",
|
|
482
|
+
"x-content-type-options",
|
|
483
|
+
"x-frame-options",
|
|
462
484
|
])
|
|
463
485
|
|
|
486
|
+
const isCacheableResponseHeader = (key: string): boolean => {
|
|
487
|
+
const normalized = key.toLowerCase()
|
|
488
|
+
return (
|
|
489
|
+
CACHEABLE_RESPONSE_HEADERS.has(normalized) ||
|
|
490
|
+
normalized.startsWith("content-security-policy-") ||
|
|
491
|
+
normalized.startsWith("cross-origin-") ||
|
|
492
|
+
normalized.startsWith("permissions-policy-")
|
|
493
|
+
)
|
|
494
|
+
}
|
|
495
|
+
|
|
464
496
|
const tagsOf = (res: Response): readonly string[] =>
|
|
465
497
|
tagsFromHeader(res.headers.get(ISR_REVALIDATE_TAGS_HEADER))
|
|
466
498
|
|
|
467
499
|
const headersOf = (res: Response): Record<string, string> => {
|
|
468
500
|
const out: Record<string, string> = {}
|
|
469
501
|
res.headers.forEach((value, key) => {
|
|
470
|
-
if (
|
|
502
|
+
if (isCacheableResponseHeader(key)) out[key] = value
|
|
471
503
|
})
|
|
472
504
|
return out
|
|
473
505
|
}
|
|
@@ -481,12 +513,14 @@ const responseFrom = (entry: CachedResponse, status: "hit" | "stale"): Response
|
|
|
481
513
|
function sanitizeCachedHeaders(headers: Readonly<Record<string, string>>): Record<string, string> {
|
|
482
514
|
const out: Record<string, string> = {}
|
|
483
515
|
for (const [key, value] of Object.entries(headers)) {
|
|
484
|
-
|
|
485
|
-
if (CACHEABLE_RESPONSE_HEADERS.has(normalized)) out[normalized] = value
|
|
516
|
+
if (isCacheableResponseHeader(key)) out[key.toLowerCase()] = value
|
|
486
517
|
}
|
|
487
518
|
return out
|
|
488
519
|
}
|
|
489
520
|
|
|
521
|
+
const isInvalidationResponse = (res: Response): boolean =>
|
|
522
|
+
res.status === 404 || res.status === 410 || (res.status >= 300 && res.status < 400)
|
|
523
|
+
|
|
490
524
|
/**
|
|
491
525
|
* Wrap a nifra app with **Incremental Static Regeneration**: a cacheable page is served from
|
|
492
526
|
* {@link CacheStore} when fresh, served **stale while a fresh copy regenerates in the background**
|
|
@@ -561,6 +595,11 @@ export function withISR(
|
|
|
561
595
|
revalidate: ttlMs(res),
|
|
562
596
|
...(tags.length === 0 ? {} : { tags }),
|
|
563
597
|
})
|
|
598
|
+
} else if (isInvalidationResponse(res)) {
|
|
599
|
+
// A successful deletion or redirect is authoritative. Keeping the old entry would make a
|
|
600
|
+
// withdrawn page stay publicly visible forever because every stale request would retry the same
|
|
601
|
+
// non-cacheable response while continuing to serve the old body.
|
|
602
|
+
await store.delete(key)
|
|
564
603
|
} else {
|
|
565
604
|
await res.body?.cancel()
|
|
566
605
|
}
|
package/src/llms-txt.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type ProjectEvidenceSnapshot,
|
|
3
|
+
reflectedRoutesFromEvidence,
|
|
4
|
+
snapshotProjectEvidence,
|
|
5
|
+
} from "@nifrajs/core/evidence"
|
|
2
6
|
|
|
3
7
|
const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
|
4
8
|
|
|
@@ -102,7 +106,11 @@ export async function generateLlmsTxt(
|
|
|
102
106
|
full: boolean,
|
|
103
107
|
pageRoutes: ReadonlyArray<{ readonly pattern: string; readonly id: string }>,
|
|
104
108
|
backend: unknown,
|
|
105
|
-
options: {
|
|
109
|
+
options: {
|
|
110
|
+
readonly includeLocalGuidelines?: boolean
|
|
111
|
+
/** Reuse the project's canonical route-evidence pass when one is already available. */
|
|
112
|
+
readonly evidence?: ProjectEvidenceSnapshot
|
|
113
|
+
} = {},
|
|
106
114
|
): Promise<string> {
|
|
107
115
|
let output = ""
|
|
108
116
|
output += `# Nifra App Context\n\n`
|
|
@@ -127,7 +135,9 @@ export async function generateLlmsTxt(
|
|
|
127
135
|
|
|
128
136
|
// 3. API Routes
|
|
129
137
|
output += `## API Routes\n\n`
|
|
130
|
-
const apiRoutes =
|
|
138
|
+
const apiRoutes = reflectedRoutesFromEvidence(
|
|
139
|
+
options.evidence ?? snapshotProjectEvidence(backend),
|
|
140
|
+
)
|
|
131
141
|
|
|
132
142
|
if (apiRoutes.length === 0) {
|
|
133
143
|
output += `No API routes registered.\n`
|
|
@@ -151,7 +161,9 @@ export async function generateLlmsTxt(
|
|
|
151
161
|
output += ` - Response Schema: \`${tsTypeOf(responseSchema)}\`\n`
|
|
152
162
|
}
|
|
153
163
|
if (s?.errors) {
|
|
154
|
-
for (const
|
|
164
|
+
for (const status of Object.keys(s.errors)) {
|
|
165
|
+
const errorSchema = s.errors[status]
|
|
166
|
+
if (errorSchema === undefined) continue
|
|
155
167
|
const errJson = errorSchema.jsonSchema
|
|
156
168
|
if (errJson !== undefined) {
|
|
157
169
|
output += ` - Error ${status} Schema: \`${tsTypeOf(errJson)}\`\n`
|
package/src/manifest.ts
CHANGED
|
@@ -66,7 +66,7 @@ export interface ClientActionResult {
|
|
|
66
66
|
/** A client-only action wrapper; it never replaces the server action. */
|
|
67
67
|
export type ClientAction = (
|
|
68
68
|
args: ClientActionArgs,
|
|
69
|
-
) => ClientActionResult |
|
|
69
|
+
) => ClientActionResult | undefined | Promise<ClientActionResult | undefined>
|
|
70
70
|
|
|
71
71
|
/** Client hooks populated by the generated route entry after a route chunk loads. */
|
|
72
72
|
export interface ClientRouteHooks {
|
package/src/public-dir.ts
CHANGED
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
* Distinct from `publicPath` in `build.ts`, which is the URL prefix for content-hashed bundle chunks.
|
|
13
13
|
* The names collide and the concepts do not: `publicPath` never covers user-authored files.
|
|
14
14
|
*/
|
|
15
|
-
import {
|
|
15
|
+
import { constants as FS } from "node:fs"
|
|
16
|
+
import { open, realpath } from "node:fs/promises"
|
|
16
17
|
import { normalize, resolve, sep } from "node:path"
|
|
18
|
+
import { Readable } from "node:stream"
|
|
17
19
|
import { parseByteRange } from "@nifrajs/core/range"
|
|
18
20
|
import { pathnameOf } from "@nifrajs/core/server"
|
|
19
21
|
|
|
@@ -129,54 +131,77 @@ export function servePublicDir(
|
|
|
129
131
|
) {
|
|
130
132
|
return undefined
|
|
131
133
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
134
|
+
// Open the canonical path once and stream from that descriptor. Reopening the pathname after
|
|
135
|
+
// realpath containment checks would let a writable public tree swap a symlink between validation
|
|
136
|
+
// and the actual read. O_NOFOLLOW also rejects a final-component symlink if one appears before
|
|
137
|
+
// the open; the descriptor then pins the bytes for the lifetime of the response.
|
|
138
|
+
let handle: Awaited<ReturnType<typeof open>> | undefined
|
|
139
|
+
try {
|
|
140
|
+
handle = await open(resolvedFile, FS.O_RDONLY | FS.O_NOFOLLOW)
|
|
141
|
+
const stat = await handle.stat()
|
|
142
|
+
if (!stat.isFile()) return undefined
|
|
143
|
+
const size = stat.size
|
|
144
|
+
const file = Bun.file(resolvedFile)
|
|
145
|
+
const headers = new Headers({
|
|
146
|
+
"cache-control": pathname.startsWith(hashedPrefix) ? hashed : assets,
|
|
147
|
+
// Advertised unconditionally: a client that never sees `accept-ranges` will not attempt a seek,
|
|
148
|
+
// so a video or audio file under `public/` is scrubbable only once this header is present.
|
|
149
|
+
"accept-ranges": "bytes",
|
|
150
|
+
})
|
|
151
|
+
// `new Response(file)` used to infer the media type, which meant HEAD - built from a null body -
|
|
152
|
+
// silently lost it. Setting it here is what makes the two methods agree.
|
|
153
|
+
if (file.type !== "") headers.set("content-type", file.type)
|
|
154
|
+
const lastModified =
|
|
155
|
+
Number.isFinite(stat.mtimeMs) && stat.mtimeMs > 0 ? stat.mtimeMs : undefined
|
|
156
|
+
if (lastModified !== undefined) {
|
|
157
|
+
headers.set("last-modified", new Date(lastModified).toUTCString())
|
|
158
|
+
}
|
|
159
|
+
const head = request.method === "HEAD"
|
|
150
160
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
161
|
+
if (isNotModified(request, lastModified)) {
|
|
162
|
+
headers.delete("content-type")
|
|
163
|
+
return new Response(null, { status: 304, headers })
|
|
164
|
+
}
|
|
155
165
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
166
|
+
const rangeHeader = request.headers.get("range")
|
|
167
|
+
const range =
|
|
168
|
+
rangeHeader !== null && ifRangeMatches(request.headers.get("if-range"), lastModified)
|
|
169
|
+
? parseByteRange(rangeHeader, size)
|
|
170
|
+
: ({ kind: "none" } as const)
|
|
161
171
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
172
|
+
if (range.kind === "unsatisfiable") {
|
|
173
|
+
headers.delete("content-type")
|
|
174
|
+
headers.set("content-range", `bytes */${size}`)
|
|
175
|
+
return new Response(null, { status: 416, headers })
|
|
176
|
+
}
|
|
167
177
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
178
|
+
const body = (start?: number, end?: number): ReadableStream<Uint8Array> => {
|
|
179
|
+
const stream = handle!.createReadStream({ start, end })
|
|
180
|
+
// FileHandle.createReadStream owns closing the descriptor after EOF/abort.
|
|
181
|
+
handle = undefined
|
|
182
|
+
return Readable.toWeb(stream) as unknown as ReadableStream<Uint8Array>
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (range.kind === "satisfiable" && range.ranges.length === 1) {
|
|
186
|
+
const { start, end } = range.ranges[0]!
|
|
187
|
+
headers.set("content-range", `bytes ${start}-${end}/${size}`)
|
|
188
|
+
headers.set("content-length", String(end - start + 1))
|
|
189
|
+
return new Response(head ? null : body(start, end), { status: 206, headers })
|
|
190
|
+
}
|
|
175
191
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
192
|
+
// Multiple ranges would require assembling `multipart/byteranges`, and doing that for a file on
|
|
193
|
+
// disk means buffering the whole representation to serve a request that asked for less of it.
|
|
194
|
+
// RFC 9110 lets a server ignore Range entirely, so the full body is the conformant answer here.
|
|
195
|
+
headers.set("content-length", String(size))
|
|
196
|
+
return new Response(head ? null : body(), { headers })
|
|
197
|
+
} finally {
|
|
198
|
+
if (handle !== undefined) {
|
|
199
|
+
try {
|
|
200
|
+
await handle.close()
|
|
201
|
+
} catch {
|
|
202
|
+
// Closing a descriptor is best-effort after the response decision is made.
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
181
206
|
}
|
|
182
207
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A framework-neutral trust boundary for the small set of adapters that intentionally inject HTML.
|
|
3
|
+
*
|
|
4
|
+
* `TrustedHtml` is a type-level brand, not a sanitizer. Callers must sanitize untrusted input with a
|
|
5
|
+
* well-reviewed allowlist sanitizer before calling {@link trustHtml}; build-time markdown and
|
|
6
|
+
* application-owned templates may use it directly after review. Keeping the constructor explicit
|
|
7
|
+
* makes every raw-HTML sink audit-greppable and prevents an ordinary string from type-checking by
|
|
8
|
+
* accident.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
declare const TRUSTED_HTML_BRAND: unique symbol
|
|
12
|
+
declare const SANITIZED_HTML_BRAND: unique symbol
|
|
13
|
+
|
|
14
|
+
export type TrustedHtml = string & { readonly [TRUSTED_HTML_BRAND]: "trusted-html" }
|
|
15
|
+
|
|
16
|
+
/** HTML returned by an explicit, caller-supplied sanitizer. It is also trusted by the adapters. */
|
|
17
|
+
export type SanitizedHtml = TrustedHtml & { readonly [SANITIZED_HTML_BRAND]: "sanitized-html" }
|
|
18
|
+
|
|
19
|
+
/** The only contract a project-specific, allowlist-based HTML sanitizer must satisfy. */
|
|
20
|
+
export type HtmlSanitizer = (value: string) => string
|
|
21
|
+
|
|
22
|
+
/** Mark already-sanitized or application-owned HTML for an intentional raw-HTML adapter sink. */
|
|
23
|
+
export function trustHtml(value: string): TrustedHtml {
|
|
24
|
+
if (typeof value !== "string") throw new TypeError("trusted HTML must be a string")
|
|
25
|
+
return value as TrustedHtml
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Run untrusted markup through an explicit sanitizer before it reaches a raw-HTML adapter.
|
|
30
|
+
*
|
|
31
|
+
* Nifra deliberately does not bundle a sanitizer: applications choose the maintained allowlist
|
|
32
|
+
* appropriate to their runtime and content policy. Requiring the sanitizer as an argument prevents
|
|
33
|
+
* `sanitizedHtml(value)` from silently becoming an alias for the unsafe trust operation.
|
|
34
|
+
*/
|
|
35
|
+
export function sanitizeHtml(value: string, sanitizer: HtmlSanitizer): SanitizedHtml {
|
|
36
|
+
if (typeof value !== "string") throw new TypeError("HTML to sanitize must be a string")
|
|
37
|
+
if (typeof sanitizer !== "function") throw new TypeError("an HTML sanitizer is required")
|
|
38
|
+
const sanitized = sanitizer(value)
|
|
39
|
+
if (typeof sanitized !== "string") throw new TypeError("HTML sanitizer must return a string")
|
|
40
|
+
return sanitized as SanitizedHtml
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Explicit sanitized-content vocabulary for callers and security audits. */
|
|
44
|
+
export const sanitizedHtml = sanitizeHtml
|