@nifrajs/web 3.3.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.
Files changed (77) hide show
  1. package/dist/build-plan.d.ts +17 -0
  2. package/dist/build-plan.d.ts.map +1 -1
  3. package/dist/build-plan.js +7 -1
  4. package/dist/build-plan.js.map +1 -1
  5. package/dist/build-vite.d.ts +14 -0
  6. package/dist/build-vite.d.ts.map +1 -1
  7. package/dist/build-vite.js +29 -4
  8. package/dist/build-vite.js.map +1 -1
  9. package/dist/build.d.ts +15 -0
  10. package/dist/build.d.ts.map +1 -1
  11. package/dist/build.js +57 -15
  12. package/dist/build.js.map +1 -1
  13. package/dist/client.d.ts +18 -0
  14. package/dist/client.d.ts.map +1 -1
  15. package/dist/client.js +115 -0
  16. package/dist/client.js.map +1 -1
  17. package/dist/css-contract.d.ts +21 -0
  18. package/dist/css-contract.d.ts.map +1 -0
  19. package/dist/css-contract.js +28 -0
  20. package/dist/css-contract.js.map +1 -0
  21. package/dist/index.d.ts +3 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +2 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/internal/codegen.d.ts +3 -0
  26. package/dist/internal/codegen.d.ts.map +1 -1
  27. package/dist/internal/codegen.js +11 -6
  28. package/dist/internal/codegen.js.map +1 -1
  29. package/dist/internal/page-execution.d.ts +57 -0
  30. package/dist/internal/page-execution.d.ts.map +1 -0
  31. package/dist/internal/page-execution.js +555 -0
  32. package/dist/internal/page-execution.js.map +1 -0
  33. package/dist/internal/render-document.d.ts +12 -2
  34. package/dist/internal/render-document.d.ts.map +1 -1
  35. package/dist/internal/render-document.js +28 -5
  36. package/dist/internal/render-document.js.map +1 -1
  37. package/dist/internal/request-url.d.ts +3 -0
  38. package/dist/internal/request-url.d.ts.map +1 -0
  39. package/dist/internal/request-url.js +14 -0
  40. package/dist/internal/request-url.js.map +1 -0
  41. package/dist/internal/web-app.d.ts +20 -1
  42. package/dist/internal/web-app.d.ts.map +1 -1
  43. package/dist/internal/web-app.js +37 -734
  44. package/dist/internal/web-app.js.map +1 -1
  45. package/dist/isr.d.ts.map +1 -1
  46. package/dist/isr.js +39 -4
  47. package/dist/isr.js.map +1 -1
  48. package/dist/llms-txt.d.ts +3 -0
  49. package/dist/llms-txt.d.ts.map +1 -1
  50. package/dist/llms-txt.js +6 -3
  51. package/dist/llms-txt.js.map +1 -1
  52. package/dist/manifest.d.ts +1 -1
  53. package/dist/manifest.d.ts.map +1 -1
  54. package/dist/public-dir.d.ts.map +1 -1
  55. package/dist/public-dir.js +69 -44
  56. package/dist/public-dir.js.map +1 -1
  57. package/dist/trusted-html.d.ts +34 -0
  58. package/dist/trusted-html.d.ts.map +1 -0
  59. package/dist/trusted-html.js +35 -0
  60. package/dist/trusted-html.js.map +1 -0
  61. package/package.json +3 -3
  62. package/src/build-plan.ts +23 -1
  63. package/src/build-vite.ts +47 -4
  64. package/src/build.ts +69 -6
  65. package/src/client.ts +140 -0
  66. package/src/css-contract.ts +44 -0
  67. package/src/index.ts +15 -0
  68. package/src/internal/codegen.ts +20 -6
  69. package/src/internal/page-execution.ts +761 -0
  70. package/src/internal/render-document.ts +39 -6
  71. package/src/internal/request-url.ts +14 -0
  72. package/src/internal/web-app.ts +68 -835
  73. package/src/isr.ts +42 -3
  74. package/src/llms-txt.ts +16 -4
  75. package/src/manifest.ts +1 -1
  76. package/src/public-dir.ts +70 -45
  77. 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 (CACHEABLE_RESPONSE_HEADERS.has(key)) out[key] = value
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
- const normalized = key.toLowerCase()
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 { reflectRoutes } from "@nifrajs/core/reflection"
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: { readonly includeLocalGuidelines?: boolean } = {},
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 = reflectRoutes(backend)
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 [status, errorSchema] of Object.entries(s.errors)) {
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 | void | Promise<ClientActionResult | void>
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 { realpath } from "node:fs/promises"
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
- const file = Bun.file(resolvedFile)
133
- if (!(await file.exists())) return undefined
134
- const size = file.size
135
- const headers = new Headers({
136
- "cache-control": pathname.startsWith(hashedPrefix) ? hashed : assets,
137
- // Advertised unconditionally: a client that never sees `accept-ranges` will not attempt a seek,
138
- // so a video or audio file under `public/` is scrubbable only once this header is present.
139
- "accept-ranges": "bytes",
140
- })
141
- // `new Response(file)` used to infer the media type, which meant HEAD - built from a null body -
142
- // silently lost it. Setting it here is what makes the two methods agree.
143
- if (file.type !== "") headers.set("content-type", file.type)
144
- const lastModified =
145
- Number.isFinite(file.lastModified) && file.lastModified > 0 ? file.lastModified : undefined
146
- if (lastModified !== undefined) {
147
- headers.set("last-modified", new Date(lastModified).toUTCString())
148
- }
149
- const head = request.method === "HEAD"
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
- if (isNotModified(request, lastModified)) {
152
- headers.delete("content-type")
153
- return new Response(null, { status: 304, headers })
154
- }
161
+ if (isNotModified(request, lastModified)) {
162
+ headers.delete("content-type")
163
+ return new Response(null, { status: 304, headers })
164
+ }
155
165
 
156
- const rangeHeader = request.headers.get("range")
157
- const range =
158
- rangeHeader !== null && ifRangeMatches(request.headers.get("if-range"), lastModified)
159
- ? parseByteRange(rangeHeader, size)
160
- : ({ kind: "none" } as const)
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
- if (range.kind === "unsatisfiable") {
163
- headers.delete("content-type")
164
- headers.set("content-range", `bytes */${size}`)
165
- return new Response(null, { status: 416, headers })
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
- if (range.kind === "satisfiable" && range.ranges.length === 1) {
169
- const { start, end } = range.ranges[0]!
170
- headers.set("content-range", `bytes ${start}-${end}/${size}`)
171
- headers.set("content-length", String(end - start + 1))
172
- // `slice` keeps the read lazy: only the selected window is ever pulled off disk.
173
- return new Response(head ? null : file.slice(start, end + 1), { status: 206, headers })
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
- // Multiple ranges would require assembling `multipart/byteranges`, and doing that for a file on
177
- // disk means buffering the whole representation to serve a request that asked for less of it.
178
- // RFC 9110 lets a server ignore Range entirely, so the full body is the conformant answer here.
179
- if (head) headers.set("content-length", String(size))
180
- return new Response(head ? null : file, { headers })
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