@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statorjs/stator",
3
- "version": "2.10.0-next.1",
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",
@@ -27,6 +27,10 @@
27
27
  "stator": "./src/cli/stator.js"
28
28
  },
29
29
  "exports": {
30
+ ".": {
31
+ "types": "./src/index.ts",
32
+ "default": "./src/index.ts"
33
+ },
30
34
  "./server": {
31
35
  "types": "./src/server/index.ts",
32
36
  "default": "./src/server/index.ts"
@@ -78,6 +82,7 @@
78
82
  "postcss": "^8.5.15",
79
83
  "postcss-selector-parser": "^7.1.4",
80
84
  "schema-dts": "^1.1.5",
85
+ "sharp": "^0.33.5",
81
86
  "typescript": "^5.6.0",
82
87
  "zod": "^3.23.0"
83
88
  },
@@ -95,7 +100,7 @@
95
100
  "pino-pretty": "^13.1.3",
96
101
  "vite": "^6.0.0",
97
102
  "vitest": "^2.1.0",
98
- "@statorjs/stator": "2.10.0-next.1"
103
+ "@statorjs/stator": "2.11.0-next.0"
99
104
  },
100
105
  "scripts": {
101
106
  "typecheck": "tsc --noEmit",
@@ -20,9 +20,12 @@ 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,
27
+ secret: config.secret,
28
+ logging: config.logging,
26
29
  })
27
30
  await app.listen(resolvePort(ctx.portFlag, config.port))
28
31
  }
@@ -90,11 +90,14 @@ 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,
96
97
  secret: config.secret,
97
98
  cors: config.cors,
99
+ logging: config.logging,
100
+ caching: config.caching,
98
101
  headExtras,
99
102
  buildId,
100
103
  machineHashes: machines,
@@ -277,6 +277,47 @@ export function lowerTemplate(template: string, opts: LowerOptions = {}): string
277
277
  }
278
278
  checkNoReadInDefer(fragment, false)
279
279
 
280
+ // Build-time gate for RCDATA elements: inside <textarea> and <title> the
281
+ // parser treats children as RAW TEXT — a live-slot <span> (text-position
282
+ // read()) or a region comment marker (each/when/match/defer) is rendered as
283
+ // literal markup instead of becoming an element. Found in the wild: a
284
+ // textarea pre-filled via read() displayed `<span data-slot="…"></span>` to
285
+ // the user. Attributes on the element itself stay legal (attribute patches
286
+ // need no wrapper) — the gate covers children only.
287
+ const RCDATA_BINDINGS = new Set(['read', 'each', 'when', 'match', 'defer'])
288
+ const checkNoBindingInRcdata = (node: ts.Node, rcdataTag: string | null): void => {
289
+ if (
290
+ rcdataTag &&
291
+ ts.isCallExpression(node) &&
292
+ ts.isIdentifier(node.expression) &&
293
+ RCDATA_BINDINGS.has(node.expression.text)
294
+ ) {
295
+ const callee = node.expression.text
296
+ throw new CompileError(
297
+ `stator: ${callee}() cannot appear inside <${rcdataTag}> — its content is raw text ` +
298
+ `(RCDATA), so the ${callee === 'read' ? 'live-slot wrapper' : 'region markers'} would ` +
299
+ `render as literal markup. Interpolate a static value instead (a selector property ` +
300
+ `like {machine.someSelector}, or a frontmatter constant); if the value must be live, ` +
301
+ `bind an attribute or use an island.`,
302
+ loc(node),
303
+ )
304
+ }
305
+ if (ts.isJsxElement(node)) {
306
+ const opening = node.openingElement
307
+ const tagName = ts.isIdentifier(opening.tagName) ? opening.tagName.text : null
308
+ const isRcdata = tagName === 'textarea' || tagName === 'title'
309
+ // Attributes keep the OUTER context; children enter the RCDATA scope.
310
+ checkNoBindingInRcdata(opening, rcdataTag)
311
+ for (const child of node.children) {
312
+ checkNoBindingInRcdata(child, isRcdata ? tagName : rcdataTag)
313
+ }
314
+ checkNoBindingInRcdata(node.closingElement, rcdataTag)
315
+ return
316
+ }
317
+ ts.forEachChild(node, (child) => checkNoBindingInRcdata(child, rcdataTag))
318
+ }
319
+ checkNoBindingInRcdata(fragment, null)
320
+
280
321
  // Build-time gate for item-read placement: a `read(<item>, …)` binding is
