@statorjs/stator 2.10.0 → 2.11.0-next.1

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'
@@ -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,