@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
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs'
|
|
3
|
+
import { readFile } from 'node:fs/promises'
|
|
4
|
+
import { basename, dirname, join, resolve, sep } from 'node:path'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Framework image serving — the promotion of the pattern proven in
|
|
8
|
+
* `examples/indie-blog` (spec `images-are-part-of-stator-*`). Mounted only
|
|
9
|
+
* when `stator.config.ts` declares `images: { dir }`; an unconfigured app has
|
|
10
|
+
* no routes here and never loads a transformer.
|
|
11
|
+
*
|
|
12
|
+
* The contract, unchanged from the proof: the URL's **extension is the
|
|
13
|
+
* delivery format** (request `x.webp` of a stored `x.jpg` and the endpoint
|
|
14
|
+
* converts — it never lies about an extension; there is no Accept-header or
|
|
15
|
+
* user-agent negotiation, ever), `?w=` resizes to an allowlisted width, and
|
|
16
|
+
* `?w=&h=` cover-crops to an exact box — BOTH dimensions must come from the
|
|
17
|
+
* allowlist, so the variant space stays bounded (widths² at worst, never an
|
|
18
|
+
* open crop parameter). Variants cache on disk beside the originals
|
|
19
|
+
* (`.variants/`), regenerated when the original is newer. GIFs serve as
|
|
20
|
+
* originals only — animation does not survive naive resizing.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The transformer seam: pure bytes-in/bytes-out, so the default (sharp) is
|
|
25
|
+
* swappable for another library. The framework owns everything around it —
|
|
26
|
+
* path resolution, the disk cache, conditional GET — adapters only transform.
|
|
27
|
+
*/
|
|
28
|
+
export interface ImageTransformer {
|
|
29
|
+
/** Intrinsic dimensions. Called at WRITE time by app upload handlers (via
|
|
30
|
+
* `probeImage`), never during a render — dimensions are data, not IO. */
|
|
31
|
+
probe(bytes: Uint8Array): Promise<{ width: number | null; height: number | null }>
|
|
32
|
+
/** Resize (optional) and/or re-encode to `format`. Width alone never
|
|
33
|
+
* enlarges; width+height is an exact cover-cropped box (art direction
|
|
34
|
+
* needs predictable geometry, so enlargement is allowed there). */
|
|
35
|
+
transform(
|
|
36
|
+
input: Uint8Array,
|
|
37
|
+
opts: { width?: number; height?: number; format: 'jpeg' | 'png' | 'webp' | 'avif' },
|
|
38
|
+
): Promise<Uint8Array>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Impl #1: sharp, imported lazily so image-free processes never load it.
|
|
42
|
+
* `threads` caps the libvips worker pool (process-global — libvips has one
|
|
43
|
+
* pool). sharp's default is the REPORTED core count, which on a shared-cpu
|
|
44
|
+
* host is the physical machine's (8+ on Fly shared-cpu-1x) — every encode
|
|
45
|
+
* then fans out that many threads' worth of encoder buffers and CPU demand
|
|
46
|
+
* on a fractional vCPU. A big AVIF encode under that fan-out is what
|
|
47
|
+
* swap-thrashed a 512MB host into a lockup. 0 = leave sharp's default. */
|
|
48
|
+
export function sharpTransformer(threads = 0): ImageTransformer {
|
|
49
|
+
const load = async () => {
|
|
50
|
+
const { default: sharp } = await import('sharp')
|
|
51
|
+
if (threads > 0) sharp.concurrency(threads)
|
|
52
|
+
return sharp
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
async probe(bytes) {
|
|
56
|
+
const sharp = await load()
|
|
57
|
+
const meta = await sharp(bytes).metadata()
|
|
58
|
+
// Report DISPLAY dimensions: EXIF orientations 5-8 transpose the
|
|
59
|
+
// raster, and these dims feed width/height attributes (the CLS box)
|
|
60
|
+
// for pixels that transform() serves upright — raw stored dims would
|
|
61
|
+
// reserve a sideways box for every portrait phone photo.
|
|
62
|
+
const swap = (meta.orientation ?? 1) >= 5
|
|
63
|
+
return {
|
|
64
|
+
width: (swap ? meta.height : meta.width) ?? null,
|
|
65
|
+
height: (swap ? meta.width : meta.height) ?? null,
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
async transform(input, opts) {
|
|
69
|
+
const sharp = await load()
|
|
70
|
+
// rotate() with no args bakes the EXIF orientation into the pixels
|
|
71
|
+
// (and re-encoding drops the tag) — without it every variant of a
|
|
72
|
+
// portrait phone photo comes out sideways, because encoders write the
|
|
73
|
+
// raster as stored and the orientation tag is what dies in transit.
|
|
74
|
+
let pipeline = sharp(input).rotate()
|
|
75
|
+
if (opts.width !== undefined && opts.height !== undefined) {
|
|
76
|
+
pipeline = pipeline.resize({ width: opts.width, height: opts.height, fit: 'cover' })
|
|
77
|
+
} else if (opts.width !== undefined) {
|
|
78
|
+
pipeline = pipeline.resize({ width: opts.width, withoutEnlargement: true })
|
|
79
|
+
}
|
|
80
|
+
// AVIF effort 2 (sharp's default is 4): the encoder's CPU/density
|
|
81
|
+
// trade, NOT a visual-quality knob. On-demand serving means a real
|
|
82
|
+
// visitor is waiting on the first encode — measured 16s at effort 4 on
|
|
83
|
+
// a shared-cpu host vs a few seconds at 2, for files a few percent
|
|
84
|
+
// larger. A build step would choose density; an on-demand endpoint
|
|
85
|
+
// chooses the visitor.
|
|
86
|
+
const encoded =
|
|
87
|
+
opts.format === 'avif' ? pipeline.avif({ effort: 2 }) : pipeline.toFormat(opts.format)
|
|
88
|
+
return new Uint8Array(await encoded.toBuffer())
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const DEFAULT_IMAGE_WIDTHS = [400, 800, 1200, 1600]
|
|
94
|
+
|
|
95
|
+
/** Crop aspect allowlist (width/height): square, the photographic landscape
|
|
96
|
+
* trio, and their portrait duals. A crop `?w=&h=` is valid iff `w` is an
|
|
97
|
+
* allowlisted width AND `h === round(w / a)` for an allowlisted aspect — the
|
|
98
|
+
* variant space stays finite (|widths| x |aspects|) while the aspects people
|
|
99
|
+
* actually shoot in (16:9, 3:2, 4:3) all work. (The first cut required
|
|
100
|
+
* `h ∈ widths`, which silently forbade every non-square aspect — caught in
|
|
101
|
+
* review before the URL grammar froze.) */
|
|
102
|
+
export const DEFAULT_IMAGE_ASPECT_RATIOS = [1, 4 / 3, 3 / 2, 16 / 9, 3 / 4, 2 / 3, 9 / 16]
|
|
103
|
+
|
|
104
|
+
/** What the render side needs to emit URLs the endpoint will accept — carried
|
|
105
|
+
* on the render state so `<Image>`/`<Picture>` can't drift from config. */
|
|
106
|
+
export interface ImagesRenderInfo {
|
|
107
|
+
widths: number[]
|
|
108
|
+
aspectRatios: number[]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const IMAGE_CONTENT_TYPES: Record<string, string> = {
|
|
112
|
+
jpg: 'image/jpeg',
|
|
113
|
+
jpeg: 'image/jpeg',
|
|
114
|
+
png: 'image/png',
|
|
115
|
+
webp: 'image/webp',
|
|
116
|
+
avif: 'image/avif',
|
|
117
|
+
gif: 'image/gif',
|
|
118
|
+
svg: 'image/svg+xml',
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const TRANSCODE_TARGETS = new Set(['jpg', 'jpeg', 'png', 'webp', 'avif'])
|
|
122
|
+
|
|
123
|
+
/** Originals-only formats: served verbatim, never a transcode SOURCE either.
|
|
124
|
+
* GIF because animation doesn't survive naive resizing; SVG because it's a
|
|
125
|
+
* vector document, not pixels — nothing to probe, resize, or rasterize
|
|
126
|
+
* (found via an imported favicon living in the media store, where an
|
|
127
|
+
* uploading admin legitimately puts it). */
|
|
128
|
+
const ORIGINAL_ONLY_SOURCES = new Set(['gif', 'svg'])
|
|
129
|
+
|
|
130
|
+
/** SVG is the one format that can execute script when navigated to directly
|
|
131
|
+
* (fine inside <img>/favicon contexts, which ignore scripts). Serve it like
|
|
132
|
+
* GitHub serves avatars: scripts and external loads neutralized, inline
|
|
133
|
+
* styles allowed, sniffing pinned. */
|
|
134
|
+
const SVG_SECURITY_HEADERS = {
|
|
135
|
+
'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'",
|
|
136
|
+
'X-Content-Type-Options': 'nosniff',
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Cold-cache stampede guard: N concurrent requests for the same missing
|
|
140
|
+
* variant share one encode instead of N. Keyed by cache path. */
|
|
141
|
+
const inflight = new Map<string, Promise<void>>()
|
|
142
|
+
|
|
143
|
+
/** sha1-16 of an original's bytes, memoized per path with `(size, mtime)` as
|
|
144
|
+
* the recompute key — mtime demoted from identity to cache hint, its correct
|
|
145
|
+
* role. Same shape and truncation as the query-route `revision()` ledger. */
|
|
146
|
+
const hashCache = new Map<string, { size: number; mtimeMs: number; hash: string }>()
|
|
147
|
+
function originalHash(full: string): string {
|
|
148
|
+
const st = statSync(full)
|
|
149
|
+
const hit = hashCache.get(full)
|
|
150
|
+
if (hit && hit.size === st.size && hit.mtimeMs === st.mtimeMs) return hit.hash
|
|
151
|
+
const hash = createHash('sha1').update(readFileSync(full)).digest('hex').slice(0, 16)
|
|
152
|
+
hashCache.set(full, { size: st.size, mtimeMs: st.mtimeMs, hash })
|
|
153
|
+
return hash
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The freshness dial (spec: image-caching, adjudicated 2026-08-30 — a dial,
|
|
157
|
+
* not an immutable boolean, because over-caching has no server-side
|
|
158
|
+
* recovery). Defaults preserve the conservative revalidation contract;
|
|
159
|
+
* `staleWhileRevalidate` trades must-revalidate for background self-healing
|
|
160
|
+
* (SWR forbids what must-revalidate demands, so they never co-emit);
|
|
161
|
+
* `immutable` appends only on top of an explicit long maxAge. */
|
|
162
|
+
function cacheControlValue(config: ResolvedImagesConfig): string {
|
|
163
|
+
const { maxAge, staleWhileRevalidate: swr, immutable } = config
|
|
164
|
+
if (maxAge === 0 && swr === 0) return 'public, max-age=0, must-revalidate'
|
|
165
|
+
let value = `public, max-age=${maxAge}`
|
|
166
|
+
if (swr > 0) value += `, stale-while-revalidate=${swr}`
|
|
167
|
+
if (immutable && maxAge > 0) value += ', immutable'
|
|
168
|
+
return value
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Global encode semaphore — DIFFERENT variants queue behind a small number
|
|
172
|
+
* of concurrent transforms. Without it, one cold-cache gallery page fans a
|
|
173
|
+
* dozen simultaneous sharp encodes (AVIF is memory-brutal) and OOM-kills a
|
|
174
|
+
* small host — found the first hour tonysull.co ran on a 512MB Fly machine.
|
|
175
|
+
* Module-level on purpose: the resource being protected (CPU/memory) is
|
|
176
|
+
* process-wide, whatever app object serves the request. */
|
|
177
|
+
let activeTransforms = 0
|
|
178
|
+
const transformQueue: Array<() => void> = []
|
|
179
|
+
function acquireTransformSlot(limit: number): Promise<void> {
|
|
180
|
+
return new Promise((resolve) => {
|
|
181
|
+
if (activeTransforms < limit) {
|
|
182
|
+
activeTransforms += 1
|
|
183
|
+
resolve()
|
|
184
|
+
} else {
|
|
185
|
+
transformQueue.push(() => {
|
|
186
|
+
activeTransforms += 1
|
|
187
|
+
resolve()
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
function releaseTransformSlot(): void {
|
|
193
|
+
activeTransforms -= 1
|
|
194
|
+
transformQueue.shift()?.()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface ResolvedImagesConfig {
|
|
198
|
+
dir: string
|
|
199
|
+
/** URL prefix (leading slash, no trailing). */
|
|
200
|
+
path: string
|
|
201
|
+
widths: number[]
|
|
202
|
+
aspectRatios: number[]
|
|
203
|
+
transformer: ImageTransformer
|
|
204
|
+
/** Max concurrent encodes across ALL variants (default 2). */
|
|
205
|
+
concurrency: number
|
|
206
|
+
/** libvips worker threads per encode (default 0 = sharp's default). */
|
|
207
|
+
threads: number
|
|
208
|
+
/** Encode deadline per request (default 15000ms; 0 disables). Past it the
|
|
209
|
+
* response degrades to a 302 at the stored original — honest bytes now —
|
|
210
|
+
* while the encode keeps filling the cache for the next request. */
|
|
211
|
+
encodeTimeoutMs: number
|
|
212
|
+
/** Freshness lifetime in seconds (default 0 = revalidate every use). */
|
|
213
|
+
maxAge: number
|
|
214
|
+
/** Serve-stale window in seconds (default 0). >0 lets a cache render its
|
|
215
|
+
* copy instantly and revalidate in the background — bounded regret. */
|
|
216
|
+
staleWhileRevalidate: number
|
|
217
|
+
/** Append `immutable` to a long maxAge — the no-recovery marker; only for
|
|
218
|
+
* apps whose image URLs are write-once by construction. */
|
|
219
|
+
immutable: boolean
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function resolveImagesConfig(config: {
|
|
223
|
+
dir: string
|
|
224
|
+
path?: string
|
|
225
|
+
widths?: number[]
|
|
226
|
+
aspectRatios?: number[]
|
|
227
|
+
transformer?: ImageTransformer
|
|
228
|
+
concurrency?: number
|
|
229
|
+
threads?: number
|
|
230
|
+
encodeTimeoutMs?: number
|
|
231
|
+
maxAge?: number
|
|
232
|
+
staleWhileRevalidate?: number
|
|
233
|
+
immutable?: boolean
|
|
234
|
+
}): ResolvedImagesConfig {
|
|
235
|
+
const dir = resolve(config.dir)
|
|
236
|
+
const path = (config.path ?? `/${basename(dir)}`).replace(/\/$/, '')
|
|
237
|
+
const threads = Math.max(0, config.threads ?? 0)
|
|
238
|
+
return {
|
|
239
|
+
dir,
|
|
240
|
+
path: path.startsWith('/') ? path : `/${path}`,
|
|
241
|
+
widths: config.widths ?? DEFAULT_IMAGE_WIDTHS,
|
|
242
|
+
aspectRatios: config.aspectRatios ?? DEFAULT_IMAGE_ASPECT_RATIOS,
|
|
243
|
+
transformer: config.transformer ?? sharpTransformer(threads),
|
|
244
|
+
concurrency: Math.max(1, config.concurrency ?? 2),
|
|
245
|
+
threads,
|
|
246
|
+
encodeTimeoutMs: Math.max(0, config.encodeTimeoutMs ?? 15_000),
|
|
247
|
+
maxAge: Math.max(0, config.maxAge ?? 0),
|
|
248
|
+
staleWhileRevalidate: Math.max(0, config.staleWhileRevalidate ?? 0),
|
|
249
|
+
immutable: config.immutable ?? false,
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Write-time probe for app upload handlers — the render never does this.
|
|
254
|
+
* Defaults to the sharp transformer; pass your configured one if you swapped
|
|
255
|
+
* it. (First dogfood finding: an earlier signature took the whole resolved
|
|
256
|
+
* config, which apps don't hold — bytes + optional transformer is the shape
|
|
257
|
+
* an upload handler actually has in hand.) */
|
|
258
|
+
export async function probeImage(
|
|
259
|
+
bytes: Uint8Array,
|
|
260
|
+
transformer: ImageTransformer = sharpTransformer(),
|
|
261
|
+
): Promise<{ width: number | null; height: number | null }> {
|
|
262
|
+
return transformer.probe(bytes)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Find the stored original for a requested relative path, whatever its
|
|
266
|
+
* extension — containment-checked against the images dir. */
|
|
267
|
+
function resolveOriginal(
|
|
268
|
+
config: ResolvedImagesConfig,
|
|
269
|
+
rel: string,
|
|
270
|
+
): { full: string; ext: string } | null {
|
|
271
|
+
const dot = rel.lastIndexOf('.')
|
|
272
|
+
if (dot === -1) return null
|
|
273
|
+
// No dotfile path segments: keeps the `.variants/` cache (and anything
|
|
274
|
+
// hidden) unreachable through the endpoint — no variants-of-variants.
|
|
275
|
+
if (rel.split('/').some((seg) => seg.startsWith('.'))) return null
|
|
276
|
+
const base = rel.slice(0, dot)
|
|
277
|
+
for (const ext of Object.keys(IMAGE_CONTENT_TYPES)) {
|
|
278
|
+
const full = resolve(config.dir, `${base}.${ext}`)
|
|
279
|
+
if (!full.startsWith(config.dir + sep)) return null
|
|
280
|
+
if (existsSync(full)) return { full, ext }
|
|
281
|
+
}
|
|
282
|
+
return null
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Serve one image request: `rel` is the path under the endpoint prefix,
|
|
287
|
+
* `widthParam` the raw `?w=` value (undefined = original size). Returns a
|
|
288
|
+
* complete `Response` — 200 with caching headers, 304 on a validator match,
|
|
289
|
+
* 400 for an off-allowlist width, 404 otherwise.
|
|
290
|
+
*/
|
|
291
|
+
export async function serveImage(
|
|
292
|
+
config: ResolvedImagesConfig,
|
|
293
|
+
rel: string,
|
|
294
|
+
widthParam: string | undefined,
|
|
295
|
+
heightParam: string | undefined,
|
|
296
|
+
ifNoneMatch: string | null,
|
|
297
|
+
): Promise<Response> {
|
|
298
|
+
let width: number | undefined
|
|
299
|
+
if (widthParam !== undefined) {
|
|
300
|
+
width = Number(widthParam)
|
|
301
|
+
if (!config.widths.includes(width)) {
|
|
302
|
+
return new Response(`w must be one of ${config.widths.join(', ')}`, { status: 400 })
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
let height: number | undefined
|
|
306
|
+
if (heightParam !== undefined) {
|
|
307
|
+
// A crop needs both axes: `w` from the width allowlist, `h` derived from
|
|
308
|
+
// an allowlisted aspect — the finite-variant DoS bound, |widths|x|aspects|.
|
|
309
|
+
height = Number(heightParam)
|
|
310
|
+
const w = width
|
|
311
|
+
if (w === undefined || !config.aspectRatios.some((a) => height === Math.round(w / a))) {
|
|
312
|
+
return new Response('h requires w, and w/h must match an allowlisted aspect', { status: 400 })
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const dot = rel.lastIndexOf('.')
|
|
317
|
+
const requestedExt = rel.slice(dot + 1).toLowerCase()
|
|
318
|
+
const contentType = IMAGE_CONTENT_TYPES[requestedExt]
|
|
319
|
+
if (!contentType) return new Response('not found', { status: 404 })
|
|
320
|
+
|
|
321
|
+
const original = resolveOriginal(config, rel)
|
|
322
|
+
if (!original) return new Response('not found', { status: 404 })
|
|
323
|
+
|
|
324
|
+
// Content-hash validators (spec: image-caching). ETags are per-URL and the
|
|
325
|
+
// transform params ARE the URL, so hash(original) is the complete entropy;
|
|
326
|
+
// the params suffix on variants is labeling for humans, not identity. The
|
|
327
|
+
// encoder is deliberately excluded (an upgrade must not mass-bust visually
|
|
328
|
+
// identical pixels) — which breaks the byte-identity promise a strong ETag
|
|
329
|
+
// makes, so variants carry WEAK validators; originals stay strong.
|
|
330
|
+
let hash: string
|
|
331
|
+
try {
|
|
332
|
+
hash = originalHash(original.full)
|
|
333
|
+
} catch {
|
|
334
|
+
return new Response('not found', { status: 404 })
|
|
335
|
+
}
|
|
336
|
+
const sameFormat =
|
|
337
|
+
IMAGE_CONTENT_TYPES[original.ext] === contentType && width === undefined && height === undefined
|
|
338
|
+
const sizeLabel = `${width ?? 'orig'}${height !== undefined ? `x${height}` : ''}`
|
|
339
|
+
const etag = sameFormat ? `"${hash}"` : `W/"${hash}-${sizeLabel}-${requestedExt}"`
|
|
340
|
+
const headers = {
|
|
341
|
+
'Content-Type': contentType,
|
|
342
|
+
ETag: etag,
|
|
343
|
+
'Cache-Control': cacheControlValue(config),
|
|
344
|
+
...(requestedExt === 'svg' ? SVG_SECURITY_HEADERS : undefined),
|
|
345
|
+
}
|
|
346
|
+
// Early 304 — validators derive from the ORIGINAL, so a match answers
|
|
347
|
+
// before any encode work (or even a variant file) exists.
|
|
348
|
+
const bare = etag.replace(/^W\//, '')
|
|
349
|
+
if (ifNoneMatch?.split(',').some((t) => t.trim().replace(/^W\//, '') === bare)) {
|
|
350
|
+
return new Response(null, { status: 304, headers })
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
let file: string
|
|
354
|
+
if (sameFormat) {
|
|
355
|
+
file = original.full
|
|
356
|
+
} else {
|
|
357
|
+
if (!TRANSCODE_TARGETS.has(requestedExt) || ORIGINAL_ONLY_SOURCES.has(original.ext)) {
|
|
358
|
+
return new Response('not found', { status: 404 })
|
|
359
|
+
}
|
|
360
|
+
// The source-hash lives IN the cache filename: freshness is a pure
|
|
361
|
+
// existence check — a changed original hashes to a different name and
|
|
362
|
+
// simply misses. (Replaces mtime comparison, which mtime-preserving and
|
|
363
|
+
// mtime-resetting copies each fooled in one direction. Stale-hash
|
|
364
|
+
// siblings are identifiable garbage for a future `stator images gc`.)
|
|
365
|
+
const cache = join(
|
|
366
|
+
config.dir,
|
|
367
|
+
'.variants',
|
|
368
|
+
`${rel.slice(0, dot)}-${hash.slice(0, 8)}-${sizeLabel}.${requestedExt}`,
|
|
369
|
+
)
|
|
370
|
+
if (!existsSync(cache)) {
|
|
371
|
+
let job = inflight.get(cache)
|
|
372
|
+
if (!job) {
|
|
373
|
+
const format =
|
|
374
|
+
requestedExt === 'jpg' ? 'jpeg' : (requestedExt as 'jpeg' | 'png' | 'webp' | 'avif')
|
|
375
|
+
job = (async () => {
|
|
376
|
+
await acquireTransformSlot(config.concurrency)
|
|
377
|
+
try {
|
|
378
|
+
const out = await config.transformer.transform(
|
|
379
|
+
new Uint8Array(await readFile(original.full)),
|
|
380
|
+
{ width, height, format },
|
|
381
|
+
)
|
|
382
|
+
mkdirSync(dirname(cache), { recursive: true })
|
|
383
|
+
const { writeFile } = await import('node:fs/promises')
|
|
384
|
+
await writeFile(cache, out)
|
|
385
|
+
} finally {
|
|
386
|
+
releaseTransformSlot()
|
|
387
|
+
}
|
|
388
|
+
})().finally(() => inflight.delete(cache))
|
|
389
|
+
inflight.set(cache, job)
|
|
390
|
+
}
|
|
391
|
+
// The on-demand contract is "first requester sees a SMALL delay" —
|
|
392
|
+
// enforce it. A pathological encode (large AVIF on a starved host) can
|
|
393
|
+
// take 30s+ or swap-thrash the machine; past the deadline this request
|
|
394
|
+
// degrades to the stored original (a redirect, so the Content-Type
|
|
395
|
+
// stays honest) while the encode keeps running to fill the cache. The
|
|
396
|
+
// browser renders real pixels now; the next visitor gets the variant.
|
|
397
|
+
if (config.encodeTimeoutMs > 0) {
|
|
398
|
+
const finished = await new Promise<boolean>((done) => {
|
|
399
|
+
const timer = setTimeout(() => done(false), config.encodeTimeoutMs)
|
|
400
|
+
const settle = () => {
|
|
401
|
+
clearTimeout(timer)
|
|
402
|
+
done(true) // rejection included — the `await job` below rethrows it
|
|
403
|
+
}
|
|
404
|
+
job.then(settle, settle)
|
|
405
|
+
})
|
|
406
|
+
if (!finished) {
|
|
407
|
+
return new Response(null, {
|
|
408
|
+
status: 302,
|
|
409
|
+
headers: {
|
|
410
|
+
Location: `${config.path}/${rel.slice(0, dot)}.${original.ext}`,
|
|
411
|
+
'Cache-Control': 'no-store',
|
|
412
|
+
},
|
|
413
|
+
})
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
await job
|
|
417
|
+
}
|
|
418
|
+
file = cache
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
return new Response(new Uint8Array(await readFile(file)), { headers })
|
|
423
|
+
} catch {
|
|
424
|
+
return new Response('not found', { status: 404 })
|
|
425
|
+
}
|
|
426
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -52,6 +52,13 @@ export {
|
|
|
52
52
|
export { scheduleSessionEffects, wireAppEffects } from './effects.ts'
|
|
53
53
|
export type { HttpConfig } from './http.ts'
|
|
54
54
|
export { buildHonoApp } from './http.ts'
|
|
55
|
+
export type { ImageTransformer, ResolvedImagesConfig } from './images.ts'
|
|
56
|
+
export {
|
|
57
|
+
DEFAULT_IMAGE_WIDTHS,
|
|
58
|
+
probeImage,
|
|
59
|
+
resolveImagesConfig,
|
|
60
|
+
sharpTransformer,
|
|
61
|
+
} from './images.ts'
|
|
55
62
|
export type { InspectPayload, InspectRoute, InspectRouteMethod } from './inspect.ts'
|
|
56
63
|
export { buildInspectPayload } from './inspect.ts'
|
|
57
64
|
export type { InstanceHandle } from './instance-proxy.ts'
|
|
@@ -12,7 +12,7 @@ import type {
|
|
|
12
12
|
QueryRouteResult,
|
|
13
13
|
RouteContext,
|
|
14
14
|
} from './routing.ts'
|
|
15
|
-
import { getOrCreateSessionId } from './session.ts'
|
|
15
|
+
import { getOrCreateSessionId, peekSessionId, sessionUse } from './session.ts'
|
|
16
16
|
import { withSessionLock } from './session-lock.ts'
|
|
17
17
|
import { SessionRuntime } from './session-runtime.ts'
|
|
18
18
|
|
|
@@ -24,6 +24,7 @@ const queryLog = scopedLogger('query')
|
|
|
24
24
|
const DATA_CONTENT_TYPES: Record<string, string> = {
|
|
25
25
|
'.json': 'application/json; charset=utf-8',
|
|
26
26
|
'.xml': 'application/xml; charset=utf-8',
|
|
27
|
+
'.atom': 'application/atom+xml; charset=utf-8',
|
|
27
28
|
'.txt': 'text/plain; charset=utf-8',
|
|
28
29
|
'.ics': 'text/calendar; charset=utf-8',
|
|
29
30
|
'.csv': 'text/csv; charset=utf-8',
|
|
@@ -55,8 +56,30 @@ export async function runQueryRoute(
|
|
|
55
56
|
/** Path params from the framework's own matcher (the GET catch-all
|
|
56
57
|
* bypasses Hono's per-route param extraction). */
|
|
57
58
|
params?: Record<string, string>,
|
|
59
|
+
/** Layer-3 derived Cache-Control values (absent → never emitted). */
|
|
60
|
+
caching?: { sMaxAge: number; staleWhileRevalidate: number },
|
|
58
61
|
): Promise<Response> {
|
|
59
|
-
|
|
62
|
+
// Lazy layer 1: only session-machine reads establish; an app-only data
|
|
63
|
+
// route (a feed, a sitemap) renders sessionless and stays CDN-cacheable.
|
|
64
|
+
const needsSession = route.reads.some((def) => def.lifecycle === 'session')
|
|
65
|
+
|
|
66
|
+
// The revision ledger: when the route declares a cheap fingerprint, the
|
|
67
|
+
// ETag derives from it and a match answers 304 BEFORE the handler — the
|
|
68
|
+
// designed upgrade the body-hash ETag always pointed at. The fingerprint
|
|
69
|
+
// runs outside any session machinery, so the derived-caching proof holds.
|
|
70
|
+
let revisionEtag: string | undefined
|
|
71
|
+
if (route.revision) {
|
|
72
|
+
const rev = await route.revision()
|
|
73
|
+
revisionEtag = `"r-${createHash('sha1').update(String(rev)).digest('base64url').slice(0, 16)}"`
|
|
74
|
+
if ((c.req.header('if-none-match') ?? '').includes(revisionEtag)) {
|
|
75
|
+
const notModified = new Response(null, { status: 304, headers: { ETag: revisionEtag } })
|
|
76
|
+
applyDerivedCaching(c, notModified, { caching, needsSession })
|
|
77
|
+
return notModified
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const sessionId = needsSession
|
|
81
|
+
? getOrCreateSessionId(c).sessionId
|
|
82
|
+
: (peekSessionId(c) ?? '@anonymous')
|
|
60
83
|
const request = params
|
|
61
84
|
? { ...buildRouteRequest(c, discovered.paramNames), params }
|
|
62
85
|
: buildRouteRequest(c, discovered.paramNames)
|
|
@@ -99,12 +122,54 @@ export async function runQueryRoute(
|
|
|
99
122
|
scheduleSessionEffects(runtime, store, sessionId)
|
|
100
123
|
}
|
|
101
124
|
|
|
102
|
-
|
|
125
|
+
const res = synthesizeDataResponse(c, result)
|
|
126
|
+
// A revision route's responses carry the revision ETag (overriding the
|
|
127
|
+
// body hash) so the cheap 304 path and full responses validate against
|
|
128
|
+
// one token.
|
|
129
|
+
if (revisionEtag) {
|
|
130
|
+
try {
|
|
131
|
+
res.headers.set('etag', revisionEtag)
|
|
132
|
+
} catch {
|
|
133
|
+
// Immutable headers (proxied Response) — the handler's call.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
applyDerivedCaching(c, res, { caching, needsSession })
|
|
137
|
+
return res
|
|
103
138
|
} finally {
|
|
104
139
|
runtime.dispose()
|
|
105
140
|
}
|
|
106
141
|
}
|
|
107
142
|
|
|
143
|
+
/** Layer 3 — derived Cache-Control, same proof as pages: no session reads
|
|
144
|
+
* declared, no session use/claims read while handling, handler set nothing
|
|
145
|
+
* itself. Applied to 200 and 304 alike (a 304's headers update the cached
|
|
146
|
+
* entry's lifetime). */
|
|
147
|
+
function applyDerivedCaching(
|
|
148
|
+
c: Context,
|
|
149
|
+
res: Response,
|
|
150
|
+
opts: { caching?: { sMaxAge: number; staleWhileRevalidate: number }; needsSession: boolean },
|
|
151
|
+
): void {
|
|
152
|
+
const use = sessionUse(c)
|
|
153
|
+
if (
|
|
154
|
+
!opts.caching ||
|
|
155
|
+
opts.needsSession ||
|
|
156
|
+
use.used ||
|
|
157
|
+
use.claimsRead ||
|
|
158
|
+
res.status >= 400 ||
|
|
159
|
+
res.headers.has('cache-control')
|
|
160
|
+
) {
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
res.headers.set(
|
|
165
|
+
'cache-control',
|
|
166
|
+
`public, max-age=0, s-maxage=${opts.caching.sMaxAge}, stale-while-revalidate=${opts.caching.staleWhileRevalidate}`,
|
|
167
|
+
)
|
|
168
|
+
} catch {
|
|
169
|
+
// Immutable headers (proxied Response) — the handler's call.
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
108
173
|
/** The URL's extension (`/rss.xml` → `.xml`). A leading dot alone
|
|
109
174
|
* (`/.well-known`) is not an extension. */
|
|
110
175
|
function urlExtension(path: string): string | undefined {
|
|
@@ -139,6 +139,7 @@ export function makeScope(prefix: string): Scope {
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
export interface RenderState {
|
|
142
|
+
images?: import('./images.ts').ImagesRenderInfo
|
|
142
143
|
sessionId: SessionId
|
|
143
144
|
routeKey: string
|
|
144
145
|
bindings: Map<SlotId, Binding>
|
|
@@ -186,6 +187,14 @@ export function createRenderState(sessionId: SessionId, routeKey: string): Rende
|
|
|
186
187
|
|
|
187
188
|
let currentRenderState: RenderState | null = null
|
|
188
189
|
|
|
190
|
+
/** The images config as the render side sees it (set by renderRoute from
|
|
191
|
+
* HttpConfig). Components read it so their srcset widths and crop aspects
|
|
192
|
+
* can't drift from what the endpoint accepts; null outside a configured
|
|
193
|
+
* render (tests, apps without `images`). */
|
|
194
|
+
export function currentImages(): import('./images.ts').ImagesRenderInfo | null {
|
|
195
|
+
return currentRenderState?.images ?? null
|
|
196
|
+
}
|
|
197
|
+
|
|
189
198
|
export function getCurrentRenderState(): RenderState | null {
|
|
190
199
|
return currentRenderState
|
|
191
200
|
}
|
package/src/server/render.ts
CHANGED
|
@@ -95,6 +95,8 @@ export interface RenderRouteOptions {
|
|
|
95
95
|
* passes false: it runs under the session lock and must not kick defer I/O;
|
|
96
96
|
* its defer slots emit inert placeholders that the diff never touches. */
|
|
97
97
|
resolveDeferred?: boolean
|
|
98
|
+
/** Endpoint widths/aspects for `<Image>`/`<Picture>` (see RenderState.images). */
|
|
99
|
+
images?: import('./images.ts').ImagesRenderInfo
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
export async function renderRoute(
|
|
@@ -107,6 +109,7 @@ export async function renderRoute(
|
|
|
107
109
|
): Promise<RenderResult> {
|
|
108
110
|
const state = createRenderState(sessionId, routeKey)
|
|
109
111
|
state.resolveDeferred = opts.resolveDeferred ?? true
|
|
112
|
+
state.images = opts.images
|
|
110
113
|
const { ctx: responseCtx, effects } = createResponseContext()
|
|
111
114
|
const renderCtx: RouteRenderContext = {
|
|
112
115
|
response: responseCtx,
|
|
@@ -35,6 +35,14 @@ export interface DiscoveredRoute {
|
|
|
35
35
|
PUT?: ApiRouteDefinition
|
|
36
36
|
PATCH?: ApiRouteDefinition
|
|
37
37
|
DELETE?: ApiRouteDefinition
|
|
38
|
+
/** True for a `404.stator`/`404.ts` file — sugar for a `[...name]`
|
|
39
|
+
* catch-all (same synthesized pattern, same matcher), except
|
|
40
|
+
* `sortRoutes` forces it to the absolute lowest priority: below even a
|
|
41
|
+
* real `[...name]` at the same depth. It's the last resort, only ever
|
|
42
|
+
* reached once nothing else — including a genuine catch-all a request
|
|
43
|
+
* matched first — could actually serve the request (a 404 cascades by
|
|
44
|
+
* default; see the GET dispatch loop in http.ts). */
|
|
45
|
+
isNotFoundFallback?: boolean
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
/**
|
|
@@ -47,6 +55,11 @@ export interface DiscoveredRoute {
|
|
|
47
55
|
* - `routes/[a]/[b].ts` → `/:a/:b`
|
|
48
56
|
* - `routes/foo/[...rest].ts` → `/foo/*rest` (catch-all: terminal, matches
|
|
49
57
|
* zero+ segments, param is the raw remainder incl. slashes)
|
|
58
|
+
* - `routes/foo/404.ts` → sugar for `routes/foo/[...___].ts` — a
|
|
59
|
+
* catch-all without needing to know the bracket-rest convention, EXCEPT
|
|
60
|
+
* `sortRoutes` also forces it to the absolute lowest match priority,
|
|
61
|
+
* below even a real `[...rest]` at the same depth — see
|
|
62
|
+
* `DiscoveredRoute.isNotFoundFallback`.
|
|
50
63
|
*
|
|
51
64
|
* Files may export any combination of `GET`/`POST`/`PUT`/`PATCH`/`DELETE`.
|
|
52
65
|
* GET is a `defineRoute` (page renderer) or a `defineApiRoute` declaring
|
|
@@ -123,11 +136,12 @@ export async function discoverRoutes(
|
|
|
123
136
|
continue
|
|
124
137
|
}
|
|
125
138
|
|
|
126
|
-
const { urlPath, paramNames } = filePathToRoute(absDir, filePath)
|
|
139
|
+
const { urlPath, paramNames, isNotFoundFallback } = filePathToRoute(absDir, filePath)
|
|
127
140
|
routes.push({
|
|
128
141
|
urlPath,
|
|
129
142
|
paramNames,
|
|
130
143
|
filePath,
|
|
144
|
+
...(isNotFoundFallback ? { isNotFoundFallback } : {}),
|
|
131
145
|
GET: get,
|
|
132
146
|
POST: post,
|
|
133
147
|
PUT: put,
|
|
@@ -169,6 +183,10 @@ function mergeByUrlPath(routes: DiscoveredRoute[]): DiscoveredRoute[] {
|
|
|
169
183
|
/**
|
|
170
184
|
* Sort routes most-specific-first (Astro's model). At match time the first
|
|
171
185
|
* matcher that matches wins, so order encodes priority:
|
|
186
|
+
* - a `404.stator`/`404.ts` file (`isNotFoundFallback`) ranks absolute
|
|
187
|
+
* last, below even a real `[...name]` at the same depth — it's the
|
|
188
|
+
* final resort, only reached once nothing else (a genuine catch-all
|
|
189
|
+
* included) could serve the request
|
|
172
190
|
* - routes without a rest/catch-all segment rank before those with one
|
|
173
191
|
* - per segment, left to right: static (0) > suffixed param `:id.json` (1)
|
|
174
192
|
* > bare param (2) > rest (3) — a suffixed param outranks a bare one so
|
|
@@ -186,6 +204,10 @@ export function sortRoutes(routes: DiscoveredRoute[]): DiscoveredRoute[] {
|
|
|
186
204
|
)
|
|
187
205
|
|
|
188
206
|
return [...routes].sort((a, b) => {
|
|
207
|
+
const aFallback = !!a.isNotFoundFallback
|
|
208
|
+
const bFallback = !!b.isNotFoundFallback
|
|
209
|
+
if (aFallback !== bFallback) return aFallback ? 1 : -1 // 404 fallback always last
|
|
210
|
+
|
|
189
211
|
const ka = kinds(a.urlPath)
|
|
190
212
|
const kb = kinds(b.urlPath)
|
|
191
213
|
const aRest = ka.includes(3)
|
|
@@ -230,7 +252,7 @@ async function walkRouteFiles(dir: string): Promise<string[]> {
|
|
|
230
252
|
export function filePathToRoute(
|
|
231
253
|
absDir: string,
|
|
232
254
|
filePath: string,
|
|
233
|
-
): { urlPath: string; paramNames: string[] } {
|
|
255
|
+
): { urlPath: string; paramNames: string[]; isNotFoundFallback: boolean } {
|
|
234
256
|
const ext = extname(filePath)
|
|
235
257
|
const rel = relative(absDir, filePath)
|
|
236
258
|
const dirSegments = dirname(rel)
|
|
@@ -240,9 +262,18 @@ export function filePathToRoute(
|
|
|
240
262
|
// sibling `<name>.stator.ts` files, which must map to the same URL as the
|
|
241
263
|
// `<name>.stator` source did in dev.
|
|
242
264
|
const fileBase = basename(rel, ext).replace(/\.stator$/, '')
|
|
243
|
-
|
|
265
|
+
// `404` is sugar for a synthesized `[...name]` at this exact position —
|
|
266
|
+
// reuses the rest-segment branch below verbatim, so it gets the same
|
|
267
|
+
// matcher/regex a real catch-all would. `sortRoutes` is what actually
|
|
268
|
+
// forces it below a real `[...name]` at the same depth; this alone would
|
|
269
|
+
// just be an ordinary (if oddly-named) catch-all.
|
|
270
|
+
const isNotFoundFallback = fileBase === '404'
|
|
271
|
+
const segments =
|
|
272
|
+
fileBase === 'index'
|
|
273
|
+
? dirSegments
|
|
274
|
+
: [...dirSegments, isNotFoundFallback ? '[...__stator_notfound__]' : fileBase]
|
|
244
275
|
|
|
245
|
-
if (segments.length === 0) return { urlPath: '/', paramNames: [] }
|
|
276
|
+
if (segments.length === 0) return { urlPath: '/', paramNames: [], isNotFoundFallback }
|
|
246
277
|
|
|
247
278
|
const paramNames: string[] = []
|
|
248
279
|
const urlSegments = segments.map((seg) => {
|
|
@@ -272,5 +303,5 @@ export function filePathToRoute(
|
|
|
272
303
|
return seg
|
|
273
304
|
})
|
|
274
305
|
|
|
275
|
-
return { urlPath: `/${urlSegments.join('/')}`, paramNames }
|
|
306
|
+
return { urlPath: `/${urlSegments.join('/')}`, paramNames, isNotFoundFallback }
|
|
276
307
|
}
|