@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 +7 -2
- package/src/cli/commands/dev.ts +3 -0
- package/src/cli/commands/start.ts +3 -0
- package/src/compiler/lower.ts +41 -0
- package/src/compiler/virtual-code.ts +1 -1
- package/src/components/images.ts +260 -0
- package/src/components/index.ts +10 -0
- package/src/config.ts +64 -0
- package/src/index.ts +7 -0
- package/src/server/api-route.ts +5 -2
- package/src/server/config-compat.ts +4 -0
- package/src/server/context.ts +13 -4
- package/src/server/create-app.ts +32 -0
- package/src/server/dev-native.ts +3 -0
- package/src/server/dev-vite.ts +3 -0
- package/src/server/dev.ts +14 -0
- package/src/server/http.ts +299 -91
- package/src/server/images.ts +426 -0
- package/src/server/index.ts +7 -0
- package/src/server/query-route.ts +68 -3
- package/src/server/render-context.ts +9 -0
- package/src/server/render.ts +3 -0
- package/src/server/route-discovery.ts +36 -5
- package/src/server/routing.ts +8 -0
- package/src/server/session.ts +58 -11
- package/src/template/html-attributes.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
103
|
+
"@statorjs/stator": "2.11.0-next.0"
|
|
99
104
|
},
|
|
100
105
|
"scripts": {
|
|
101
106
|
"typecheck": "tsc --noEmit",
|
package/src/cli/commands/dev.ts
CHANGED
|
@@ -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,
|
package/src/compiler/lower.ts
CHANGED
|
@@ -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:
|
|
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
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -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'
|
package/src/server/api-route.ts
CHANGED
|
@@ -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>() =>
|
|
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
|
}
|
package/src/server/context.ts
CHANGED
|
@@ -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
|
|
75
|
+
return getSessionState(c)?.sid ?? ''
|
|
75
76
|
},
|
|
76
77
|
claims<T = unknown>(): T | undefined {
|
|
77
|
-
|
|
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
|
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[]
|
|
@@ -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 {
|
package/src/server/dev-native.ts
CHANGED
|
@@ -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,
|