@nifrajs/web 2.9.1 → 2.11.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 (62) hide show
  1. package/dist/client.d.ts +12 -0
  2. package/dist/client.d.ts.map +1 -1
  3. package/dist/client.js +16 -0
  4. package/dist/client.js.map +1 -1
  5. package/dist/dev-ssr-graph.d.ts +33 -0
  6. package/dist/dev-ssr-graph.d.ts.map +1 -0
  7. package/dist/dev-ssr-graph.js +285 -0
  8. package/dist/dev-ssr-graph.js.map +1 -0
  9. package/dist/dev.d.ts.map +1 -1
  10. package/dist/dev.js +76 -18
  11. package/dist/dev.js.map +1 -1
  12. package/dist/index.d.ts +28 -25
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +53 -79
  15. package/dist/index.js.map +1 -1
  16. package/dist/internal/head-merge.d.ts +27 -0
  17. package/dist/internal/head-merge.d.ts.map +1 -0
  18. package/dist/internal/head-merge.js +65 -0
  19. package/dist/internal/head-merge.js.map +1 -0
  20. package/dist/internal/server-boundary.d.ts +1 -2
  21. package/dist/internal/server-boundary.d.ts.map +1 -1
  22. package/dist/internal/server-boundary.js +1 -2
  23. package/dist/internal/server-boundary.js.map +1 -1
  24. package/dist/isr.d.ts +32 -3
  25. package/dist/isr.d.ts.map +1 -1
  26. package/dist/isr.js +181 -16
  27. package/dist/isr.js.map +1 -1
  28. package/dist/manifest.d.ts +2 -0
  29. package/dist/manifest.d.ts.map +1 -1
  30. package/dist/manifest.js.map +1 -1
  31. package/dist/plugins/css-modules.d.ts +12 -0
  32. package/dist/plugins/css-modules.d.ts.map +1 -1
  33. package/dist/plugins/css-modules.js +0 -0
  34. package/dist/plugins/css-modules.js.map +1 -1
  35. package/dist/plugins/kit.d.ts +54 -0
  36. package/dist/plugins/kit.d.ts.map +1 -1
  37. package/dist/plugins/kit.js +66 -0
  38. package/dist/plugins/kit.js.map +1 -1
  39. package/dist/public-dir.d.ts.map +1 -1
  40. package/dist/public-dir.js +71 -4
  41. package/dist/public-dir.js.map +1 -1
  42. package/dist/route-manifest.d.ts +3 -1
  43. package/dist/route-manifest.d.ts.map +1 -1
  44. package/dist/route-manifest.js +3 -0
  45. package/dist/route-manifest.js.map +1 -1
  46. package/dist/vite.d.ts.map +1 -1
  47. package/dist/vite.js +27 -1
  48. package/dist/vite.js.map +1 -1
  49. package/package.json +3 -3
  50. package/src/client.ts +28 -0
  51. package/src/dev-ssr-graph.ts +311 -0
  52. package/src/dev.ts +81 -20
  53. package/src/index.ts +63 -71
  54. package/src/internal/head-merge.ts +80 -0
  55. package/src/internal/server-boundary.ts +1 -2
  56. package/src/isr.ts +216 -19
  57. package/src/manifest.ts +2 -0
  58. package/src/plugins/css-modules.ts +0 -0
  59. package/src/plugins/kit.ts +71 -0
  60. package/src/public-dir.ts +74 -4
  61. package/src/route-manifest.ts +10 -1
  62. package/src/vite.ts +29 -1
package/src/isr.ts CHANGED
@@ -20,6 +20,8 @@ export interface CachedResponse {
20
20
  readonly storedAt: number
21
21
  /** Freshness window (ms): `now - storedAt >= revalidate` ⇒ stale (serve it, regenerate behind it). */
22
22
  readonly revalidate: number
23
+ /** Public, bounded invalidation labels. A tag purge makes matching entries miss immediately. */
24
+ readonly tags?: readonly string[]
23
25
  }
24
26
 
25
27
  /**
@@ -35,6 +37,45 @@ export interface CacheStore {
35
37
  set(key: string, value: CachedResponse): Promise<void>
36
38
  /** Drop `key` (on-demand revalidation / purge). A no-op if absent. */
37
39
  delete(key: string): Promise<void>
