@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,253 @@
1
+ import { DEFAULT_IMAGE_WIDTHS } from '../server/images.ts'
2
+ import { currentImages } from '../server/render-context.ts'
3
+
4
+ /**
5
+ * `getImage()`/`getPicture()` — pure math over provided dimensions, no
6
+ * markup (endpoint in `server/images.ts`). The `<Image>`/`<Picture>` markup
7
+ * components are `image.stator`/`picture.stator` in this directory; these
8
+ * two plus `SOURCE_TYPES` (the format→mime-type map) stay exported here as
9
+ * the public escape hatch for a custom art-direction component.
10
+ *
11
+ * `src` is the PUBLIC URL of the original: an image-endpoint path
12
+ * (`/media/2026/08/x.jpg`) gets derived variants — the endpoint's contract is
13
+ * that the URL's extension is the delivery format and `?w=` resizes — while a
14
+ * remote URL (`http…`) passes through untouched (the framework never proxies
15
+ * or transforms remote origins).
16
+ *
17
+ * Dimensions are REQUIRED, always, and always come from the caller: the
18
+ * framework never probes at render (renders are synchronous; intrinsic size
19
+ * is write-time data your upload handler stores via `probeImage`). Provide
20
+ * `width`+`height`, or `width`+`aspectRatio` and the height is derived. That
21
+ * rule is what makes every `<Image>` CLS-safe by construction.
22
+ */
23
+
24
+ export type ImageFormat = 'jpg' | 'png' | 'webp' | 'avif'
25
+
26
+ export type GetImageOptions = {
27
+ src: string
28
+ /** Intrinsic dimensions — BOTH required, always from data (`probeImage` at
29
+ * upload), never probed at render. That rule is what makes every `<Image>`
30
+ * CLS-safe by construction. (An earlier cut also accepted
31
+ * `width`+`aspectRatio` and derived the height; it had no consumers, and
32
+ * spending the word `aspectRatio` on "the shape my source happens to be"
33
+ * blocked it from meaning "the shape I want" — which is `crop`, below.) */
34
+ width: number
35
+ height: number
36
+ /** Delivery format for src/srcset URLs — defaults to the URL's extension. */
37
+ format?: ImageFormat
38
+ /** Candidate srcset widths; filtered to those below the intrinsic width.
39
+ * Defaults to the CONFIGURED `images.widths` when rendering inside an app
40
+ * with images configured (read from the render state — no drift), else the
41
+ * shipped default. */
42
+ widths?: number[]
43
+ /** Cover-crop the delivered variants to this ratio (width / height): `1` is
44
+ * square, `16 / 9` widescreen. The ratio must be on the endpoint's crop
45
+ * allowlist (`images.aspectRatios`) — an unlisted one throws rather than
46
+ * silently emitting URLs the endpoint would 400. Omit for the source's own
47
+ * shape. Cropping is what `<picture>` art direction does per breakpoint;
48
+ * as a plain prop it also covers the common case of one fixed geometry
49
+ * (a thumbnail grid), which previously needed an always-true media query. */
50
+ crop?: number
51
+ }
52
+
53
+ export interface ResolvedImage {
54
+ src: string
55
+ srcset: string | null
56
+ width: number
57
+ height: number
58
+ }
59
+
60
+ /** Widths for srcset candidates: the explicit prop wins, then the CONFIGURED
61
+ * endpoint allowlist (carried on the render state, so components can't emit
62
+ * a `?w=` the endpoint rejects), then the shipped default — the same one the
63
+ * endpoint defaults to, imported, not copied. */
64
+ function effectiveWidths(explicit: number[] | undefined): number[] {
65
+ return explicit ?? currentImages()?.widths ?? DEFAULT_IMAGE_WIDTHS
66
+ }
67
+
68
+ const isRemote = (src: string): boolean => /^[a-z][a-z0-9+.-]*:|^\/\//i.test(src)
69
+
70
+ const withFormat = (src: string, format: ImageFormat | undefined): string => {
71
+ if (!format) return src
72
+ const dot = src.lastIndexOf('.')
73
+ return dot === -1 ? src : `${src.slice(0, dot)}.${format}`
74
+ }
75
+
76
+ /** A crop ratio must be on the endpoint's allowlist or its URLs would 400 —
77
+ * a render error, never a silent drop. Validated once, up front, for both the
78
+ * plain `crop` prop and every art-directed source. */
79
+ function assertCropAllowed(crop: number): void {
80
+ const allowed = currentImages()?.aspectRatios
81
+ if (allowed && !allowed.some((a) => Math.abs(a - crop) < 1e-9)) {
82
+ throw new Error(
83
+ `stator: crop ${crop} is not in images.aspectRatios ` +
84
+ `(${allowed.map((a) => a.toFixed(4)).join(', ')}) — the endpoint would reject its URLs. ` +
85
+ `Add it to the config allowlist or use a listed ratio.`,
86
+ )
87
+ }
88
+ }
89
+
90
+ export function getImage(opts: GetImageOptions): ResolvedImage {
91
+ const { src, width, height, format, widths, crop } = opts
92
+ const ext = src.slice(src.lastIndexOf('.') + 1).toLowerCase()
93
+ // Remote URLs pass through; GIF and SVG serve as originals only — the
94
+ // endpoint refuses to transform either (animation doesn't survive naive
95
+ // resizing; a vector document has no pixels to resample).
96
+ if (isRemote(src) || ext === 'gif' || ext === 'svg') {
97
+ return { src, srcset: null, width, height }
98
+ }
99
+ const base = withFormat(src, format)
100
+ const candidates = effectiveWidths(widths).filter((w) => w < width)
101
+
102
+ if (crop === undefined) {
103
+ const srcset =
104
+ candidates.length > 0 ? candidates.map((w) => `${base}?w=${w} ${w}w`).join(', ') : null
105
+ return { src: base, srcset, width, height }
106
+ }
107
+
108
+ assertCropAllowed(crop)
109
+ // A crop URL must name an allowlisted width; with none below the intrinsic
110
+ // width there is no valid variant to ask for, so the original serves
111
+ // uncropped (CSS `object-fit` still covers) rather than emitting a 400.
112
+ if (candidates.length === 0) return { src: base, srcset: null, width, height }
113
+ const box = (w: number) => ({ w, h: Math.round(w / crop) })
114
+ const srcset = candidates.map((w) => `${base}?w=${w}&h=${box(w).h} ${w}w`).join(', ')
115
+ // Dimensions describe the resource actually in `src` — the largest rung —
116
+ // so the reserved box carries the CROPPED ratio, not the source's.
117
+ const largest = box(Math.max(...candidates))
118
+ return {
119
+ src: `${base}?w=${largest.w}&h=${largest.h}`,
120
+ srcset,
121
+ width: largest.w,
122
+ height: largest.h,
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Loading control: either the `priority` shorthand OR the native attributes it
128
+ * stands for, never both — `<Image priority loading="lazy">` has no defensible
129
+ * meaning, so the type refuses it instead of resolving it silently.
130
+ *
131
+ * `priority` earns its place by teaching: it says "there is one image on this
132
+ * page worth flagging", which two independent attributes don't. But it bakes
133
+ * in a hero-shaped assumption — a thumbnail grid wants the whole first row
134
+ * eager with only the first at high priority, which the rollup cannot express.
135
+ * So the underlying attributes are first-class, typed exactly as
136
+ * `HTMLAttributes<'img'>` types them (native names, native enums).
137
+ */
138
+ export type ImageLoading =
139
+ | { priority?: boolean; loading?: never; fetchpriority?: never }
140
+ | {
141
+ priority?: never
142
+ loading?: 'eager' | 'lazy'
143
+ fetchpriority?: 'high' | 'low' | 'auto'
144
+ }
145
+
146
+ export type ImageProps = GetImageOptions &
147
+ ImageLoading & {
148
+ /** Required — an image without alt text is a compile error on purpose.
149
+ * Decorative images say so explicitly with `alt=""`. */
150
+ alt: string
151
+ sizes?: string
152
+ /** Orthogonal to the priority question, so it sits outside that union —
153
+ * folding it in would repeat the over-coupling one level up. */
154
+ decoding?: 'sync' | 'async' | 'auto'
155
+ class?: string
156
+ }
157
+
158
+ /** An art-directed source: under `media`, serve a different GEOMETRY — the
159
+ * `crop` ratio the endpoint cover-crops to, same vocabulary and same
160
+ * allowlist as the plain `crop` prop. Reach for `sources` when the geometry
161
+ * VARIES by breakpoint; a single fixed crop is just `crop` on the component. */
162
+ export interface PictureSource {
163
+ media: string
164
+ /** width / height of the crop. `1` = square, `16 / 9` = widescreen. */
165
+ crop: number
166
+ sizes?: string
167
+ widths?: number[]
168
+ }
169
+
170
+ export type PictureProps = ImageProps & {
171
+ /** Modern-format `<source>` chain, most-preferred first. The stored format
172
+ * stays the `<img>` fallback. */
173
+ formats?: ImageFormat[]
174
+ /** Art direction — this is where `<picture>` earns its difficulty: each
175
+ * entry crosses with every format (media × type sources, most specific
176
+ * first), and the plain-format chain follows for non-matching viewports. */
177
+ sources?: PictureSource[]
178
+ }
179
+
180
+ /** `<source type>` for each delivery format — exported alongside `getPicture`
181
+ * so a hand-rolled `<picture>` (art direction the first-party `<Picture>`
182
+ * doesn't cover) can build its own `<source>` tags from `getImage`/
183
+ * `getPicture` output without re-deriving this mapping. */
184
+ export const SOURCE_TYPES: Record<ImageFormat, string> = {
185
+ jpg: 'image/jpeg',
186
+ png: 'image/png',
187
+ webp: 'image/webp',
188
+ avif: 'image/avif',
189
+ }
190
+
191
+ /** One resolved `<source>` candidate, most-specific (art-directed) first. */
192
+ export interface ResolvedPictureSource {
193
+ media?: string
194
+ format: ImageFormat
195
+ srcset: string
196
+ sizes: string
197
+ }
198
+
199
+ export interface ResolvedPicture {
200
+ /** `<source>` candidates — empty when `src` is remote or no candidate
201
+ * produced a srcset, in which case only `image` (the plain `<img>`) applies. */
202
+ sources: ResolvedPictureSource[]
203
+ /** The fallback `<img>`'s own resolved data — same shape `getImage()` returns,
204
+ * built from the same props the sources were (`crop` included, so its
205
+ * reserved box describes the cropped output rather than the source's own
206
+ * shape). */
207
+ image: ResolvedImage
208
+ }
209
+
210
+ /** Pure `<picture>` math: every `<source>` candidate's resolved srcset plus
211
+ * the fallback `<img>`'s own resolved data — no markup. `<Picture>` renders
212
+ * this; a custom art-direction component that needs a different `<picture>`
213
+ * shape than `<Picture>` offers can call this directly instead. */
214
+ export function getPicture(props: PictureProps): ResolvedPicture {
215
+ const { formats = ['avif', 'webp'], sources: artSources = [], sizes = '100vw', ...rest } = props
216
+ const imageProps = { ...rest, sizes } as ImageProps
217
+ const image = getImage(imageProps)
218
+ if (isRemote(rest.src)) return { sources: [], image }
219
+
220
+ const storedExt = rest.src.slice(rest.src.lastIndexOf('.') + 1).toLowerCase() as ImageFormat
221
+
222
+ // Art-directed sources come FIRST (the browser takes the first media+type
223
+ // match), each crossed with every modern format plus the stored format —
224
+ // without the stored-format row, a browser supporting none of the modern
225
+ // formats would fall past the media condition to the uncropped fallback.
226
+ // Cropping is just `getImage` with a different `crop`, so art direction and
227
+ // the plain chain share one code path.
228
+ const art = artSources.flatMap((source) =>
229
+ [...formats, storedExt]
230
+ .filter((f, i, all) => all.indexOf(f) === i && f in SOURCE_TYPES)
231
+ .map((format) => ({
232
+ media: source.media,
233
+ format: format as ImageFormat,
234
+ srcset: getImage({
235
+ ...imageProps,
236
+ format: format as ImageFormat,
237
+ crop: source.crop,
238
+ widths: source.widths ?? rest.widths,
239
+ }).srcset,
240
+ sizes: source.sizes ?? sizes,
241
+ }))
242
+ .filter((s) => s.srcset !== null),
243
+ )
244
+ const plain = formats
245
+ .map((format) => ({
246
+ format,
247
+ srcset: getImage({ ...imageProps, format }).srcset,
248
+ sizes,
249
+ }))
250
+ .filter((s) => s.srcset !== null)
251
+
252
+ return { sources: [...art, ...plain] as ResolvedPictureSource[], image }
253
+ }
@@ -1,2 +1,18 @@
1
+ // Compiled sibling, not the raw `.stator` source — keep fresh with
2
+ // `pnpm build:components`.
3
+ export { default as Image } from './image.stator.ts'
4
+ export type {
5
+ GetImageOptions,
6
+ ImageFormat,
7
+ ImageLoading,
8
+ ImageProps,
9
+ PictureProps,
10
+ PictureSource,
11
+ ResolvedImage,
12
+ ResolvedPicture,
13
+ ResolvedPictureSource,
14
+ } from './images.ts'
15
+ export { getImage, getPicture, SOURCE_TYPES } from './images.ts'
1
16
  export type { JsonLdProps } from './json-ld.ts'
2
17
  export { JsonLd, ldToString } from './json-ld.ts'
18
+ export { default as Picture } from './picture.stator.ts'
@@ -0,0 +1,17 @@
1
+ ---
2
+ import { getPicture, SOURCE_TYPES, type PictureProps } from './images.ts'
3
+ import Image from './image.stator'
4
+
5
+ const p = Stator.props<PictureProps>()
6
+ const { sources } = getPicture(p)
7
+ ---
8
+ {sources.length === 0 ? (
9
+ <Image {...p} />
10
+ ) : (
11
+ <picture>
12
+ {each(sources, (s) => (
13
+ <source media={s.media} type={SOURCE_TYPES[s.format]} srcset={s.srcset} sizes={s.sizes} />
14
+ ))}
15
+ <Image {...p} />
16
+ </picture>
17
+ )}
@@ -0,0 +1,21 @@
1
+ import {
2
+ classList,
3
+ defer,
4
+ each,
5
+ html,
6
+ itemBind,
7
+ match,
8
+ on,
9
+ read,
10
+ spreadAttrs,
11
+ styleList,
12
+ when,
13
+ } from '@statorjs/stator/template'
14
+ import Image from './image.stator.ts'
15
+ import { getPicture, type PictureProps, SOURCE_TYPES } from './images.ts'
16
+
17
+ export default function (props: PictureProps) {
18
+ const p = props
19
+ const { sources } = getPicture(p)
20
+ return html`${sources.length === 0 ? html`${Image({ ...p })}` : html`<picture>${each(sources, (s) => html`<source media="${s.media}" type="${SOURCE_TYPES[s.format]}" srcset="${s.srcset}" sizes="${s.sizes}" />`)}${Image({ ...p })}</picture>`}`
21
+ }
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[]
@@ -73,8 +90,8 @@ export interface CreateAppConfig extends DeprecatedFlatConfig {
73
90
  host?: string
74
91
  /** Cross-origin READ policy (CORS); `origins` defaults to `trustedOrigins`. */
75
92
  cors?: { origins?: string[]; credentials?: boolean }
76
- /** Extra `<head>` HTML per GET route. A production build uses this to link the
77
- * prebuilt `components.css`; ignored if omitted. */
93
+ /** Extra `<head>` HTML per GET route. A production build uses this to link
94
+ * that route's own prebuilt scoped-CSS artifact; ignored if omitted. */
78
95
  headExtras?: (filePath: string) => string | Promise<string>
79
96
  /** Path to the app's `middleware.ts` (if any). Loaded and validated; its
80
97
  * default export must be `defineMiddleware`/`dangerouslyDefineMiddleware`. */
@@ -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,
@@ -16,6 +16,7 @@ import {
16
16
  routeIslandMap,
17
17
  walkFiles,
18
18
  } from '../build/islands.ts'
19
+ import { bareSpecifiersOf, externalCss } from '../build/route-css.ts'
19
20
  import { sourceId } from '../build/source-id.ts'
20
21
  import { CompileError, compile, formatCompileError, regionResolverFor } from '../compiler/index.ts'
21
22
  import type { AnyMachineDef, EventOf } from '../engine/index.ts'
@@ -29,6 +30,7 @@ import { discoverMachines } from './discovery.ts'
29
30
  import { wireAppEffects } from './effects.ts'
30
31
  import { loadDotenv } from './env.ts'
31
32
  import { buildHonoApp, contentTypeFor } from './http.ts'
33
+ import { resolveImagesConfig } from './images.ts'
32
34
  import { logger, setLogLevel } from './logger.ts'
33
35
  import { codeHashOf, codeInputsOf } from './machine-hash.ts'
34
36
  import { MachineStore } from './machine-store.ts'
@@ -58,8 +60,8 @@ import { InMemoryStore } from './store.ts'
58
60
  * graph serving and renders the error (with its code frame) in an overlay.
59
61
  * - **Scoped CSS + island scripts** — islands bundle through the
60
62
  * `bundleIslands` seam (Vite today, never on the SSR path) and are served
61
- * from memory with the concatenated scoped CSS, on the same URLs and with
62
- * the same `<head>` shape as production.
63
+ * from memory with the concatenated scoped CSS, local and `node_modules`
64
+ * alike, on the same URLs and with the same `<head>` shape as production.
63
65
  *
64
66
  * See spec `toolchain-adapter-seam-and-the-vite-exit`.
65
67
  */
@@ -133,6 +135,7 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
133
135
  const machinesDir = real(resolve(config.machinesDir))
134
136
  const routesDir = real(resolve(config.routesDir))
135
137
  const staticDir = config.staticDir ? real(resolve(config.staticDir)) : resolve(root, 'static')
138
+ const images = config.images ? resolveImagesConfig(config.images) : undefined
136
139
  // One build-id per successful BUILD, not per process. Connected pages reload
137
140
  // through the dev signal, but a page whose channel was released while it sat
138
141
  // in the background (or dropped over a blip) never sees that signal — it
@@ -162,6 +165,9 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
162
165
  const versions = new Map<string, number>()
163
166
  const forward = new Map<string, Set<string>>()
164
167
  const importers = new Map<string, Set<string>>()
168
+ // Bare specifiers each app file imports directly, feeding the CSS
169
+ // aggregate below via each package's own sibling `.css` (`externalCss`).
170
+ const externalSpecs = new Map<string, Set<string>>()
165
171
  let seq = 0
166
172
  const acks = new Map<number, () => void>()
167
173
  port1.on('message', (m: { type?: string; id?: number }) => {
@@ -272,11 +278,16 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
272
278
  }
273
279
  }
274
280
  const updateEdges = async (files: string[]): Promise<void> => {
275
- for (const f of files) setEdges(f, new Set((await localImports(f, root)).filter(isAppFile)))
281
+ for (const f of files) {
282
+ const [local, external] = await Promise.all([localImports(f, root), bareSpecifiersOf(f)])
283
+ setEdges(f, new Set(local.filter(isAppFile)))
284
+ externalSpecs.set(f, new Set(external))
285
+ }
276
286
  }
277
287
  const buildGraph = async (): Promise<void> => {
278
288
  forward.clear()
279
289
  importers.clear()
290
+ externalSpecs.clear()
280
291
  await updateEdges(await walkFiles(root, (f) => APP_EXT.test(f), skipDir))
281
292
  }
282
293
  /** The changed files plus everything that (transitively) imports them. */
@@ -312,10 +323,17 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
312
323
  }
313
324
  }
314
325
  let css = ''
315
- const regenCss = (): void => {
316
- css = ''
326
+ const regenCss = async (): Promise<void> => {
327
+ let next = ''
317
328
  for (const [file, info] of infos)
318
- if (info.css) css += `/* ${sourceId(root, file).id} */\n${info.css}\n`
329
+ if (info.css) next += `/* ${sourceId(root, file).id} */\n${info.css}\n`
330
+ const packages = new Set<string>()
331
+ for (const specs of externalSpecs.values()) for (const spec of specs) packages.add(spec)
332
+ for (const spec of [...packages].sort()) {
333
+ const text = await externalCss(spec, root)
334
+ if (text) next += `/* ${spec} */\n${text}\n`
335
+ }
336
+ css = next
319
337
  }
320
338
 
321
339
  // ── Islands: bundled through the seam, held in memory ──────────────────────
@@ -428,6 +446,7 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
428
446
  const middleware = await discoverMiddleware(resolve(root, 'middleware.ts'), bust)
429
447
  await remapRoutes()
430
448
  app = await buildHonoApp({
449
+ images,
431
450
  routes,
432
451
  store,
433
452
  staticDir,
@@ -446,7 +465,7 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
446
465
 
447
466
  await scanAll()
448
467
  await buildGraph()
449
- regenCss()
468
+ await regenCss()
450
469
  await rebundleIslands()
451
470
  await rebuildStore()
452
471
  await rebuildServer()
@@ -475,7 +494,7 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
475
494
  }
476
495
  const affected = affectedBy(files)
477
496
  await bumpVersions(affected)
478
- regenCss()
497
+ await regenCss()
479
498
  const needsBundle =
480
499
  structural || files.some((f) => islandGraph.has(norm(f)) || infos.get(f)?.isClient)
481
500
  if (needsBundle) {
@@ -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[] = []