@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.
- package/package.json +6 -3
- package/src/build/build.ts +45 -33
- package/src/build/compile-component.ts +36 -0
- package/src/build/head.ts +10 -12
- package/src/build/route-css.ts +211 -0
- package/src/cli/commands/dev.ts +1 -0
- package/src/cli/commands/start.ts +1 -0
- package/src/compiler/styles.ts +43 -20
- package/src/components/components.css +8 -0
- package/src/components/image.stator +37 -0
- package/src/components/image.stator.css +7 -0
- package/src/components/image.stator.ts +29 -0
- package/src/components/images.ts +253 -0
- package/src/components/index.ts +16 -0
- package/src/components/picture.stator +17 -0
- package/src/components/picture.stator.ts +21 -0
- package/src/config.ts +48 -0
- package/src/server/create-app.ts +21 -2
- package/src/server/dev-native.ts +27 -8
- package/src/server/dev-vite.ts +3 -0
- package/src/server/dev.ts +14 -0
- package/src/server/http.ts +35 -2
- package/src/server/images.ts +426 -0
- package/src/server/index.ts +7 -0
- package/src/server/render-context.ts +9 -0
- package/src/server/render.ts +3 -0
|
@@ -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
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -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
|
|
package/src/server/create-app.ts
CHANGED
|
@@ -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
|
|
77
|
-
* prebuilt
|
|
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,
|
package/src/server/dev-native.ts
CHANGED
|
@@ -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,
|
|
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)
|
|
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
|
-
|
|
326
|
+
const regenCss = async (): Promise<void> => {
|
|
327
|
+
let next = ''
|
|
317
328
|
for (const [file, info] of infos)
|
|
318
|
-
if (info.css)
|
|
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) {
|
package/src/server/dev-vite.ts
CHANGED
|
@@ -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
|
package/src/server/http.ts
CHANGED
|
@@ -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[] = []
|