40
+ /** Drop every cached response carrying `tag`, when the backend supports tag invalidation. */
41
+ readonly invalidateTag?: (tag: string) => Promise<void>
42
+ }
43
+
44
+ const ISR_TAG_PATTERN = /^[A-Za-z][A-Za-z0-9._:/-]{0,127}$/
45
+ const MAX_ISR_TAGS = 32
46
+
47
+ function normalizeTag(tag: string): string {
48
+ if (typeof tag !== "string" || !ISR_TAG_PATTERN.test(tag)) {
49
+ throw new TypeError("[nifra/web] ISR tag must be a bounded token")
50
+ }
51
+ return tag
52
+ }
53
+
54
+ function normalizeTags(tags: readonly string[] | undefined): readonly string[] {
55
+ if (tags === undefined) return []
56
+ if (!Array.isArray(tags) || tags.length > MAX_ISR_TAGS) {
57
+ throw new TypeError("[nifra/web] ISR tags must contain at most 32 bounded tokens")
58
+ }
59
+ const unique = new Set<string>()
60
+ for (const tag of tags) unique.add(normalizeTag(tag))
61
+ return Object.freeze([...unique])
62
+ }
63
+
64
+ function tagsFromHeader(value: string | null): readonly string[] {
65
+ if (value === null || value.trim() === "") return []
66
+ try {
67
+ return normalizeTags(value.split(",").map((tag) => tag.trim()))
68
+ } catch {
69
+ // A malformed response-controlled header must never accidentally create an unpurgeable cache
70
+ // entry. Treat it as untagged; route declarations are validated before they reach this path.
71
+ return []
72
+ }
73
+ }
74
+
75
+ /** Validate and serialize route-declared ISR tags for the internal response header. */
76
+ export function serializeISRTags(tags: readonly string[]): string | undefined {
77
+ const normalized = normalizeTags(tags)
78
+ return normalized.length === 0 ? undefined : normalized.join(",")
38
79
  }
39
80
 
