@statorjs/stator 2.10.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statorjs/stator",
3
- "version": "2.10.0",
3
+ "version": "2.11.0-next.0",
4
4
  "description": "Server-canonical web framework: business logic in composable state machines, UI as a thin renderer binding machine outputs to DOM positions. Ships TypeScript source, loaded natively by its own toolchain — no build step.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -82,6 +82,7 @@
82
82
  "postcss": "^8.5.15",
83
83
  "postcss-selector-parser": "^7.1.4",
84
84
  "schema-dts": "^1.1.5",
85
+ "sharp": "^0.33.5",
85
86
  "typescript": "^5.6.0",
86
87
  "zod": "^3.23.0"
87
88
  },
@@ -99,7 +100,7 @@
99
100
  "pino-pretty": "^13.1.3",
100
101
  "vite": "^6.0.0",
101
102
  "vitest": "^2.1.0",
102
- "@statorjs/stator": "2.10.0"
103
+ "@statorjs/stator": "2.11.0-next.0"
103
104
  },
104
105
  "scripts": {
105
106
  "typecheck": "tsc --noEmit",
@@ -20,6 +20,7 @@ export async function run(ctx: CliContext): Promise<void> {
20
20
  persistence: config.persistence,
21
21
  sessions: config.sessions,
22
22
  dev: config.dev,
23
+ images: config.images,
23
24
  trustedOrigins: config.trustedOrigins,
24
25
  origin: config.origin,
25
26
  cors: config.cors,
@@ -90,6 +90,7 @@ export async function run(ctx: CliContext): Promise<void> {
90
90
  persistence: config.persistence,
91
91
  sessions: config.sessions,
92
92
  realtime: config.realtime,
93
+ images: config.images,
93
94
  trustedOrigins: config.trustedOrigins,
94
95
  origin: config.origin,
95
96
  host: config.host,
@@ -0,0 +1,260 @@
1
+ import { DEFAULT_IMAGE_WIDTHS } from '../server/images.ts'
2
+ import { currentImages } from '../server/render-context.ts'
3
+ import { html } from '../template/html.ts'
4
+ import type { HtmlFragment } from '../template/types.ts'
5
+
6
+ /**
7
+ * `getImage()` + `<Image>`/`<Picture>` — the render half of framework image
8
+ * support (spec `images-are-part-of-stator-*`; endpoint in `server/images.ts`).
9
+ *
10
+ * `src` is the PUBLIC URL of the original: an image-endpoint path
11
+ * (`/media/2026/08/x.jpg`) gets derived variants — the endpoint's contract is
12
+ * that the URL's extension is the delivery format and `?w=` resizes — while a
13
+ * remote URL (`http…`) passes through untouched (the framework never proxies
14
+ * or transforms remote origins).
15
+ *
16
+ * Dimensions are REQUIRED, always, and always come from the caller: the
17
+ * framework never probes at render (renders are synchronous; intrinsic size
18
+ * is write-time data your upload handler stores via `probeImage`). Provide
19
+ * `width`+`height`, or `width`+`aspectRatio` and the height is derived. That
20
+ * rule is what makes every `<Image>` CLS-safe by construction.
21
+ */
22
+
23
+ export type ImageFormat = 'jpg' | 'png' | 'webp' | 'avif'
24
+
25
+ export type GetImageOptions = {
26
+ src: string
27
+ /** Intrinsic dimensions — BOTH required, always from data (`probeImage` at
28
+ * upload), never probed at render. That rule is what makes every `<Image>`
29
+ * CLS-safe by construction. (An earlier cut also accepted
30
+ * `width`+`aspectRatio` and derived the height; it had no consumers, and
31
+ * spending the word `aspectRatio` on "the shape my source happens to be"
32
+ * blocked it from meaning "the shape I want" — which is `crop`, below.) */
33
+ width: number
34
+ height: number
35
+ /** Delivery format for src/srcset URLs — defaults to the URL's extension. */
36
+ format?: ImageFormat
37
+ /** Candidate srcset widths; filtered to those below the intrinsic width.
38
+ * Defaults to the CONFIGURED `images.widths` when rendering inside an app
39
+ * with images configured (read from the render state — no drift), else the
40
+ * shipped default. */
41
+ widths?: number[]
42
+ /** Cover-crop the delivered variants to this ratio (width / height): `1` is
43
+ * square, `16 / 9` widescreen. The ratio must be on the endpoint's crop
44
+ * allowlist (`images.aspectRatios`) — an unlisted one throws rather than
45
+ * silently emitting URLs the endpoint would 400. Omit for the source's own
46
+ * shape. Cropping is what `<picture>` art direction does per breakpoint;
47
+ * as a plain prop it also covers the common case of one fixed geometry
48
+ * (a thumbnail grid), which previously needed an always-true media query. */
49
+ crop?: number
50
+ }
51
+
52
+ export interface ResolvedImage {
53
+ src: string
54
+ srcset: string | null
55
+ width: number
56
+ height: number
57
+ }
58
+
59
+ /** Widths for srcset candidates: the explicit prop wins, then the CONFIGURED
60
+ * endpoint allowlist (carried on the render state, so components can't emit
61
+ * a `?w=` the endpoint rejects), then the shipped default — the same one the
62
+ * endpoint defaults to, imported, not copied. */
63
+ function effectiveWidths(explicit: number[] | undefined): number[] {
64
+ return explicit ?? currentImages()?.widths ?? DEFAULT_IMAGE_WIDTHS
65
+ }
66
+
67
+ const isRemote = (src: string): boolean => /^[a-z][a-z0-9+.-]*:|^\/\//i.test(src)
68
+
69
+ const withFormat = (src: string, format: ImageFormat | undefined): string => {
70
+ if (!format) return src
71
+ const dot = src.lastIndexOf('.')
72
+ return dot === -1 ? src : `${src.slice(0, dot)}.${format}`
73
+ }
74
+
75
+ /** A crop ratio must be on the endpoint's allowlist or its URLs would 400 —
76
+ * a render error, never a silent drop. Validated once, up front, for both the
77
+ * plain `crop` prop and every art-directed source. */
78
+ function assertCropAllowed(crop: number): void {
79
+ const allowed = currentImages()?.aspectRatios
80
+ if (allowed && !allowed.some((a) => Math.abs(a - crop) < 1e-9)) {
81
+ throw new Error(
82
+ `stator: crop ${crop} is not in images.aspectRatios ` +
83
+ `(${allowed.map((a) => a.toFixed(4)).join(', ')}) — the endpoint would reject its URLs. ` +
84
+ `Add it to the config allowlist or use a listed ratio.`,
85
+ )
86
+ }
87
+ }
88
+
89
+ export function getImage(opts: GetImageOptions): ResolvedImage {
90
+ const { src, width, height, format, widths, crop } = opts
91
+ const ext = src.slice(src.lastIndexOf('.') + 1).toLowerCase()
92
+ // Remote URLs pass through; GIF and SVG serve as originals only — the
93
+ // endpoint refuses to transform either (animation doesn't survive naive
94
+ // resizing; a vector document has no pixels to resample).
95
+ if (isRemote(src) || ext === 'gif' || ext === 'svg') {
96
+ return { src, srcset: null, width, height }
97
+ }
98
+ const base = withFormat(src, format)
99
+ const candidates = effectiveWidths(widths).filter((w) => w < width)
100
+
101
+ if (crop === undefined) {
102
+ const srcset =
103
+ candidates.length > 0 ? candidates.map((w) => `${base}?w=${w} ${w}w`).join(', ') : null
104
+ return { src: base, srcset, width, height }
105
+ }
106
+
107
+ assertCropAllowed(crop)
108
+ // A crop URL must name an allowlisted width; with none below the intrinsic
109
+ // width there is no valid variant to ask for, so the original serves
110
+ // uncropped (CSS `object-fit` still covers) rather than emitting a 400.
111
+ if (candidates.length === 0) return { src: base, srcset: null, width, height }
112
+ const box = (w: number) => ({ w, h: Math.round(w / crop) })
113
+ const srcset = candidates.map((w) => `${base}?w=${w}&h=${box(w).h} ${w}w`).join(', ')
114
+ // Dimensions describe the resource actually in `src` — the largest rung —
115
+ // so the reserved box carries the CROPPED ratio, not the source's.
116
+ const largest = box(Math.max(...candidates))
117
+ return {
118
+ src: `${base}?w=${largest.w}&h=${largest.h}`,
119
+ srcset,
120
+ width: largest.w,
121
+ height: largest.h,
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Loading control: either the `priority` shorthand OR the native attributes it
127
+ * stands for, never both — `<Image priority loading="lazy">` has no defensible
128
+ * meaning, so the type refuses it instead of resolving it silently.
129
+ *
130
+ * `priority` earns its place by teaching: it says "there is one image on this
131
+ * page worth flagging", which two independent attributes don't. But it bakes
132
+ * in a hero-shaped assumption — a thumbnail grid wants the whole first row
133
+ * eager with only the first at high priority, which the rollup cannot express.
134
+ * So the underlying attributes are first-class, typed exactly as
135
+ * `HTMLAttributes<'img'>` types them (native names, native enums).
136
+ */
137
+ export type ImageLoading =
138
+ | { priority?: boolean; loading?: never; fetchpriority?: never }
139
+ | {
140
+ priority?: never
141
+ loading?: 'eager' | 'lazy'
142
+ fetchpriority?: 'high' | 'low' | 'auto'
143
+ }
144
+
145
+ export type ImageProps = GetImageOptions &
146
+ ImageLoading & {
147
+ /** Required — an image without alt text is a compile error on purpose.
148
+ * Decorative images say so explicitly with `alt=""`. */
149
+ alt: string
150
+ sizes?: string
151
+ /** Orthogonal to the priority question, so it sits outside that union —
152
+ * folding it in would repeat the over-coupling one level up. */
153
+ decoding?: 'sync' | 'async' | 'auto'
154
+ class?: string
155
+ }
156
+
157
+ export function Image(props: ImageProps): HtmlFragment {
158
+ const {
159
+ alt,
160
+ class: className,
161
+ sizes = '100vw',
162
+ priority,
163
+ loading = priority ? 'eager' : 'lazy',
164
+ fetchpriority = priority ? 'high' : undefined,
165
+ decoding = 'async',
166
+ } = props
167
+ const img = getImage(props)
168
+ return html`<img
169
+ src="${img.src}"
170
+ srcset="${img.srcset ?? undefined}"
171
+ sizes="${img.srcset ? sizes : undefined}"
172
+ alt="${alt}"
173
+ width="${img.width}"
174
+ height="${img.height}"
175
+ loading="${loading}"
176
+ fetchpriority="${fetchpriority}"
177
+ decoding="${decoding}"
178
+ class="${className}"
179
+ />`
180
+ }
181
+
182
+ /** An art-directed source: under `media`, serve a different GEOMETRY — the
183
+ * `crop` ratio the endpoint cover-crops to, same vocabulary and same
184
+ * allowlist as the plain `crop` prop. Reach for `sources` when the geometry
185
+ * VARIES by breakpoint; a single fixed crop is just `crop` on the component. */
186
+ export interface PictureSource {
187
+ media: string
188
+ /** width / height of the crop. `1` = square, `16 / 9` = widescreen. */
189
+ crop: number
190
+ sizes?: string
191
+ widths?: number[]
192
+ }
193
+
194
+ export type PictureProps = ImageProps & {
195
+ /** Modern-format `<source>` chain, most-preferred first. The stored format
196
+ * stays the `<img>` fallback. */
197
+ formats?: ImageFormat[]
198
+ /** Art direction — this is where `<picture>` earns its difficulty: each
199
+ * entry crosses with every format (media × type sources, most specific
200
+ * first), and the plain-format chain follows for non-matching viewports. */
201
+ sources?: PictureSource[]
202
+ }
203
+
204
+ const SOURCE_TYPES: Record<ImageFormat, string> = {
205
+ jpg: 'image/jpeg',
206
+ png: 'image/png',
207
+ webp: 'image/webp',
208
+ avif: 'image/avif',
209
+ }
210
+
211
+ export function Picture(props: PictureProps): HtmlFragment {
212
+ const { formats = ['avif', 'webp'], sources: artSources = [], sizes = '100vw', ...rest } = props
213
+ // The fallback <img> is built from the same props the sources were — `crop`
214
+ // included, so its reserved box describes the cropped output rather than the
215
+ // source's own shape.
216
+ const imageProps = { ...rest, sizes } as ImageProps
217
+ if (isRemote(rest.src)) return Image(imageProps)
218
+
219
+ const storedExt = rest.src.slice(rest.src.lastIndexOf('.') + 1).toLowerCase() as ImageFormat
220
+
221
+ // Art-directed sources come FIRST (the browser takes the first media+type
222
+ // match), each crossed with every modern format plus the stored format —
223
+ // without the stored-format row, a browser supporting none of the modern
224
+ // formats would fall past the media condition to the uncropped fallback.
225
+ // Cropping is just `getImage` with a different `crop`, so art direction and
226
+ // the plain chain share one code path.
227
+ const art = artSources.flatMap((source) =>
228
+ [...formats, storedExt]
229
+ .filter((f, i, all) => all.indexOf(f) === i && f in SOURCE_TYPES)
230
+ .map((format) => ({
231
+ media: source.media,
232
+ format: format as ImageFormat,
233
+ srcset: getImage({
234
+ ...imageProps,
235
+ format: format as ImageFormat,
236
+ crop: source.crop,
237
+ widths: source.widths ?? rest.widths,
238
+ }).srcset,
239
+ sizes: source.sizes ?? sizes,
240
+ }))
241
+ .filter((s) => s.srcset !== null),
242
+ )
243
+ const plain = formats
244
+ .map((format) => ({
245
+ media: undefined,
246
+ format,
247
+ srcset: getImage({ ...imageProps, format }).srcset,
248
+ sizes,
249
+ }))
250
+ .filter((s) => s.srcset !== null)
251
+
252
+ const all = [...art, ...plain]
253
+ if (all.length === 0) return Image(imageProps)
254
+ return html`<picture
255
+ >${all.map(
256
+ (s) =>
257
+ html`<source media="${s.media ?? undefined}" type="${SOURCE_TYPES[s.format]}" srcset="${s.srcset}" sizes="${s.sizes}" />`,
258
+ )}${Image(imageProps)}</picture
259
+ >`
260
+ }
@@ -1,2 +1,12 @@
1
+ export type {
2
+ GetImageOptions,
3
+ ImageFormat,
4
+ ImageLoading,
5
+ ImageProps,
6
+ PictureProps,
7
+ PictureSource,
8
+ ResolvedImage,
9
+ } from './images.ts'
10
+ export { getImage, Image, Picture } from './images.ts'
1
11
  export type { JsonLdProps } from './json-ld.ts'
2
12
  export { JsonLd, ldToString } from './json-ld.ts'
package/src/config.ts CHANGED
@@ -119,6 +119,54 @@ export interface StatorConfig {
119
119
  * Precedence: `LOG_LEVEL` env > this > default. */
120
120
  level?: LogLevel
121
121
  }
122
+ /** Image serving. Present → the framework mounts an image endpoint over
123
+ * `dir` (originals + on-demand variants: the URL's extension is the
124
+ * delivery format, `?w=` resizes from the allowlist). Absent → no routes,
125
+ * no transformer loaded — image-free apps pay nothing. */
126
+ images?: {
127
+ /** Directory holding the original images (dated subpaths welcome) —
128
+ * runtime-written data like a SQLite file, NOT under `static/` (builds
129
+ * recreate `dist/static`). Relative paths resolve against the cwd. */
130
+ dir: string
131
+ /** URL prefix the endpoint serves under. Default: `/<basename(dir)>`
132
+ * (`dir: 'media'` → `/media`). */
133
+ path?: string
134
+ /** The `?w=` allowlist, and the default `srcset` widths `getImage`
135
+ * emits. An allowlist because an open resize parameter is a
136
+ * denial-of-service invitation. Default: `[400, 800, 1200, 1600]`. */
137
+ widths?: number[]
138
+ /** Crop aspect allowlist (width/height) for `?w=&h=` cover-crops and
139
+ * `<Picture>` art direction. Default: square, 4:3, 3:2, 16:9 and their
140
+ * portrait duals. The variant space stays |widths| x |aspectRatios|. */
141
+ aspectRatios?: number[]
142
+ /** Swap the transformer (default: sharp). See `ImageTransformer`. */
143
+ transformer?: import('./server/images.ts').ImageTransformer
144
+ /** Max concurrent encodes across ALL variants (default: 2). A cold-cache
145
+ * gallery page fans out many transforms at once; the semaphore keeps a
146
+ * small host from OOMing under AVIF encodes. */
147
+ concurrency?: number
148
+ /** libvips worker threads per encode (default: sharp's default, which is
149
+ * the REPORTED core count — on a shared-cpu host that's the physical
150
+ * machine's, so a fractional vCPU fans out 8+ encoder threads. Set `1`
151
+ * on small hosts.) */
152
+ threads?: number
153
+ /** Per-request encode deadline in ms (default: 15000; 0 disables). Past
154
+ * it the response 302s to the stored original — real pixels now — while
155
+ * the encode keeps filling the variant cache for the next request. */
156
+ encodeTimeoutMs?: number
157
+ /** Freshness lifetime in seconds (default: 0 — every use revalidates via
158
+ * the content-hash ETag; a cheap 304 per image per view). */
159
+ maxAge?: number
160
+ /** Serve-stale window in seconds (default: 0). >0 emits
161
+ * `stale-while-revalidate` — repeat views render instantly from cache
162
+ * while the browser revalidates in the background; a changed image heals
163
+ * on the NEXT view. The bounded-regret middle of the dial. */
164
+ staleWhileRevalidate?: number
165
+ /** Appends `immutable` to a nonzero maxAge. NO server-side recovery
166
+ * exists once an immutable response is cached — only for apps whose
167
+ * image URLs are write-once by construction (replacement = new URL). */
168
+ immutable?: boolean
169
+ }
122
170
  // observers?: Observer[] — top-level when the observability spec lands.
123
171
  }
124
172
 
@@ -12,6 +12,7 @@ import { discoverMachines } from './discovery.ts'
12
12
  import { wireAppEffects } from './effects.ts'
13
13
  import { loadDotenv } from './env.ts'
14
14
  import { buildHonoApp } from './http.ts'
15
+ import { type ImageTransformer, resolveImagesConfig } from './images.ts'
15
16
  import { logger, setLogLevel } from './logger.ts'
16
17
  import { MachineStore } from './machine-store.ts'
17
18
  import { discoverMiddleware } from './middleware.ts'
@@ -61,6 +62,22 @@ export interface CreateAppConfig extends DeprecatedFlatConfig {
61
62
  * `LOG_LEVEL` env takes precedence over this. */
62
63
  level?: LogLevel
63
64
  }
65
+ /** Image serving — present mounts the image endpoint over `images.dir`
66
+ * (extension = delivery format, `?w=` allowlist, disk-cached variants).
67
+ * Absent → no routes, no transformer loaded. Mirrors `StatorConfig.images`. */
68
+ images?: {
69
+ dir: string
70
+ path?: string
71
+ widths?: number[]
72
+ aspectRatios?: number[]
73
+ transformer?: ImageTransformer
74
+ concurrency?: number
75
+ threads?: number
76
+ encodeTimeoutMs?: number
77
+ maxAge?: number
78
+ staleWhileRevalidate?: number
79
+ immutable?: boolean
80
+ }
64
81
  /** Origins allowed to make cross-site writes despite the CSRF guard (exact or
65
82
  * wildcard-subdomain). Mirrors `StatorConfig.trustedOrigins`. */
66
83
  trustedOrigins?: readonly string[]
@@ -147,7 +164,9 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
147
164
  : undefined
148
165
  const bootDef = config.bootFile ? await discoverBoot(config.bootFile) : undefined
149
166
  const inspector = resolved.inspector
167
+ const images = config.images ? resolveImagesConfig(config.images) : undefined
150
168
  const app = await buildHonoApp({
169
+ images,
151
170
  routes,
152
171
  store,
153
172
  staticDir,
@@ -29,6 +29,7 @@ import { discoverMachines } from './discovery.ts'
29
29
  import { wireAppEffects } from './effects.ts'
30
30
  import { loadDotenv } from './env.ts'
31
31
  import { buildHonoApp, contentTypeFor } from './http.ts'
32
+ import { resolveImagesConfig } from './images.ts'
32
33
  import { logger, setLogLevel } from './logger.ts'
33
34
  import { codeHashOf, codeInputsOf } from './machine-hash.ts'
34
35
  import { MachineStore } from './machine-store.ts'
@@ -133,6 +134,7 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
133
134
  const machinesDir = real(resolve(config.machinesDir))
134
135
  const routesDir = real(resolve(config.routesDir))
135
136
  const staticDir = config.staticDir ? real(resolve(config.staticDir)) : resolve(root, 'static')
137
+ const images = config.images ? resolveImagesConfig(config.images) : undefined
136
138
  // One build-id per successful BUILD, not per process. Connected pages reload
137
139
  // through the dev signal, but a page whose channel was released while it sat
138
140
  // in the background (or dropped over a blip) never sees that signal — it
@@ -428,6 +430,7 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
428
430
  const middleware = await discoverMiddleware(resolve(root, 'middleware.ts'), bust)
429
431
  await remapRoutes()
430
432
  app = await buildHonoApp({
433
+ images,
431
434
  routes,
432
435
  store,
433
436
  staticDir,
@@ -13,6 +13,7 @@ import { findFreePort, installGracefulShutdown, printDevBanner } from './banner.
13
13
  import { resolveAppConfig } from './config-compat.ts'
14
14
  import type { DevApp, DevAppConfig } from './dev.ts'
15
15
  import { loadDotenv } from './env.ts'
16
+ import { resolveImagesConfig } from './images.ts'
16
17
  import { logger, setLogLevel } from './logger.ts'
17
18
  import type { MachineStore } from './machine-store.ts'
18
19
  import type { DiscoveredRoute } from './route-discovery.ts'
@@ -73,6 +74,7 @@ export async function createViteDevApp(config: DevAppConfig): Promise<DevApp> {
73
74
  const loader = (file: string) => vite.ssrLoadModule(file) as Promise<Record<string, unknown>>
74
75
 
75
76
  const root = resolve(config.root)
77
+ const images = config.images ? resolveImagesConfig(config.images) : undefined
76
78
  const machinesDir = resolve(config.machinesDir)
77
79
  const routesDir = resolve(config.routesDir)
78
80
  const resolved = resolveAppConfig(config)
@@ -201,6 +203,7 @@ export async function createViteDevApp(config: DevAppConfig): Promise<DevApp> {
201
203
  routes,
202
204
  store,
203
205
  staticDir: config.staticDir,
206
+ images,
204
207
  headExtras,
205
208
  inspector: inspectorOn,
206
209
  inspect: true,
package/src/server/dev.ts CHANGED
@@ -41,6 +41,20 @@ export interface DevAppConfig {
41
41
  cookie?: { sameSite?: 'Lax' | 'Strict' }
42
42
  }
43
43
  /** Dev-only tooling. */
44
+ /** Image serving — mirrors `StatorConfig.images`. */
45
+ images?: {
46
+ dir: string
47
+ path?: string
48
+ widths?: number[]
49
+ aspectRatios?: number[]
50
+ transformer?: import('./images.ts').ImageTransformer
51
+ concurrency?: number
52
+ threads?: number
53
+ encodeTimeoutMs?: number
54
+ maxAge?: number
55
+ staleWhileRevalidate?: number
56
+ immutable?: boolean
57
+ }
44
58
  dev?: {
45
59
  /** Auto-inject the dev inspector toolbar. On by default; set false to disable. */
46
60
  inspector?: boolean
@@ -9,6 +9,7 @@ import { applyRenderedEffects, runApiRoute } from './api-route.ts'
9
9
  import { crossSiteGuard } from './csrf.ts'
10
10
  import { scheduleSessionEffects } from './effects.ts'
11
11
  import { record, replayFor } from './event-dedupe.ts'
12
+ import { type ImagesRenderInfo, type ResolvedImagesConfig, serveImage } from './images.ts'
12
13
  import { buildInspectPayload } from './inspect.ts'
13
14
  import { scopedLogger } from './logger.ts'
14
15
  import type { MachineStore } from './machine-store.ts'
@@ -37,6 +38,9 @@ export interface HttpConfig {
37
38
  routes: DiscoveredRoute[]
38
39
  store: MachineStore
39
40
  staticDir?: string
41
+ /** Resolved image-serving config — present mounts the endpoint at
42
+ * `images.path` (see server/images.ts). */
43
+ images?: ResolvedImagesConfig
40
44
  /** Optional hook to inject extra `<head>` HTML for a GET route, keyed by the
41
45
  * route's file path. The dev server uses this to inline collected scoped CSS
42
46
  * (SSR head injection). Inserted at the `</head>` boundary. */
@@ -233,6 +237,11 @@ function parseRouteKey(
233
237
 
234
238
  export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
235
239
  const app = new Hono()
240
+ // Render-side view of the images config: `<Image>`/`<Picture>` read this
241
+ // from the render state so their URLs can't drift from the endpoint.
242
+ const imagesInfo = config.images
243
+ ? { widths: config.images.widths, aspectRatios: config.images.aspectRatios }
244
+ : undefined
236
245
  const clientJs = await bundleClient()
237
246
 
238
247
  // Request logger: one line per request with method, path, status, duration.
@@ -408,6 +417,25 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
408
417
  })
409
418
  }
410
419
 
420
+ if (config.images) {
421
+ const images = config.images
422
+ app.get(`${images.path}/*`, (c) => {
423
+ let rel: string
424
+ try {
425
+ rel = decodeURIComponent(c.req.path.slice(images.path.length + 1))
426
+ } catch {
427
+ return c.text('not found', 404) // malformed percent-encoding is a 404, not a 500
428
+ }
429
+ return serveImage(
430
+ images,
431
+ rel,
432
+ c.req.query('w'),
433
+ c.req.query('h'),
434
+ c.req.header('if-none-match') ?? null,
435
+ )
436
+ })
437
+ }
438
+
411
439
  // SSE endpoint. The connection's runtime + renderState stay alive for
412
440
  // the connection's lifetime — this is the one place per-session state
413
441
  // outlives a request, because the connection *is* one (very long) request.
@@ -460,7 +488,9 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
460
488
 
461
489
  const runtime = new SessionRuntime(sessionId, config.store)
462
490
  await runtime.loadGraph(route.reads)
463
- const { renderState } = await renderRoute(route, routeKey, sessionId, runtime, request)
491
+ const { renderState } = await renderRoute(route, routeKey, sessionId, runtime, request, {
492
+ images: imagesInfo,
493
+ })
464
494
 
465
495
  // The handler parks on `finished` until the connection ends. Both ways
466
496
  // out resolve it: the client aborting, and the server hanging up on a
@@ -616,6 +646,7 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
616
646
  // resolve defer slots here (that would kick their I/O under the lock).
617
647
  const { renderState } = await renderRoute(route, routeKey, sessionId, runtime, request, {
618
648
  resolveDeferred: false,
649
+ images: imagesInfo,
619
650
  })
620
651
 
621
652
  const touched = runtime.processEvent(body.machine, body.event)
@@ -726,6 +757,7 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
726
757
  config.headExtras,
727
758
  config.buildId,
728
759
  config.caching,
760
+ imagesInfo,
729
761
  )
730
762
  if (candidate.status !== 404) return candidate.commit()
731
763
  lastNotFound = candidate.commit
@@ -820,6 +852,7 @@ async function renderGetCandidate(
820
852
  headExtras?: (filePath: string) => string | Promise<string>,
821
853
  buildId?: string,
822
854
  caching?: { sMaxAge: number; staleWhileRevalidate: number },
855
+ images?: ImagesRenderInfo,
823
856
  ): Promise<GetCandidate> {
824
857
  // Lazy layer 1: only a session-machine read forces establishment. An
825
858
  // app-only (or read-free) page renders against the resumed sid when a
@@ -836,7 +869,7 @@ async function renderGetCandidate(
836
869
  const runtime = new SessionRuntime(sessionId, store)
837
870
  try {
838
871
  await runtime.loadGraph(route.reads)
839
- const result = await renderRoute(route, routeKey, sessionId, runtime, request)
872
+ const result = await renderRoute(route, routeKey, sessionId, runtime, request, { images })
840
873
  let html = result.html
841
874
 
842
875
  const headHtml: string[] = []
@@ -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,