281
322
  // OWNED by its each() row — the row render supplies the item and collects the
282
323
  // binding, and the list's recompute is what re-diffs it. Three positions break
@@ -102,7 +102,7 @@ const STATOR_AMBIENT = `declare const Stator: {
102
102
  reads<const T extends readonly __SMachineDef[]>(defs: T): { -readonly [K in keyof T]: __SInstanceOf<T[K]> };
103
103
  forwarded(name: \`on:\${string}\`): (() => unknown) | undefined;
104
104
  request: any;
105
- response: { status: number; headers: Record<string, string>; cookies: any };
105
+ response: { status: number; headers: Headers; cookies: any };
106
106
  };
107
107
  declare global {
108
108
  namespace JSX {
@@ -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
@@ -97,12 +97,76 @@ export interface StatorConfig {
97
97
  * Opt in per route/app with the `cors()` middleware; `origins` defaults to
98
98
  * `trustedOrigins`. */
99
99
  cors?: { origins?: string[]; credentials?: boolean }
100
+ /** Derived Cache-Control (the cacheable read path, layer 3): on GET
101
+ * responses the framework can PROVE anonymous-identical — every declared
102
+ * read app-lifecycle, no session use or claims read while handling — it
103
+ * emits `Cache-Control: public, s-maxage, stale-while-revalidate` so a
104
+ * CDN can absorb anonymous traffic. Hand-set headers always win; pages
105
+ * that read session machines are never marked. On by default under
106
+ * `stator start` (60s / 300s); set to `false` to disable; dev never
107
+ * emits (editing must always re-render). */
108
+ caching?:
109
+ | {
110
+ /** Shared-cache lifetime in seconds. Default: 60. */
111
+ sMaxAge?: number
112
+ /** Serve-stale-while-refreshing window in seconds. Default: 300. */
113
+ staleWhileRevalidate?: number
114
+ }
115
+ | false
100
116
  /** Logging policy. */
101
117
  logging?: {
102
118
  /** Minimum level to emit. Default: `warn` in production, `info` in dev.
103
119
  * Precedence: `LOG_LEVEL` env > this > default. */
104
120
  level?: LogLevel
105
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
+ }
106
170
  // observers?: Observer[] — top-level when the observability spec lands.
107
171
  }
108
172
 
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ // Root export = the config-authoring surface only. `stator.config.ts` is the
2
+ // first file anyone writes against the bare package name, and it must never
3
+ // drag server-only dependencies (sharp, ioredis) into a config load — anything
4
+ // beyond config keeps its own subpath.
5
+
6
+ export type { LogLevel, StatorConfig } from './config.ts'
7
+ export { defineConfig } from './config.ts'
@@ -15,7 +15,7 @@ import type {
15
15
  Directive,
16
16
  RouteRequest,
17
17
  } from './routing.ts'
18
- import { getOrCreateSessionId, getSessionState, rotateSessionNow } from './session.ts'
18
+ import { getOrCreateSessionId, getSessionState, rotateSessionNow, sessionUse } from './session.ts'
19
19
  import { withSessionLock } from './session-lock.ts'
20
20
  import { SessionRuntime } from './session-runtime.ts'
21
21
  import { fanOut } from './sse.ts'
@@ -76,7 +76,10 @@ export async function runApiRoute(
76
76
  clearSession: () => {
77
77
  rotation = { clear: true }
78
78
  },
79
- claims: <T = unknown>() => getSessionState(c)?.claims as T | undefined,
79
+ claims: <T = unknown>() => {
80
+ sessionUse(c).claimsRead = true
81
+ return getSessionState(c)?.claims as T | undefined
82
+ },
80
83
  setClaims: (claims) => {
81
84
  const s = getSessionState(c)
82
85
  if (s) {
@@ -34,6 +34,7 @@ interface NestedReads {
34
34
  host?: string
35
35
  secret?: string
36
36
  cors?: { origins?: readonly string[]; credentials?: boolean }
37
+ caching?: { sMaxAge?: number; staleWhileRevalidate?: number } | false
37
38
  }
38
39
 
39
40
  /** The flat internal values `createApp`/`createDevApp` actually consume. */
@@ -51,6 +52,8 @@ export interface ResolvedAppConfig {
51
52
  /** Signed-cookie signing key. `config.secret` ?? `STATOR_SECRET`. */
52
53
  secret?: string
53
54
  cors?: { origins: readonly string[]; credentials: boolean }
55
+ /** Derived Cache-Control knobs; `false` disables emission entirely. */
56
+ caching?: { sMaxAge?: number; staleWhileRevalidate?: number } | false
54
57
  }
55
58
 
56
59
  /**
@@ -93,5 +96,6 @@ export function resolveAppConfig(config: NestedReads & DeprecatedFlatConfig): Re
93
96
  credentials: config.cors.credentials ?? false,
94
97
  }
95
98
  : undefined,
99
+ caching: config.caching,
96
100
  }
97
101
  }
@@ -1,6 +1,6 @@
1
1
  import type { Context } from 'hono'
2
2
  import { type CookieJar, cookieJar } from './cookies.ts'
3
- import { getSessionState, rotateSessionNow } from './session.ts'
3
+ import { getOrCreateSessionId, getSessionState, rotateSessionNow, sessionUse } from './session.ts'
4
4
  import type { Store } from './store.ts'
5
5
 
6
6
  /**
@@ -64,25 +64,34 @@ const DEFAULT: StatorConfigData = { trustedOrigins: [], sameSite: 'Lax' }
64
64
  */
65
65
  export function stator(c: Context): StatorContext {
66
66
  const cfg = c.get('stator') ?? DEFAULT
67
- const session = getSessionState(c)
68
67
  return {
69
68
  origin: cfg.origin,
70
69
  trustedOrigins: cfg.trustedOrigins,
71
70
  sameSite: cfg.sameSite,
72
71
  cors: cfg.cors,
72
+ // Live reads (not a captured snapshot): the session may be established
73
+ // mid-request, after this context object was built.
73
74
  get sid() {
74
- return session?.sid ?? ''
75
+ return getSessionState(c)?.sid ?? ''
75
76
  },
76
77
  claims<T = unknown>(): T | undefined {
77
- return session?.claims as T | undefined
78
+ // A claims READ is a peek — it never establishes (an anonymous /admin
79
+ // probe redirects without minting state) — but it does make the
80
+ // response claims-dependent, so layer 3 marks it unprovable.
81
+ sessionUse(c).claimsRead = true
82
+ return getSessionState(c)?.claims as T | undefined
78
83
  },
79
84
  setClaims(claims: unknown): void {
85
+ // Writing claims is an explicit session op — an establishment trigger.
86
+ getOrCreateSessionId(c)
87
+ const session = getSessionState(c)
80
88
  if (session) {
81
89
  session.claims = claims
82
90
  session.claimsDirty = true
83
91
  }
84
92
  },
85
93
  clearClaims(): void {
94
+ const session = getSessionState(c)
86
95
  if (session) {
87
96
  session.claims = undefined
88
97
  session.claimsDirty = true
@@ -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[]
@@ -89,6 +106,9 @@ export interface CreateAppConfig extends DeprecatedFlatConfig {
89
106
  * keyed by file relative to `machinesDir`. Omitted ⇒ hashed live at boot;
90
107
  * a discovered machine missing from a supplied map is a boot error. */
91
108
  machineHashes?: Readonly<Record<string, string>>
109
+ /** Derived Cache-Control (cacheable read path, layer 3). Defaults on with
110
+ * modest values (60s / 300s); `false` disables emission. */
111
+ caching?: { sMaxAge?: number; staleWhileRevalidate?: number } | false
92
112
  }
93
113
 
94
114
  export interface StatorApp {
@@ -144,7 +164,9 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
144
164
  : undefined
145
165
  const bootDef = config.bootFile ? await discoverBoot(config.bootFile) : undefined
146
166
  const inspector = resolved.inspector
167
+ const images = config.images ? resolveImagesConfig(config.images) : undefined
147
168
  const app = await buildHonoApp({
169
+ images,
148
170
  routes,
149
171
  store,
150
172
  staticDir,
@@ -163,6 +185,16 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
163
185
  buildId: config.buildId,
164
186
  cors: resolved.cors,
165
187
  middleware,
188
+ // Layer-3 derived Cache-Control: on by default with modest values; the
189
+ // whole block set to `false` disables. (createDevApp never passes it —
190
+ // dev pages must always re-render while editing.)
191
+ caching:
192
+ resolved.caching === false
193
+ ? undefined
194
+ : {
195
+ sMaxAge: resolved.caching?.sMaxAge ?? 60,
196
+ staleWhileRevalidate: resolved.caching?.staleWhileRevalidate ?? 300,
197
+ },
166
198
  })
167
199
 
168
200
  return {
@@ -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,