40
81
  export interface MemoryCacheStoreOptions {
@@ -52,8 +93,12 @@ export interface MemoryCacheStoreOptions {
52
93
  * page survives a burst of new pages).
53
94
  */
54
95
  export class MemoryCacheStore implements CacheStore {
55
- private readonly cache = new Map<string, CachedResponse>()
96
+ private readonly cache = new Map<
97
+ string,
98
+ { readonly value: CachedResponse; readonly tagEpochs: Readonly<Record<string, string>> }
99
+ >()
56
100
  private readonly max: number
101
+ private readonly tagEpoch = new Map<string, string>()
57
102
 
58
103
  constructor(options: MemoryCacheStoreOptions = {}) {
59
104
  const isProd = typeof process !== "undefined" && process.env?.NODE_ENV === "production"
@@ -71,31 +116,75 @@ export class MemoryCacheStore implements CacheStore {
71
116
  }
72
117
 
73
118
  get(key: string): Promise<CachedResponse | undefined> {
74
- const value = this.cache.get(key)
119
+ const row = this.cache.get(key)
120
+ if (row !== undefined) {
121
+ for (const [tag, epoch] of Object.entries(row.tagEpochs)) {
122
+ if ((this.tagEpoch.get(tag) ?? "0") !== epoch) {
123
+ this.cache.delete(key)
124
+ this.pruneTagEpochs()
125
+ return Promise.resolve(undefined)
126
+ }
127
+ }
128
+ }
129
+ const value = row?.value
75
130
  // LRU: a read bumps the entry to the tail so the bounded evict drops the LEAST-RECENTLY-USED,
76
131
  // not the oldest-written - otherwise a burst of new pages would evict a hot, frequently-read one.
77
132
  if (value !== undefined) {
78
133
  this.cache.delete(key)
79
- this.cache.set(key, value)
134
+ this.cache.set(
135
+ key,
136
+ row as {
137
+ readonly value: CachedResponse
138
+ readonly tagEpochs: Readonly<Record<string, string>>
139
+ },
140
+ )
80
141
  }
81
142
  return Promise.resolve(value)
82
143
  }
83
144
 
84
145
  set(key: string, value: CachedResponse): Promise<void> {
146
+ const tags = normalizeTags(value.tags)
147
+ const tagEpochs: Record<string, string> = {}
148
+ for (const tag of tags) tagEpochs[tag] = this.tagEpoch.get(tag) ?? "0"
85
149
  this.cache.delete(key) // re-insert at the tail so Map order tracks recency (with get) for the LRU evict
86
- this.cache.set(key, value)
150
+ this.cache.set(key, { value: tags.length === 0 ? value : { ...value, tags }, tagEpochs })
87
151
  while (this.cache.size > this.max) {
88
152
  const oldest = this.cache.keys().next().value
89
153
  if (oldest === undefined) break
90
154
  this.cache.delete(oldest)
91
155
  }
156
+ this.pruneTagEpochs()
92
157
  return Promise.resolve()
93
158
  }
94
159
 
95
160
  delete(key: string): Promise<void> {
96
161
  this.cache.delete(key)
162
+ this.pruneTagEpochs()
163
+ return Promise.resolve()
164
+ }
165
+
166
+ invalidateTag(tag: string): Promise<void> {
167
+ normalizeTag(tag)
168
+ this.tagEpoch.set(tag, crypto.randomUUID())
169
+ // This store is bounded and local, so eagerly remove matching rows. It keeps invalidation
170
+ // immediate and lets us discard epoch keys that no retained entry references anymore.
171
+ for (const [key, row] of this.cache) {
172
+ if (Object.hasOwn(row.tagEpochs, tag)) this.cache.delete(key)
173
+ }
174
+ this.pruneTagEpochs()
97
175
  return Promise.resolve()
98
176
  }
177
+
178
+ private pruneTagEpochs(): void {
179
+ if (this.tagEpoch.size === 0) return
180
+ const used = new Set<string>()
181
+ for (const row of this.cache.values()) {
182
+ for (const tag of Object.keys(row.tagEpochs)) used.add(tag)
183
+ }
184
+ for (const tag of this.tagEpoch.keys()) {
185
+ if (!used.has(tag)) this.tagEpoch.delete(tag)
186
+ }
187
+ }
99
188
  }
100
189
 
101
190
  /**
@@ -121,6 +210,16 @@ export interface KVCacheStoreOptions {
121
210
  * overwritten on regeneration or purged via `revalidateEndpoint`.
122
211
  */
123
212
  readonly expirationTtl?: number
213
+ /**
214
+ * Smallest `expirationTtl` this binding accepts (**seconds**). Defaults to 60, which is Cloudflare
215
+ * KV's floor - the common case, and worth rejecting at construction because Cloudflare fails the
216
+ * `put` at runtime instead, one deploy later.
217
+ *
218
+ * The floor belongs to the *binding*, not to this class. {@link KVNamespaceLike} is structural, so
219
+ * a Redis, Deno KV, Upstash, or in-memory binding satisfies it too, and those accept far shorter
220
+ * TTLs. Pass their real minimum, or `0` for a backend with none.
221
+ */
222
+ readonly minExpirationTtl?: number
124
223
  }
125
224
 
126
225
  /**
@@ -141,31 +240,64 @@ const isCachedResponse = (value: unknown): value is CachedResponse => {
141
240
  ) {
142
241
  return false
143
242
  }
243
+ if (v.tags !== undefined) {
244
+ if (!Array.isArray(v.tags)) return false
245
+ try {
246
+ normalizeTags(v.tags as string[])
247
+ } catch {
248
+ return false
249
+ }
250
+ }
144
251
  for (const headerValue of Object.values(v.headers as Record<string, unknown>)) {
145
252
  if (typeof headerValue !== "string") return false
146
253
  }
147
254
  return true
148
255
  }
149
256
 
257
+ const KV_TAG_EPOCH_PREFIX = "__nifra_isr_tag_v1:"
258
+
259
+ type StoredTaggedResponse = CachedResponse & {
260
+ readonly __nifraTagEpochs?: Readonly<Record<string, string>>
261
+ }
262
+
150
263
  /**
151
264
  * A {@link CacheStore} backed by a **Cloudflare Workers KV** namespace (or any {@link KVNamespaceLike}
152
265
  * binding) - the production-grade shared/durable store ISR wants: cached pages and on-demand purges
153
266
  * hold *across* worker instances (unlike the per-instance {@link MemoryCacheStore}). Entries serialize
154
267
  * to JSON; every read is validated before it's trusted (a malformed/version-skewed entry is treated as
155
268
  * a miss). Construct it in your Workers `fetch` from the binding: `new KVCacheStore(env.ISR_CACHE)`.
269
+ *
270
+ * Cloudflare is the default, not a requirement: {@link KVNamespaceLike} is three structural methods,
271
+ * so a Redis, Deno KV, or Upstash binding satisfies it (pass `minExpirationTtl` for their TTL floor).
272
+ * A backend that *can* enumerate keys deserves its own {@link CacheStore} rather than this one - the
273
+ * epoch indirection in {@link KVCacheStore.invalidateTag} exists only because KV cannot list by tag.
156
274
  */
157
275
  export class KVCacheStore implements CacheStore {
158
276
  private readonly kv: KVNamespaceLike
159
277
  private readonly putOptions: { readonly expirationTtl?: number } | undefined
160
278
 
161
279
  constructor(kv: KVNamespaceLike, options: KVCacheStoreOptions = {}) {
162
- if (options.expirationTtl !== undefined && options.expirationTtl < 60) {
163
- throw new Error(
164
- "[nifra/web] KVCacheStore expirationTtl must be >= 60 (Cloudflare KV's minimum), and should " +
165
- "exceed your longest `revalidate` window so stale-while-revalidate isn't turned into a " +
166
- "blocking miss by KV expiry.",
280
+ const floor = options.minExpirationTtl ?? 60
281
+ if (!Number.isSafeInteger(floor) || floor < 0) {
282
+ throw new RangeError(
283
+ "[nifra/web] KVCacheStore minExpirationTtl must be a non-negative integer",
167
284
  )
168
285
  }
286
+ if (options.expirationTtl !== undefined) {
287
+ if (!Number.isSafeInteger(options.expirationTtl) || options.expirationTtl < 0) {
288
+ throw new RangeError(
289
+ "[nifra/web] KVCacheStore expirationTtl must be a non-negative integer (seconds)",
290
+ )
291
+ }
292
+ if (options.expirationTtl < floor) {
293
+ throw new Error(
294
+ `[nifra/web] KVCacheStore expirationTtl must be >= ${floor}, this binding's minimum. 60 is ` +
295
+ "Cloudflare KV's floor and the default; pass `minExpirationTtl` for a backend with a " +
296
+ "lower one. Whatever the floor, the TTL should exceed your longest `revalidate` window, " +
297
+ "or expiry turns a stale-while-revalidate into a blocking miss.",
298
+ )
299
+ }
300
+ }
169
301
  this.kv = kv
170
302
  // Precompute the put options object once: omit it entirely when no TTL (KV then never expires).
171
303
  this.putOptions =
@@ -182,16 +314,53 @@ export class KVCacheStore implements CacheStore {
182
314
  // A corrupt (non-JSON) entry: treat as a miss so the page re-renders; the next set overwrites it.
183
315
  return undefined
184
316
  }
185
- return isCachedResponse(parsed) ? parsed : undefined
317
+ if (!isCachedResponse(parsed)) return undefined
318
+ const tags = normalizeTags(parsed.tags)
319
+ if (tags.length === 0) return parsed
320
+ const epochs = (parsed as StoredTaggedResponse).__nifraTagEpochs
321
+ if (epochs === undefined) return undefined
322
+ const current = await Promise.all(
323
+ tags.map(async (tag) => [tag, (await this.kv.get(this.tagKey(tag))) ?? "0"] as const),
324
+ )
325
+ if (current.some(([tag, epoch]) => epochs[tag] !== epoch)) return undefined
326
+ const { __nifraTagEpochs: _ignored, ...value } = parsed as StoredTaggedResponse
327
+ return value
186
328
  }
187
329
 
188
330
  async set(key: string, value: CachedResponse): Promise<void> {
189
- await this.kv.put(key, JSON.stringify(value), this.putOptions)
331
+ const tags = normalizeTags(value.tags)
332
+ if (tags.length === 0) {
333
+ await this.kv.put(key, JSON.stringify(value), this.putOptions)
334
+ return
335
+ }
336
+ const epochs = Object.fromEntries(
337
+ await Promise.all(
338
+ tags.map(async (tag) => [tag, (await this.kv.get(this.tagKey(tag))) ?? "0"] as const),
339
+ ),
340
+ )
341
+ const stored: StoredTaggedResponse = {
342
+ ...value,
343
+ tags,
344
+ __nifraTagEpochs: epochs,
345
+ }
346
+ await this.kv.put(key, JSON.stringify(stored), this.putOptions)
190
347
  }
191
348
 
192
349
  async delete(key: string): Promise<void> {
193
350
  await this.kv.delete(key)
194
351
  }
352
+
353
+ async invalidateTag(tag: string): Promise<void> {
354
+ normalizeTag(tag)
355
+ // Epoch invalidation avoids KV key enumeration and remains bounded for a tag with millions of
356
+ // pages. Epoch keys intentionally have no expiration: an expired epoch would resurrect entries
357
+ // written before the invalidation when their page key still exists.
358
+ await this.kv.put(this.tagKey(tag), crypto.randomUUID())
359
+ }
360
+
361
+ private tagKey(tag: string): string {
362
+ return `${KV_TAG_EPOCH_PREFIX}${encodeURIComponent(tag)}`
363
+ }
195
364
  }
196
365
 
197
366
  /** Minimal platform shape `withISR` needs - just `waitUntil` (edge runtimes extend the response
@@ -209,6 +378,9 @@ export interface ISRApp {
209
378
  * regenerating behind it), or `miss` (rendered now + stored). Useful for debugging + tests. */
210
379
  export const ISR_STATUS_HEADER = "x-nifra-isr"
211
380
 
381
+ /** Response header carrying bounded route tags into {@link withISR}. */
382
+ export const ISR_REVALIDATE_TAGS_HEADER = "x-nifra-isr-tags"
383
+
212
384
  /**
213
385
  * Response header a route uses to advertise its ISR freshness (**seconds**) to a {@link withISR}
214
386
  * wrapper - `createWebApp` emits it from a route's `export const revalidate`. Deliberately distinct
@@ -289,6 +461,9 @@ const CACHEABLE_RESPONSE_HEADERS = new Set([
289
461
  "link",
290
462
  ])
291
463
 
464
+ const tagsOf = (res: Response): readonly string[] =>
465
+ tagsFromHeader(res.headers.get(ISR_REVALIDATE_TAGS_HEADER))
466
+
292
467
  const headersOf = (res: Response): Record<string, string> => {
293
468
  const out: Record<string, string> = {}
294
469
  res.headers.forEach((value, key) => {
@@ -349,12 +524,14 @@ export function withISR(
349
524
  const res = await app.fetch(req, platform)
350
525
  if (!isCacheablePage(req, res)) return res
351
526
  const body = await res.text()
527
+ const tags = tagsOf(res)
352
528
  const entry: CachedResponse = {
353
529
  body,
354
530
  status: res.status,
355
531
  headers: headersOf(res),
356
532
  storedAt: now(),
357
533
  revalidate: ttlMs(res),
534
+ ...(tags.length === 0 ? {} : { tags }),
358
535
  }
359
536
  await store.set(key, entry)
360
537
  return new Response(body, {
@@ -375,12 +552,14 @@ export function withISR(
375
552
  try {
376
553
  const res = await app.fetch(req, platform)
377
554
  if (isCacheablePage(req, res)) {
555
+ const tags = tagsOf(res)
378
556
  await store.set(key, {
379
557
  body: await res.text(),
380
558
  status: res.status,
381
559
  headers: headersOf(res),
382
560
  storedAt: now(),
383
561
  revalidate: ttlMs(res),
562
+ ...(tags.length === 0 ? {} : { tags }),
384
563
  })
385
564
  } else {
386
565
  await res.body?.cancel()
@@ -431,9 +610,10 @@ export interface RevalidateEndpointOptions {
431
610
 
432
611
  /**
433
612
  * An **on-demand revalidation** (purge) endpoint - a `fetch` handler that drops a path's cached entry
434
- * so the next request re-renders. `POST` with the secret in the token header and the path as `?path=`
435
- * or a JSON `{ "path": "/blog/x" }` body. The token is checked in **constant time** (wrong/missing →
436
- * `401`); a missing/relative path → `400`; non-POST → `405`. Mount it on a nifra route, e.g.
613
+ * or invalidates every entry carrying a tag. `POST` with the secret in the token header and either
614
+ * `?path=/blog/x`, `?tag=products`, or a JSON `{ "path": "/blog/x" }` / `{ "tag": "products" }` body.
615
+ * The token is checked in **constant time** (wrong/missing → `401`); malformed targets → `400`;
616
+ * non-POST → `405`. A store without tag support returns `501` for tag requests. Mount it on a nifra route, e.g.
437
617
  * `app.post("/__nifra/revalidate", (c) => handler(c.req))`.
438
618
  */
439
619
  export function revalidateEndpoint(
@@ -446,12 +626,29 @@ export function revalidateEndpoint(
446
626
  if (!timingSafeEqual(req.headers.get(tokenHeader) ?? "", options.secret)) {
447
627
  return jsonError(401, "unauthorized")
448
628
  }
449
- let path = new URL(req.url).searchParams.get("path")
450
- if (path === null) {
629
+ const url = new URL(req.url)
630
+ let path = url.searchParams.get("path")
631
+ let tag = url.searchParams.get("tag")
632
+ if (path === null && tag === null) {
451
633
  const body: unknown = await req.json().catch(() => null)
452
- const candidate =
453
- typeof body === "object" && body !== null ? (body as { path?: unknown }).path : undefined
454
- path = typeof candidate === "string" ? candidate : null
634
+ if (typeof body === "object" && body !== null) {
635
+ const candidate = body as { path?: unknown; tag?: unknown }
636
+ path = typeof candidate.path === "string" ? candidate.path : null
637
+ tag = typeof candidate.tag === "string" ? candidate.tag : null
638
+ }
639
+ }
640
+ if (path !== null && tag !== null) return jsonError(400, "choose_path_or_tag")
641
+ if (tag !== null) {
642
+ try {
643
+ normalizeTag(tag)
644
+ } catch {
645
+ return jsonError(400, "invalid_tag")
646
+ }
647
+ if (options.store.invalidateTag === undefined) {
648
+ return jsonError(501, "tag_invalidation_not_supported")
649
+ }
650
+ await options.store.invalidateTag(tag)
651
+ return Response.json({ revalidatedTag: tag })
455
652
  }
456
653
  if (path === null || !path.startsWith("/")) return jsonError(400, "invalid_path")
457
654
  await options.store.delete(keyOf(path, req))
package/src/manifest.ts CHANGED
@@ -227,6 +227,8 @@ export interface RouteModule {
227
227
  * default). Older-than-`revalidate` cached pages are served stale while regenerating.
228
228
  */
229
229
  readonly revalidate?: number
230
+ /** Optional bounded tags used by ISR on-demand invalidation (`?tag=...`). */
231
+ readonly revalidateTags?: readonly string[]
230
232
  /**
231
233
  * No-framework island bundles (`@nifrajs/web/islands`) to load on this route, as `<script
232
234
  * type="module">` in the document tail. Loaded **regardless of `hydrate`** - pair with
Binary file
@@ -11,6 +11,16 @@ import type { BunPlugin } from "bun"
11
11
  /** The argument Bun passes to a plugin's `setup` - Bun doesn't export the type, so derive it. */
12
12
  export type PluginBuilder = Parameters<BunPlugin["setup"]>[0]
13
13
 
14
+ /**
15
+ * Re-keys the app-owned imports of a module the SERVER half of a plugin just compiled, so an edit below
16
+ * a route reaches SSR instead of being served from Bun's import cache. A plugin that claims a file
17
+ * extension is the only code that ever sees that file's source, so it is the only place its imports can
18
+ * be versioned - every `generate: "ssr"` loader should return its output through this.
19
+ *
20
+ * A no-op on the client half and outside a dev server. See `../dev-ssr-graph.ts` for the whole design.
21
+ */
22
+ export { rewriteSsrImports } from "../dev-ssr-graph.ts"
23
+
14
24
  const packageRootCache = new Map<string, string>()
15
25
 
16
26
  /** The nearest ancestor directory of `startDir` that holds a `package.json` (the file's package root),
@@ -57,6 +67,67 @@ export function hash8(input: string): string {
57
67
  return h.toString(16).padStart(8, "0")
58
68
  }
59
69
 
70
+ /**
71
+ * Env flag a nifra dev server sets for its own lifetime, so a framework compiler plugin can tell a dev
72
+ * compile from a `nifra build` one. Exported for the dev server that sets it; plugins ask
73
+ * {@link devServerCompile} instead of reading it.
74
+ */
75
+ export const DEV_HMR_ENV = "NIFRA_DEV_HMR"
76
+
77
+ /**
78
+ * True while a nifra dev server is running, i.e. the compile may emit HMR wiring (and, where the
79
+ * framework couples the two, dev-mode runtime output).
80
+ *
81
+ * The phase cannot be a plugin constructor argument: an app builds its `clientPlugins` once in
82
+ * `nifra.config.ts` and the same plugin objects serve both `nifra dev` and `nifra build`. Guarding the
83
+ * emitted code with `if (import.meta.hot)` is not sufficient either - `Bun.build` keeps the branch, so
84
+ * the HMR calls were measured shipping in production client chunks. The dev server announces itself in
85
+ * the environment instead, and `nifra build` never sets it. Read per compile (not captured at
86
+ * registration) so the flag is honoured however late the server sets it.
87
+ */
88
+ export function devServerCompile(): boolean {
89
+ return process.env[DEV_HMR_ENV] === "1"
90
+ }
91
+
92
+ /**
93
+ * The app root and the routes directory of the running dev server, for plugins that need to tell one of
94
+ * the app's own components from a dependency or a route module. Set alongside {@link DEV_HMR_ENV}; read
95
+ * through {@link devHotComponent}.
96
+ */
97
+ export const DEV_ROOT_ENV = "NIFRA_DEV_ROOT"
98
+ export const DEV_ROUTES_ENV = "NIFRA_DEV_ROUTES"
99
+
100
+ const under = (path: string, dir: string | undefined): boolean =>
101
+ dir !== undefined &&
102
+ dir !== "" &&
103
+ (path === dir || path.startsWith(dir.endsWith("/") ? dir : `${dir}/`))
104
+
105
+ /**
106
+ * Whether a dev compile of `path` may wrap the module in component-level hot-patching - i.e. whether it
107
+ * is one of the app's OWN components rather than a dependency or a route module.
108
+ *
109
+ * The exclusions are not caution, they are two things such a wrapper cannot survive:
110
+ *
111
+ * - a **dependency**, whose components are the framework's own composition machinery. An adapter folds
112
+ * a layout chain through them, so they are entered from a snippet or as a dynamic component - and a
113
+ * wrapper there sits between the SSR markup and the component that is supposed to claim it.
114
+ * - a **route module**, for the same reason one level down: a route or layout IS a chain member, so it
115
+ * is always the dynamic child. It is also the wrong boundary to patch - a route module carries the
116
+ * loader and meta the server ran, and re-running those is a navigation, not a patch. Editing one
117
+ * finds no accepting module and reloads the page, which is the correct outcome.
118
+ *
119
+ * Both cost nothing in practice: the components an edit loop actually touches are the views, and a view
120
+ * that wants to hot-patch lives outside the routes directory. Where the split is unknown (no dev server,
121
+ * or a root that was never announced), the answer is `false` - a full reload is always correct, a
122
+ * mispatched tree is not.
123
+ */
124
+ export function devHotComponent(path: string): boolean {
125
+ const root = process.env[DEV_ROOT_ENV]
126
+ if (!under(path, root)) return false
127
+ if (path.includes("/node_modules/")) return false
128
+ return !under(path, process.env[DEV_ROUTES_ENV])
129
+ }
130
+
60
131
  /**
61
132
  * Records compiled CSS and wires it into the client bundle through a virtual `?<namespace>` module -
62
133
  * the idiom the Vue plugin established (`?vue-css`). Register one per plugin `setup`; call `emit` per
package/src/public-dir.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { realpath } from "node:fs/promises"
16
16
  import { normalize, resolve, sep } from "node:path"
17
+ import { parseByteRange } from "@nifrajs/core/range"
17
18
  import { pathnameOf } from "@nifrajs/core/server"
18
19
 
19
20
  /** How long each subtree may be cached. Content-hashed bundle output can be immutable; a
@@ -38,6 +39,35 @@ export interface ServePublicDirOptions {
38
39
  const IMMUTABLE = "public, max-age=31536000, immutable"
39
40
  const ONE_DAY = "public, max-age=86400"
40
41
 
42
+ /** HTTP dates carry second precision; comparing raw milliseconds makes every file look stale. */
43
+ function seconds(time: number): number {
44
+ return Math.floor(time / 1000)
45
+ }
46
+
47
+ /**
48
+ * `If-Range` decides whether a range request is still safe to answer partially. The only validator
49
+ * this handler publishes is `last-modified`, so an entity-tag form can never match and the whole
50
+ * representation is sent instead - which is the conformant outcome, not a fallback. Dates use strong
51
+ * comparison (RFC 9110 13.1.5): a file modified since the client's copy invalidates its byte offsets.
52
+ */
53
+ function ifRangeMatches(value: string | null, lastModified: number | undefined): boolean {
54
+ if (value === null) return true
55
+ if (lastModified === undefined) return false
56
+ const item = value.trim()
57
+ if (item.startsWith('"') || item.startsWith("W/")) return false
58
+ const parsed = Date.parse(item)
59
+ return Number.isFinite(parsed) && seconds(parsed) === seconds(lastModified)
60
+ }
61
+
62
+ /** `If-Modified-Since` freshness. No ETag is published, so there is no `If-None-Match` precedence. */
63
+ function isNotModified(request: Request, lastModified: number | undefined): boolean {
64
+ if (lastModified === undefined) return false
65
+ const since = request.headers.get("if-modified-since")
66
+ if (since === null) return false
67
+ const parsed = Date.parse(since)
68
+ return Number.isFinite(parsed) && seconds(parsed) >= seconds(lastModified)
69
+ }
70
+
41
71
  /**
42
72
  * Resolve a URL pathname to an absolute path **confined** to `root`, or `undefined` if it escapes.
43
73
  *
@@ -101,12 +131,52 @@ export function servePublicDir(
101
131
  }
102
132
  const file = Bun.file(resolvedFile)
103
133
  if (!(await file.exists())) return undefined
134
+ const size = file.size
104
135
  const headers = new Headers({
105
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",
106
140
  })
107
- // HEAD must not carry a body, but must otherwise match GET's headers.
108
- return request.method === "HEAD"
109
- ? new Response(null, { headers })
110
- : new Response(file, { headers })
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"
150
+
151
+ if (isNotModified(request, lastModified)) {
152
+ headers.delete("content-type")
153
+ return new Response(null, { status: 304, headers })
154
+ }
155
+
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)
161
+
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
+ }
167
+
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
+ }
175
+
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 })
111
181
  }
112
182
  }
@@ -41,6 +41,8 @@ export interface RouteManifestEntry {
41
41
  readonly hydrate: boolean
42
42
  /** Revalidation window in seconds. Present only for `isr`. */
43
43
  readonly revalidate?: number
44
+ /** Bounded tags that can purge this route from a shared ISR store. */
45
+ readonly revalidateTags?: readonly string[]
44
46
  /**
45
47
  * Concrete paths prerendered at build. Present for `static` routes: a single path for a static route,
46
48
  * one per `getStaticPaths` entry for a dynamic one. Absent means "no path was enumerated", which for a
@@ -84,7 +86,10 @@ const isDynamic = (pattern: string): boolean => /[:*]/.test(pattern)
84
86
  export function deriveRouteEntry(
85
87
  id: string,
86
88
  pattern: string,
87
- module: Pick<RouteModule, "prerender" | "getStaticPaths" | "revalidate" | "hydrate">,
89
+ module: Pick<
90
+ RouteModule,
91
+ "prerender" | "getStaticPaths" | "revalidate" | "revalidateTags" | "hydrate"
92
+ >,
88
93
  prerenderedPaths?: readonly string[],
89
94
  ): RouteManifestEntry {
90
95
  const wantsPrerender = module.prerender === true || module.getStaticPaths !== undefined
@@ -105,10 +110,14 @@ export function deriveRouteEntry(
105
110
  mode: RenderMode
106
111
  hydrate: boolean
107
112
  revalidate?: number
113
+ revalidateTags?: readonly string[]
108
114
  prerenderedPaths?: readonly string[]
109
115
  requires: readonly RouteCapability[]
110
116
  } = { id, pattern, mode, hydrate: module.hydrate !== false, requires }
111
117
  if (mode === "isr" && module.revalidate !== undefined) entry.revalidate = module.revalidate
118
+ if (mode === "isr" && module.revalidateTags !== undefined) {
119
+ entry.revalidateTags = module.revalidateTags
120
+ }
112
121
  if (prerenderedPaths !== undefined) entry.prerenderedPaths = prerenderedPaths
113
122
  return entry as RouteManifestEntry
114
123
  }