@statorjs/stator 2.10.0-next.1 → 2.11.0-next.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.
@@ -0,0 +1,426 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { basename, dirname, join, resolve, sep } from 'node:path'
5
+
6
+ /**
7
+ * Framework image serving — the promotion of the pattern proven in
8
+ * `examples/indie-blog` (spec `images-are-part-of-stator-*`). Mounted only
9
+ * when `stator.config.ts` declares `images: { dir }`; an unconfigured app has
10
+ * no routes here and never loads a transformer.
11
+ *
12
+ * The contract, unchanged from the proof: the URL's **extension is the
13
+ * delivery format** (request `x.webp` of a stored `x.jpg` and the endpoint
14
+ * converts — it never lies about an extension; there is no Accept-header or
15
+ * user-agent negotiation, ever), `?w=` resizes to an allowlisted width, and
16
+ * `?w=&h=` cover-crops to an exact box — BOTH dimensions must come from the
17
+ * allowlist, so the variant space stays bounded (widths² at worst, never an
18
+ * open crop parameter). Variants cache on disk beside the originals
19
+ * (`.variants/`), regenerated when the original is newer. GIFs serve as
20
+ * originals only — animation does not survive naive resizing.
21
+ */
22
+
23
+ /**
24
+ * The transformer seam: pure bytes-in/bytes-out, so the default (sharp) is
25
+ * swappable for another library. The framework owns everything around it —
26
+ * path resolution, the disk cache, conditional GET — adapters only transform.
27
+ */
28
+ export interface ImageTransformer {
29
+ /** Intrinsic dimensions. Called at WRITE time by app upload handlers (via
30
+ * `probeImage`), never during a render — dimensions are data, not IO. */
31
+ probe(bytes: Uint8Array): Promise<{ width: number | null; height: number | null }>
32
+ /** Resize (optional) and/or re-encode to `format`. Width alone never
33
+ * enlarges; width+height is an exact cover-cropped box (art direction
34
+ * needs predictable geometry, so enlargement is allowed there). */
35
+ transform(
36
+ input: Uint8Array,
37
+ opts: { width?: number; height?: number; format: 'jpeg' | 'png' | 'webp' | 'avif' },
38
+ ): Promise<Uint8Array>
39
+ }
40
+
41
+ /** Impl #1: sharp, imported lazily so image-free processes never load it.
42
+ * `threads` caps the libvips worker pool (process-global — libvips has one
43
+ * pool). sharp's default is the REPORTED core count, which on a shared-cpu
44
+ * host is the physical machine's (8+ on Fly shared-cpu-1x) — every encode
45
+ * then fans out that many threads' worth of encoder buffers and CPU demand
46
+ * on a fractional vCPU. A big AVIF encode under that fan-out is what
47
+ * swap-thrashed a 512MB host into a lockup. 0 = leave sharp's default. */
48
+ export function sharpTransformer(threads = 0): ImageTransformer {
49
+ const load = async () => {
50
+ const { default: sharp } = await import('sharp')
51
+ if (threads > 0) sharp.concurrency(threads)
52
+ return sharp
53
+ }
54
+ return {
55
+ async probe(bytes) {
56
+ const sharp = await load()
57
+ const meta = await sharp(bytes).metadata()
58
+ // Report DISPLAY dimensions: EXIF orientations 5-8 transpose the
59
+ // raster, and these dims feed width/height attributes (the CLS box)
60
+ // for pixels that transform() serves upright — raw stored dims would
61
+ // reserve a sideways box for every portrait phone photo.
62
+ const swap = (meta.orientation ?? 1) >= 5
63
+ return {
64
+ width: (swap ? meta.height : meta.width) ?? null,
65
+ height: (swap ? meta.width : meta.height) ?? null,
66
+ }
67
+ },
68
+ async transform(input, opts) {
69
+ const sharp = await load()
70
+ // rotate() with no args bakes the EXIF orientation into the pixels
71
+ // (and re-encoding drops the tag) — without it every variant of a
72
+ // portrait phone photo comes out sideways, because encoders write the
73
+ // raster as stored and the orientation tag is what dies in transit.
74
+ let pipeline = sharp(input).rotate()
75
+ if (opts.width !== undefined && opts.height !== undefined) {
76
+ pipeline = pipeline.resize({ width: opts.width, height: opts.height, fit: 'cover' })
77
+ } else if (opts.width !== undefined) {
78
+ pipeline = pipeline.resize({ width: opts.width, withoutEnlargement: true })
79
+ }
80
+ // AVIF effort 2 (sharp's default is 4): the encoder's CPU/density
81
+ // trade, NOT a visual-quality knob. On-demand serving means a real
82
+ // visitor is waiting on the first encode — measured 16s at effort 4 on
83
+ // a shared-cpu host vs a few seconds at 2, for files a few percent
84
+ // larger. A build step would choose density; an on-demand endpoint
85
+ // chooses the visitor.
86
+ const encoded =
87
+ opts.format === 'avif' ? pipeline.avif({ effort: 2 }) : pipeline.toFormat(opts.format)
88
+ return new Uint8Array(await encoded.toBuffer())
89
+ },
90
+ }
91
+ }
92
+
93
+ export const DEFAULT_IMAGE_WIDTHS = [400, 800, 1200, 1600]
94
+
95
+ /** Crop aspect allowlist (width/height): square, the photographic landscape
96
+ * trio, and their portrait duals. A crop `?w=&h=` is valid iff `w` is an
97
+ * allowlisted width AND `h === round(w / a)` for an allowlisted aspect — the
98
+ * variant space stays finite (|widths| x |aspects|) while the aspects people
99
+ * actually shoot in (16:9, 3:2, 4:3) all work. (The first cut required
100
+ * `h ∈ widths`, which silently forbade every non-square aspect — caught in
101
+ * review before the URL grammar froze.) */
102
+ export const DEFAULT_IMAGE_ASPECT_RATIOS = [1, 4 / 3, 3 / 2, 16 / 9, 3 / 4, 2 / 3, 9 / 16]
103
+
104
+ /** What the render side needs to emit URLs the endpoint will accept — carried
105
+ * on the render state so `<Image>`/`<Picture>` can't drift from config. */
106
+ export interface ImagesRenderInfo {
107
+ widths: number[]
108
+ aspectRatios: number[]
109
+ }
110
+
111
+ export const IMAGE_CONTENT_TYPES: Record<string, string> = {
112
+ jpg: 'image/jpeg',
113
+ jpeg: 'image/jpeg',
114
+ png: 'image/png',
115
+ webp: 'image/webp',
116
+ avif: 'image/avif',
117
+ gif: 'image/gif',
118
+ svg: 'image/svg+xml',
119
+ }
120
+
121
+ const TRANSCODE_TARGETS = new Set(['jpg', 'jpeg', 'png', 'webp', 'avif'])
122
+
123
+ /** Originals-only formats: served verbatim, never a transcode SOURCE either.
124
+ * GIF because animation doesn't survive naive resizing; SVG because it's a
125
+ * vector document, not pixels — nothing to probe, resize, or rasterize
126
+ * (found via an imported favicon living in the media store, where an
127
+ * uploading admin legitimately puts it). */
128
+ const ORIGINAL_ONLY_SOURCES = new Set(['gif', 'svg'])
129
+
130
+ /** SVG is the one format that can execute script when navigated to directly
131
+ * (fine inside <img>/favicon contexts, which ignore scripts). Serve it like
132
+ * GitHub serves avatars: scripts and external loads neutralized, inline
133
+ * styles allowed, sniffing pinned. */
134
+ const SVG_SECURITY_HEADERS = {
135
+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'",
136
+ 'X-Content-Type-Options': 'nosniff',
137
+ }
138
+
139
+ /** Cold-cache stampede guard: N concurrent requests for the same missing
140
+ * variant share one encode instead of N. Keyed by cache path. */
141
+ const inflight = new Map<string, Promise<void>>()
142
+
143
+ /** sha1-16 of an original's bytes, memoized per path with `(size, mtime)` as
144
+ * the recompute key — mtime demoted from identity to cache hint, its correct
145
+ * role. Same shape and truncation as the query-route `revision()` ledger. */
146
+ const hashCache = new Map<string, { size: number; mtimeMs: number; hash: string }>()
147
+ function originalHash(full: string): string {
148
+ const st = statSync(full)
149
+ const hit = hashCache.get(full)
150
+ if (hit && hit.size === st.size && hit.mtimeMs === st.mtimeMs) return hit.hash
151
+ const hash = createHash('sha1').update(readFileSync(full)).digest('hex').slice(0, 16)
152
+ hashCache.set(full, { size: st.size, mtimeMs: st.mtimeMs, hash })
153
+ return hash
154
+ }
155
+
156
+ /** The freshness dial (spec: image-caching, adjudicated 2026-08-30 — a dial,
157
+ * not an immutable boolean, because over-caching has no server-side
158
+ * recovery). Defaults preserve the conservative revalidation contract;
159
+ * `staleWhileRevalidate` trades must-revalidate for background self-healing
160
+ * (SWR forbids what must-revalidate demands, so they never co-emit);
161
+ * `immutable` appends only on top of an explicit long maxAge. */
162
+ function cacheControlValue(config: ResolvedImagesConfig): string {
163
+ const { maxAge, staleWhileRevalidate: swr, immutable } = config
164
+ if (maxAge === 0 && swr === 0) return 'public, max-age=0, must-revalidate'
165
+ let value = `public, max-age=${maxAge}`
166
+ if (swr > 0) value += `, stale-while-revalidate=${swr}`
167
+ if (immutable && maxAge > 0) value += ', immutable'
168
+ return value
169
+ }
170
+
171
+ /** Global encode semaphore — DIFFERENT variants queue behind a small number
172
+ * of concurrent transforms. Without it, one cold-cache gallery page fans a
173
+ * dozen simultaneous sharp encodes (AVIF is memory-brutal) and OOM-kills a
174
+ * small host — found the first hour tonysull.co ran on a 512MB Fly machine.
175
+ * Module-level on purpose: the resource being protected (CPU/memory) is
176
+ * process-wide, whatever app object serves the request. */
177
+ let activeTransforms = 0
178
+ const transformQueue: Array<() => void> = []
179
+ function acquireTransformSlot(limit: number): Promise<void> {
180
+ return new Promise((resolve) => {
181
+ if (activeTransforms < limit) {
182
+ activeTransforms += 1
183
+ resolve()
184
+ } else {
185
+ transformQueue.push(() => {
186
+ activeTransforms += 1
187
+ resolve()
188
+ })
189
+ }
190
+ })
191
+ }
192
+ function releaseTransformSlot(): void {
193
+ activeTransforms -= 1
194
+ transformQueue.shift()?.()
195
+ }
196
+
197
+ export interface ResolvedImagesConfig {
198
+ dir: string
199
+ /** URL prefix (leading slash, no trailing). */
200
+ path: string
201
+ widths: number[]
202
+ aspectRatios: number[]
203
+ transformer: ImageTransformer
204
+ /** Max concurrent encodes across ALL variants (default 2). */
205
+ concurrency: number
206
+ /** libvips worker threads per encode (default 0 = sharp's default). */
207
+ threads: number
208
+ /** Encode deadline per request (default 15000ms; 0 disables). Past it the
209
+ * response degrades to a 302 at the stored original — honest bytes now —
210
+ * while the encode keeps filling the cache for the next request. */
211
+ encodeTimeoutMs: number
212
+ /** Freshness lifetime in seconds (default 0 = revalidate every use). */
213
+ maxAge: number
214
+ /** Serve-stale window in seconds (default 0). >0 lets a cache render its
215
+ * copy instantly and revalidate in the background — bounded regret. */
216
+ staleWhileRevalidate: number
217
+ /** Append `immutable` to a long maxAge — the no-recovery marker; only for
218
+ * apps whose image URLs are write-once by construction. */
219
+ immutable: boolean
220
+ }
221
+
222
+ export function resolveImagesConfig(config: {
223
+ dir: string
224
+ path?: string
225
+ widths?: number[]
226
+ aspectRatios?: number[]
227
+ transformer?: ImageTransformer
228
+ concurrency?: number
229
+ threads?: number
230
+ encodeTimeoutMs?: number
231
+ maxAge?: number
232
+ staleWhileRevalidate?: number
233
+ immutable?: boolean
234
+ }): ResolvedImagesConfig {
235
+ const dir = resolve(config.dir)
236
+ const path = (config.path ?? `/${basename(dir)}`).replace(/\/$/, '')
237
+ const threads = Math.max(0, config.threads ?? 0)
238
+ return {
239
+ dir,
240
+ path: path.startsWith('/') ? path : `/${path}`,
241
+ widths: config.widths ?? DEFAULT_IMAGE_WIDTHS,
242
+ aspectRatios: config.aspectRatios ?? DEFAULT_IMAGE_ASPECT_RATIOS,
243
+ transformer: config.transformer ?? sharpTransformer(threads),
244
+ concurrency: Math.max(1, config.concurrency ?? 2),
245
+ threads,
246
+ encodeTimeoutMs: Math.max(0, config.encodeTimeoutMs ?? 15_000),
247
+ maxAge: Math.max(0, config.maxAge ?? 0),
248
+ staleWhileRevalidate: Math.max(0, config.staleWhileRevalidate ?? 0),
249
+ immutable: config.immutable ?? false,
250
+ }
251
+ }
252
+
253
+ /** Write-time probe for app upload handlers — the render never does this.
254
+ * Defaults to the sharp transformer; pass your configured one if you swapped
255
+ * it. (First dogfood finding: an earlier signature took the whole resolved
256
+ * config, which apps don't hold — bytes + optional transformer is the shape
257
+ * an upload handler actually has in hand.) */
258
+ export async function probeImage(
259
+ bytes: Uint8Array,
260
+ transformer: ImageTransformer = sharpTransformer(),
261
+ ): Promise<{ width: number | null; height: number | null }> {
262
+ return transformer.probe(bytes)
263
+ }
264
+
265
+ /** Find the stored original for a requested relative path, whatever its
266
+ * extension — containment-checked against the images dir. */
267
+ function resolveOriginal(
268
+ config: ResolvedImagesConfig,
269
+ rel: string,
270
+ ): { full: string; ext: string } | null {
271
+ const dot = rel.lastIndexOf('.')
272
+ if (dot === -1) return null
273
+ // No dotfile path segments: keeps the `.variants/` cache (and anything
274
+ // hidden) unreachable through the endpoint — no variants-of-variants.
275
+ if (rel.split('/').some((seg) => seg.startsWith('.'))) return null
276
+ const base = rel.slice(0, dot)
277
+ for (const ext of Object.keys(IMAGE_CONTENT_TYPES)) {
278
+ const full = resolve(config.dir, `${base}.${ext}`)
279
+ if (!full.startsWith(config.dir + sep)) return null
280
+ if (existsSync(full)) return { full, ext }
281
+ }
282
+ return null
283
+ }
284
+
285
+ /**
286
+ * Serve one image request: `rel` is the path under the endpoint prefix,
287
+ * `widthParam` the raw `?w=` value (undefined = original size). Returns a
288
+ * complete `Response` — 200 with caching headers, 304 on a validator match,
289
+ * 400 for an off-allowlist width, 404 otherwise.
290
+ */
291
+ export async function serveImage(
292
+ config: ResolvedImagesConfig,
293
+ rel: string,
294
+ widthParam: string | undefined,
295
+ heightParam: string | undefined,
296
+ ifNoneMatch: string | null,
297
+ ): Promise<Response> {
298
+ let width: number | undefined
299
+ if (widthParam !== undefined) {
300
+ width = Number(widthParam)
301
+ if (!config.widths.includes(width)) {
302
+ return new Response(`w must be one of ${config.widths.join(', ')}`, { status: 400 })
303
+ }
304
+ }
305
+ let height: number | undefined
306
+ if (heightParam !== undefined) {
307
+ // A crop needs both axes: `w` from the width allowlist, `h` derived from
308
+ // an allowlisted aspect — the finite-variant DoS bound, |widths|x|aspects|.
309
+ height = Number(heightParam)
310
+ const w = width
311
+ if (w === undefined || !config.aspectRatios.some((a) => height === Math.round(w / a))) {
312
+ return new Response('h requires w, and w/h must match an allowlisted aspect', { status: 400 })
313
+ }
314
+ }
315
+
316
+ const dot = rel.lastIndexOf('.')
317
+ const requestedExt = rel.slice(dot + 1).toLowerCase()
318
+ const contentType = IMAGE_CONTENT_TYPES[requestedExt]
319
+ if (!contentType) return new Response('not found', { status: 404 })
320
+
321
+ const original = resolveOriginal(config, rel)
322
+ if (!original) return new Response('not found', { status: 404 })
323
+
324
+ // Content-hash validators (spec: image-caching). ETags are per-URL and the
325
+ // transform params ARE the URL, so hash(original) is the complete entropy;
326
+ // the params suffix on variants is labeling for humans, not identity. The
327
+ // encoder is deliberately excluded (an upgrade must not mass-bust visually
328
+ // identical pixels) — which breaks the byte-identity promise a strong ETag
329
+ // makes, so variants carry WEAK validators; originals stay strong.
330
+ let hash: string
331
+ try {
332
+ hash = originalHash(original.full)
333
+ } catch {
334
+ return new Response('not found', { status: 404 })
335
+ }
336
+ const sameFormat =
337
+ IMAGE_CONTENT_TYPES[original.ext] === contentType && width === undefined && height === undefined
338
+ const sizeLabel = `${width ?? 'orig'}${height !== undefined ? `x${height}` : ''}`
339
+ const etag = sameFormat ? `"${hash}"` : `W/"${hash}-${sizeLabel}-${requestedExt}"`
340
+ const headers = {
341
+ 'Content-Type': contentType,
342
+ ETag: etag,
343
+ 'Cache-Control': cacheControlValue(config),
344
+ ...(requestedExt === 'svg' ? SVG_SECURITY_HEADERS : undefined),
345
+ }
346
+ // Early 304 — validators derive from the ORIGINAL, so a match answers
347
+ // before any encode work (or even a variant file) exists.
348
+ const bare = etag.replace(/^W\//, '')
349
+ if (ifNoneMatch?.split(',').some((t) => t.trim().replace(/^W\//, '') === bare)) {
350
+ return new Response(null, { status: 304, headers })
351
+ }
352
+
353
+ let file: string
354
+ if (sameFormat) {
355
+ file = original.full
356
+ } else {
357
+ if (!TRANSCODE_TARGETS.has(requestedExt) || ORIGINAL_ONLY_SOURCES.has(original.ext)) {
358
+ return new Response('not found', { status: 404 })
359
+ }
360
+ // The source-hash lives IN the cache filename: freshness is a pure
361
+ // existence check — a changed original hashes to a different name and
362
+ // simply misses. (Replaces mtime comparison, which mtime-preserving and
363
+ // mtime-resetting copies each fooled in one direction. Stale-hash
364
+ // siblings are identifiable garbage for a future `stator images gc`.)
365
+ const cache = join(
366
+ config.dir,
367
+ '.variants',
368
+ `${rel.slice(0, dot)}-${hash.slice(0, 8)}-${sizeLabel}.${requestedExt}`,
369
+ )
370
+ if (!existsSync(cache)) {
371
+ let job = inflight.get(cache)
372
+ if (!job) {
373
+ const format =
374
+ requestedExt === 'jpg' ? 'jpeg' : (requestedExt as 'jpeg' | 'png' | 'webp' | 'avif')
375
+ job = (async () => {
376
+ await acquireTransformSlot(config.concurrency)
377
+ try {
378
+ const out = await config.transformer.transform(
379
+ new Uint8Array(await readFile(original.full)),
380
+ { width, height, format },
381
+ )
382
+ mkdirSync(dirname(cache), { recursive: true })
383
+ const { writeFile } = await import('node:fs/promises')
384
+ await writeFile(cache, out)
385
+ } finally {
386
+ releaseTransformSlot()
387
+ }
388
+ })().finally(() => inflight.delete(cache))
389
+ inflight.set(cache, job)
390
+ }
391
+ // The on-demand contract is "first requester sees a SMALL delay" —
392
+ // enforce it. A pathological encode (large AVIF on a starved host) can
393
+ // take 30s+ or swap-thrash the machine; past the deadline this request
394
+ // degrades to the stored original (a redirect, so the Content-Type
395
+ // stays honest) while the encode keeps running to fill the cache. The
396
+ // browser renders real pixels now; the next visitor gets the variant.
397
+ if (config.encodeTimeoutMs > 0) {
398
+ const finished = await new Promise<boolean>((done) => {
399
+ const timer = setTimeout(() => done(false), config.encodeTimeoutMs)
400
+ const settle = () => {
401
+ clearTimeout(timer)
402
+ done(true) // rejection included — the `await job` below rethrows it
403
+ }
404
+ job.then(settle, settle)
405
+ })
406
+ if (!finished) {
407
+ return new Response(null, {
408
+ status: 302,
409
+ headers: {
410
+ Location: `${config.path}/${rel.slice(0, dot)}.${original.ext}`,
411
+ 'Cache-Control': 'no-store',
412
+ },
413
+ })
414
+ }
415
+ }
416
+ await job
417
+ }
418
+ file = cache
419
+ }
420
+
421
+ try {
422
+ return new Response(new Uint8Array(await readFile(file)), { headers })
423
+ } catch {
424
+ return new Response('not found', { status: 404 })
425
+ }
426
+ }
@@ -52,6 +52,13 @@ export {
52
52
  export { scheduleSessionEffects, wireAppEffects } from './effects.ts'
53
53
  export type { HttpConfig } from './http.ts'
54
54
  export { buildHonoApp } from './http.ts'
55
+ export type { ImageTransformer, ResolvedImagesConfig } from './images.ts'
56
+ export {
57
+ DEFAULT_IMAGE_WIDTHS,
58
+ probeImage,
59
+ resolveImagesConfig,
60
+ sharpTransformer,
61
+ } from './images.ts'
55
62
  export type { InspectPayload, InspectRoute, InspectRouteMethod } from './inspect.ts'
56
63
  export { buildInspectPayload } from './inspect.ts'
57
64
  export type { InstanceHandle } from './instance-proxy.ts'
@@ -12,7 +12,7 @@ import type {
12
12
  QueryRouteResult,
13
13
  RouteContext,
14
14
  } from './routing.ts'
15
- import { getOrCreateSessionId } from './session.ts'
15
+ import { getOrCreateSessionId, peekSessionId, sessionUse } from './session.ts'
16
16
  import { withSessionLock } from './session-lock.ts'
17
17
  import { SessionRuntime } from './session-runtime.ts'
18
18
 
@@ -24,6 +24,7 @@ const queryLog = scopedLogger('query')
24
24
  const DATA_CONTENT_TYPES: Record<string, string> = {
25
25
  '.json': 'application/json; charset=utf-8',
26
26
  '.xml': 'application/xml; charset=utf-8',
27
+ '.atom': 'application/atom+xml; charset=utf-8',
27
28
  '.txt': 'text/plain; charset=utf-8',
28
29
  '.ics': 'text/calendar; charset=utf-8',
29
30
  '.csv': 'text/csv; charset=utf-8',
@@ -55,8 +56,30 @@ export async function runQueryRoute(
55
56
  /** Path params from the framework's own matcher (the GET catch-all
56
57
  * bypasses Hono's per-route param extraction). */
57
58
  params?: Record<string, string>,
59
+ /** Layer-3 derived Cache-Control values (absent → never emitted). */
60
+ caching?: { sMaxAge: number; staleWhileRevalidate: number },
58
61
  ): Promise<Response> {
59
- const { sessionId } = getOrCreateSessionId(c)
62
+ // Lazy layer 1: only session-machine reads establish; an app-only data
63
+ // route (a feed, a sitemap) renders sessionless and stays CDN-cacheable.
64
+ const needsSession = route.reads.some((def) => def.lifecycle === 'session')
65
+
66
+ // The revision ledger: when the route declares a cheap fingerprint, the
67
+ // ETag derives from it and a match answers 304 BEFORE the handler — the
68
+ // designed upgrade the body-hash ETag always pointed at. The fingerprint
69
+ // runs outside any session machinery, so the derived-caching proof holds.
70
+ let revisionEtag: string | undefined
71
+ if (route.revision) {
72
+ const rev = await route.revision()
73
+ revisionEtag = `"r-${createHash('sha1').update(String(rev)).digest('base64url').slice(0, 16)}"`
74
+ if ((c.req.header('if-none-match') ?? '').includes(revisionEtag)) {
75
+ const notModified = new Response(null, { status: 304, headers: { ETag: revisionEtag } })
76
+ applyDerivedCaching(c, notModified, { caching, needsSession })
77
+ return notModified
78
+ }
79
+ }
80
+ const sessionId = needsSession
81
+ ? getOrCreateSessionId(c).sessionId
82
+ : (peekSessionId(c) ?? '@anonymous')
60
83
  const request = params
61
84
  ? { ...buildRouteRequest(c, discovered.paramNames), params }
62
85
  : buildRouteRequest(c, discovered.paramNames)
@@ -99,12 +122,54 @@ export async function runQueryRoute(
99
122
  scheduleSessionEffects(runtime, store, sessionId)
100
123
  }
101
124
 
102
- return synthesizeDataResponse(c, result)
125
+ const res = synthesizeDataResponse(c, result)
126
+ // A revision route's responses carry the revision ETag (overriding the
127
+ // body hash) so the cheap 304 path and full responses validate against
128
+ // one token.
129
+ if (revisionEtag) {
130
+ try {
131
+ res.headers.set('etag', revisionEtag)
132
+ } catch {
133
+ // Immutable headers (proxied Response) — the handler's call.
134
+ }
135
+ }
136
+ applyDerivedCaching(c, res, { caching, needsSession })
137
+ return res
103
138
  } finally {
104
139
  runtime.dispose()
105
140
  }
106
141
  }
107
142
 
143
+ /** Layer 3 — derived Cache-Control, same proof as pages: no session reads
144
+ * declared, no session use/claims read while handling, handler set nothing
145
+ * itself. Applied to 200 and 304 alike (a 304's headers update the cached
146
+ * entry's lifetime). */
147
+ function applyDerivedCaching(
148
+ c: Context,
149
+ res: Response,
150
+ opts: { caching?: { sMaxAge: number; staleWhileRevalidate: number }; needsSession: boolean },
151
+ ): void {
152
+ const use = sessionUse(c)
153
+ if (
154
+ !opts.caching ||
155
+ opts.needsSession ||
156
+ use.used ||
157
+ use.claimsRead ||
158
+ res.status >= 400 ||
159
+ res.headers.has('cache-control')
160
+ ) {
161
+ return
162
+ }
163
+ try {
164
+ res.headers.set(
165
+ 'cache-control',
166
+ `public, max-age=0, s-maxage=${opts.caching.sMaxAge}, stale-while-revalidate=${opts.caching.staleWhileRevalidate}`,
167
+ )
168
+ } catch {
169
+ // Immutable headers (proxied Response) — the handler's call.
170
+ }
171
+ }
172
+
108
173
  /** The URL's extension (`/rss.xml` → `.xml`). A leading dot alone
109
174
  * (`/.well-known`) is not an extension. */
110
175
  function urlExtension(path: string): string | undefined {
@@ -139,6 +139,7 @@ export function makeScope(prefix: string): Scope {
139
139
  }
140
140
 
141
141
  export interface RenderState {
142
+ images?: import('./images.ts').ImagesRenderInfo
142
143
  sessionId: SessionId
143
144
  routeKey: string
144
145
  bindings: Map<SlotId, Binding>
@@ -186,6 +187,14 @@ export function createRenderState(sessionId: SessionId, routeKey: string): Rende
186
187
 
187
188
  let currentRenderState: RenderState | null = null
188
189
 
190
+ /** The images config as the render side sees it (set by renderRoute from
191
+ * HttpConfig). Components read it so their srcset widths and crop aspects
192
+ * can't drift from what the endpoint accepts; null outside a configured
193
+ * render (tests, apps without `images`). */
194
+ export function currentImages(): import('./images.ts').ImagesRenderInfo | null {
195
+ return currentRenderState?.images ?? null
196
+ }
197
+
189
198
  export function getCurrentRenderState(): RenderState | null {
190
199
  return currentRenderState
191
200
  }
@@ -95,6 +95,8 @@ export interface RenderRouteOptions {
95
95
  * passes false: it runs under the session lock and must not kick defer I/O;
96
96
  * its defer slots emit inert placeholders that the diff never touches. */
97
97
  resolveDeferred?: boolean
98
+ /** Endpoint widths/aspects for `<Image>`/`<Picture>` (see RenderState.images). */
99
+ images?: import('./images.ts').ImagesRenderInfo
98
100
  }
99
101
 
100
102
  export async function renderRoute(
@@ -107,6 +109,7 @@ export async function renderRoute(
107
109
  ): Promise<RenderResult> {
108
110
  const state = createRenderState(sessionId, routeKey)
109
111
  state.resolveDeferred = opts.resolveDeferred ?? true
112
+ state.images = opts.images
110
113
  const { ctx: responseCtx, effects } = createResponseContext()
111
114
  const renderCtx: RouteRenderContext = {
112
115
  response: responseCtx,
@@ -35,6 +35,14 @@ export interface DiscoveredRoute {
35
35
  PUT?: ApiRouteDefinition
36
36
  PATCH?: ApiRouteDefinition
37
37
  DELETE?: ApiRouteDefinition
38
+ /** True for a `404.stator`/`404.ts` file — sugar for a `[...name]`
39
+ * catch-all (same synthesized pattern, same matcher), except
40
+ * `sortRoutes` forces it to the absolute lowest priority: below even a
41
+ * real `[...name]` at the same depth. It's the last resort, only ever
42
+ * reached once nothing else — including a genuine catch-all a request
43
+ * matched first — could actually serve the request (a 404 cascades by
44
+ * default; see the GET dispatch loop in http.ts). */
45
+ isNotFoundFallback?: boolean
38
46
  }
39
47
 
40
48
  /**
@@ -47,6 +55,11 @@ export interface DiscoveredRoute {
47
55
  * - `routes/[a]/[b].ts` → `/:a/:b`
48
56
  * - `routes/foo/[...rest].ts` → `/foo/*rest` (catch-all: terminal, matches
49
57
  * zero+ segments, param is the raw remainder incl. slashes)
58
+ * - `routes/foo/404.ts` → sugar for `routes/foo/[...___].ts` — a
59
+ * catch-all without needing to know the bracket-rest convention, EXCEPT
60
+ * `sortRoutes` also forces it to the absolute lowest match priority,
61
+ * below even a real `[...rest]` at the same depth — see
62
+ * `DiscoveredRoute.isNotFoundFallback`.
50
63
  *
51
64
  * Files may export any combination of `GET`/`POST`/`PUT`/`PATCH`/`DELETE`.
52
65
  * GET is a `defineRoute` (page renderer) or a `defineApiRoute` declaring
@@ -123,11 +136,12 @@ export async function discoverRoutes(
123
136
  continue
124
137
  }
125
138
 
126
- const { urlPath, paramNames } = filePathToRoute(absDir, filePath)
139
+ const { urlPath, paramNames, isNotFoundFallback } = filePathToRoute(absDir, filePath)
127
140
  routes.push({
128
141
  urlPath,
129
142
  paramNames,
130
143
  filePath,
144
+ ...(isNotFoundFallback ? { isNotFoundFallback } : {}),
131
145
  GET: get,
132
146
  POST: post,
133
147
  PUT: put,
@@ -169,6 +183,10 @@ function mergeByUrlPath(routes: DiscoveredRoute[]): DiscoveredRoute[] {
169
183
  /**
170
184
  * Sort routes most-specific-first (Astro's model). At match time the first
171
185
  * matcher that matches wins, so order encodes priority:
186
+ * - a `404.stator`/`404.ts` file (`isNotFoundFallback`) ranks absolute
187
+ * last, below even a real `[...name]` at the same depth — it's the
188
+ * final resort, only reached once nothing else (a genuine catch-all
189
+ * included) could serve the request
172
190
  * - routes without a rest/catch-all segment rank before those with one
173
191
  * - per segment, left to right: static (0) > suffixed param `:id.json` (1)
174
192
  * > bare param (2) > rest (3) — a suffixed param outranks a bare one so
@@ -186,6 +204,10 @@ export function sortRoutes(routes: DiscoveredRoute[]): DiscoveredRoute[] {
186
204
  )
187
205
 
188
206
  return [...routes].sort((a, b) => {
207
+ const aFallback = !!a.isNotFoundFallback
208
+ const bFallback = !!b.isNotFoundFallback
209
+ if (aFallback !== bFallback) return aFallback ? 1 : -1 // 404 fallback always last
210
+
189
211
  const ka = kinds(a.urlPath)
190
212
  const kb = kinds(b.urlPath)
191
213
  const aRest = ka.includes(3)
@@ -230,7 +252,7 @@ async function walkRouteFiles(dir: string): Promise<string[]> {
230
252
  export function filePathToRoute(
231
253
  absDir: string,
232
254
  filePath: string,
233
- ): { urlPath: string; paramNames: string[] } {
255
+ ): { urlPath: string; paramNames: string[]; isNotFoundFallback: boolean } {
234
256
  const ext = extname(filePath)
235
257
  const rel = relative(absDir, filePath)
236
258
  const dirSegments = dirname(rel)
@@ -240,9 +262,18 @@ export function filePathToRoute(
240
262
  // sibling `<name>.stator.ts` files, which must map to the same URL as the
241
263
  // `<name>.stator` source did in dev.
242
264
  const fileBase = basename(rel, ext).replace(/\.stator$/, '')
243
- const segments = fileBase === 'index' ? dirSegments : [...dirSegments, fileBase]
265
+ // `404` is sugar for a synthesized `[...name]` at this exact position —
266
+ // reuses the rest-segment branch below verbatim, so it gets the same
267
+ // matcher/regex a real catch-all would. `sortRoutes` is what actually
268
+ // forces it below a real `[...name]` at the same depth; this alone would
269
+ // just be an ordinary (if oddly-named) catch-all.
270
+ const isNotFoundFallback = fileBase === '404'
271
+ const segments =
272
+ fileBase === 'index'
273
+ ? dirSegments
274
+ : [...dirSegments, isNotFoundFallback ? '[...__stator_notfound__]' : fileBase]
244
275
 
245
- if (segments.length === 0) return { urlPath: '/', paramNames: [] }
276
+ if (segments.length === 0) return { urlPath: '/', paramNames: [], isNotFoundFallback }
246
277
 
247
278
  const paramNames: string[] = []
248
279
  const urlSegments = segments.map((seg) => {
@@ -272,5 +303,5 @@ export function filePathToRoute(
272
303
  return seg
273
304
  })
274
305
 
275
- return { urlPath: `/${urlSegments.join('/')}`, paramNames }
306
+ return { urlPath: `/${urlSegments.join('/')}`, paramNames, isNotFoundFallback }
276
307
  }