@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.
@@ -13,6 +13,7 @@ import { findFreePort, installGracefulShutdown, printDevBanner } from './banner.
13
13
  import { resolveAppConfig } from './config-compat.ts'
14
14
  import type { DevApp, DevAppConfig } from './dev.ts'
15
15
  import { loadDotenv } from './env.ts'
16
+ import { resolveImagesConfig } from './images.ts'
16
17
  import { logger, setLogLevel } from './logger.ts'
17
18
  import type { MachineStore } from './machine-store.ts'
18
19
  import type { DiscoveredRoute } from './route-discovery.ts'
@@ -73,6 +74,7 @@ export async function createViteDevApp(config: DevAppConfig): Promise<DevApp> {
73
74
  const loader = (file: string) => vite.ssrLoadModule(file) as Promise<Record<string, unknown>>
74
75
 
75
76
  const root = resolve(config.root)
77
+ const images = config.images ? resolveImagesConfig(config.images) : undefined
76
78
  const machinesDir = resolve(config.machinesDir)
77
79
  const routesDir = resolve(config.routesDir)
78
80
  const resolved = resolveAppConfig(config)
@@ -201,6 +203,7 @@ export async function createViteDevApp(config: DevAppConfig): Promise<DevApp> {
201
203
  routes,
202
204
  store,
203
205
  staticDir: config.staticDir,
206
+ images,
204
207
  headExtras,
205
208
  inspector: inspectorOn,
206
209
  inspect: true,
package/src/server/dev.ts CHANGED
@@ -41,6 +41,20 @@ export interface DevAppConfig {
41
41
  cookie?: { sameSite?: 'Lax' | 'Strict' }
42
42
  }
43
43
  /** Dev-only tooling. */
44
+ /** Image serving — mirrors `StatorConfig.images`. */
45
+ images?: {
46
+ dir: string
47
+ path?: string
48
+ widths?: number[]
49
+ aspectRatios?: number[]
50
+ transformer?: import('./images.ts').ImageTransformer
51
+ concurrency?: number
52
+ threads?: number
53
+ encodeTimeoutMs?: number
54
+ maxAge?: number
55
+ staleWhileRevalidate?: number
56
+ immutable?: boolean
57
+ }
44
58
  dev?: {
45
59
  /** Auto-inject the dev inspector toolbar. On by default; set false to disable. */
46
60
  inspector?: boolean
@@ -9,6 +9,7 @@ import { applyRenderedEffects, runApiRoute } from './api-route.ts'
9
9
  import { crossSiteGuard } from './csrf.ts'
10
10
  import { scheduleSessionEffects } from './effects.ts'
11
11
  import { record, replayFor } from './event-dedupe.ts'
12
+ import { type ImagesRenderInfo, type ResolvedImagesConfig, serveImage } from './images.ts'
12
13
  import { buildInspectPayload } from './inspect.ts'
13
14
  import { scopedLogger } from './logger.ts'
14
15
  import type { MachineStore } from './machine-store.ts'
@@ -19,7 +20,14 @@ import { renderRoute } from './render.ts'
19
20
  import type { DiscoveredRoute } from './route-discovery.ts'
20
21
  import { buildRouteRequest } from './route-request.ts'
21
22
  import { isStatorQueryRoute, type RouteDefinition } from './routing.ts'
22
- import { CLAIMS_KEY, getOrCreateSessionId, getSessionState } from './session.ts'
23
+ import {
24
+ CLAIMS_KEY,
25
+ getOrCreateSessionId,
26
+ getSessionState,
27
+ peekSessionId,
28
+ resumeSession,
29
+ sessionUse,
30
+ } from './session.ts'
23
31
  import { withSessionLock } from './session-lock.ts'
24
32
  import { SessionRuntime } from './session-runtime.ts'
25
33
  import { enqueue, fanOut, registerConnection, unregisterConnection } from './sse.ts'
@@ -30,6 +38,9 @@ export interface HttpConfig {
30
38
  routes: DiscoveredRoute[]
31
39
  store: MachineStore
32
40
  staticDir?: string
41
+ /** Resolved image-serving config — present mounts the endpoint at
42
+ * `images.path` (see server/images.ts). */
43
+ images?: ResolvedImagesConfig
33
44
  /** Optional hook to inject extra `<head>` HTML for a GET route, keyed by the
34
45
  * route's file path. The dev server uses this to inline collected scoped CSS
35
46
  * (SSR head injection). Inserted at the `</head>` boundary. */
@@ -64,8 +75,18 @@ export interface HttpConfig {
64
75
  /** The app's discovered `middleware.ts` definition (if any). `withDefaults`
65
76
  * controls whether the framework security stack is prepended. */
66
77
  middleware?: MiddlewareDefinition
78
+ /** Layer-3 derived Cache-Control: emitted on GET responses the framework can
79
+ * PROVE anonymous-identical (no session reads declared, no session use or
80
+ * claims read while handling, nothing hand-set). Absent → never emitted
81
+ * (the dev servers leave it off so editing always re-renders). */
82
+ caching?: { sMaxAge: number; staleWhileRevalidate: number }
67
83
  }
68
84
 
85
+ /** Placeholder sid for session-free page renders — `loadGraph` loads only
86
+ * session-lifecycle machines, so on app-only routes this never reaches the
87
+ * store; it exists to keep SessionRuntime's signature honest. */
88
+ const ANONYMOUS_SID = '@anonymous'
89
+
69
90
  const eventSchema = z.object({
70
91
  machine: z.string(),
71
92
  event: z
@@ -113,14 +134,20 @@ function compileMatcher(route: DiscoveredRoute): RouteMatcher {
113
134
  return { route, regex: new RegExp(`^${pattern}$`) }
114
135
  }
115
136
 
137
+ /** One matcher's result against a literal path: the route plus its
138
+ * extracted path params. */
139
+ interface PathMatch {
140
+ route: DiscoveredRoute
141
+ params: Record<string, string>
142
+ }
143
+
116
144
  /**
117
- * Match a literal URL path against compiled matchers. Returns the matched
118
- * route + extracted params, or null if nothing matches.
145
+ * Match a literal URL path against every compiled matcher that matches it,
146
+ * in `matchers`' own (priority-sorted) order — every candidate a 404-
147
+ * cascading GET dispatch might need to try, not just the first.
119
148
  */
120
- function matchPath(
121
- matchers: RouteMatcher[],
122
- literalPath: string,
123
- ): { route: DiscoveredRoute; params: Record<string, string> } | null {
149
+ function matchAllPaths(matchers: RouteMatcher[], literalPath: string): PathMatch[] {
150
+ const matches: PathMatch[] = []
124
151
  for (const m of matchers) {
125
152
  const result = m.regex.exec(literalPath)
126
153
  if (!result) continue
@@ -128,9 +155,65 @@ function matchPath(
128
155
  m.route.paramNames.forEach((name, i) => {
129
156
  params[name] = decodeURIComponent(result[i + 1] ?? '')
130
157
  })
131
- return { route: m.route, params }
158
+ matches.push({ route: m.route, params })
132
159
  }
133
- return null
160
+ return matches
161
+ }
162
+
163
+ /**
164
+ * Match a literal URL path against compiled matchers. Returns the FIRST
165
+ * matched route + extracted params, or null if nothing matches — for call
166
+ * sites resolving an already-known route key (the SSE/event endpoints),
167
+ * where there is exactly one right answer and no cascading question.
168
+ */
169
+ function matchPath(matchers: RouteMatcher[], literalPath: string): PathMatch | null {
170
+ return matchAllPaths(matchers, literalPath)[0] ?? null
171
+ }
172
+
173
+ /** True when any segment of a urlPath is a rest/catch-all (`*name`). */
174
+ function isRestPattern(urlPath: string): boolean {
175
+ return urlPath.split('/').some((seg) => seg.startsWith('*'))
176
+ }
177
+
178
+ /** True when any segment of a urlPath is a suffixed param (`:name.ext`) —
179
+ * the file-extension-routing pattern (`[id].json.ts` → `/p/:id.json`). */
180
+ function isSuffixedParamPattern(urlPath: string): boolean {
181
+ return urlPath.split('/').some((seg) => seg.startsWith(':') && seg.includes('.'))
182
+ }
183
+
184
+ /**
185
+ * Candidates a 404-cascading GET dispatch may actually try, in priority
186
+ * order.
187
+ *
188
+ * Two matches can structurally overlap for one literal URL in ways that
189
+ * were never meant to form a "try something more general" chain:
190
+ *
191
+ * - A suffixed param (`/p/:id.json`, from `[id].json.ts`) and its bare
192
+ * sibling (`/p/:id`) — `compileMatcher`'s bare `:id` regex doesn't
193
+ * exclude dots, so both structurally match `/p/abc.json`. The suffix
194
+ * is the author's own explicit, dedicated handler for exactly that
195
+ * extension (`sortRoutes`'s own doc comment: "so `/p/:id.json` wins
196
+ * `/p/abc.json` before `/p/:id` can swallow it") — nothing ranked
197
+ * below it, bare OR rest, is a MORE correct answer than what was
198
+ * written on purpose for this exact case. (A rest/catch-all can't
199
+ * even carry a suffix of its own — `filePathToRoute` rejects
200
+ * `[...name].ext` outright — so it's exactly as wrong a fallback here
201
+ * as bare is: confirmed against a real app, examples/live-poll's
202
+ * `/p/:id.json` data route + `/p/:id` HTML page pair, where a 404
203
+ * from the JSON route cascading into the page handed back HTML for a
204
+ * request that explicitly asked for JSON.) So a winning suffixed
205
+ * match is unconditionally terminal — no cascading at all, into
206
+ * anything, once it's the one that matched.
207
+ * - Anything bare/static vs. a rest/catch-all IS a legitimate fallback
208
+ * chain (`routes/media/[...path].ts`, this feature's original
209
+ * motivating case) — a catch-all stays cascade-eligible unless the
210
+ * winning match was itself suffixed.
211
+ */
212
+ function cascadeCandidates<T extends PathMatch>(all: T[]): T[] {
213
+ if (all.length === 0) return all
214
+ const winner = all[0]!
215
+ if (isSuffixedParamPattern(winner.route.urlPath)) return [winner]
216
+ return [winner, ...all.slice(1).filter((m) => isRestPattern(m.route.urlPath))]
134
217
  }
135
218
 
136
219
  /** Parse a route key like "GET /p/abc-123" into method + literal path. */
@@ -154,6 +237,11 @@ function parseRouteKey(
154
237
 
155
238
  export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
156
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
157
245
  const clientJs = await bundleClient()
158
246
 
159
247
  // Request logger: one line per request with method, path, status, duration.
@@ -179,10 +267,13 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
179
267
  })
180
268
 
181
269
  // Context bridge — expose resolved config to every middleware (cors, app
182
- // middleware) that follows, off `stator(c)`. Runs first. Also establishes the
183
- // session once and eager-loads its claims (for a returning session), so the
184
- // whole request shares one sid + claims snapshot; dirty claims persist at the
185
- // end, to the current (possibly rotated) sid.
270
+ // middleware) that follows, off `stator(c)`. Runs first. Sessions are LAZY
271
+ // (the cacheable-read-path layer 1): an arriving cookie RESUMES its session
272
+ // (claims eager-loaded, no cookie write), but first contact establishes
273
+ // nothing — creation happens only at a dispatch, a session-machine read, an
274
+ // SSE connect, or an explicit session op. Anonymous reads therefore carry
275
+ // no Set-Cookie and stay CDN-cacheable. Dirty claims persist at the end,
276
+ // to the current (possibly rotated or just-established) sid.
186
277
  app.use('*', async (c, next) => {
187
278
  c.set('stator', {
188
279
  origin: config.origin,
@@ -194,14 +285,15 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
194
285
  c.set('statorStore', config.store)
195
286
  // Stash the signing key so the cookie jar's signed methods can reach it.
196
287
  if (config.secret !== undefined) c.set('statorSecret', config.secret)
197
- const { sessionId, isNew } = getOrCreateSessionId(c)
198
- const session = getSessionState(c)
199
- if (!isNew && session) {
200
- session.claims = (await config.store.persistence.get(sessionId, CLAIMS_KEY)) ?? undefined
288
+ sessionUse(c)
289
+ const resumed = resumeSession(c)
290
+ if (resumed) {
291
+ resumed.claims = (await config.store.persistence.get(resumed.sid, CLAIMS_KEY)) ?? undefined
201
292
  }
202
293
  await next()
203
- // Same session object (rotation mutates `.sid` in place), so persist to the
204
- // current — possibly rotated — id.
294
+ // Re-read: the session may have been established mid-request. Rotation
295
+ // mutates `.sid` in place, so this persists to the current id.
296
+ const session = getSessionState(c)
205
297
  if (session?.claimsDirty) {
206
298
  await config.store.persistence.set(session.sid, CLAIMS_KEY, session.claims, {
207
299
  ttlSeconds: config.store.sessionTtlSeconds,
@@ -260,7 +352,9 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
260
352
  const payload = await buildInspectPayload({
261
353
  store: config.store,
262
354
  routes: config.routes,
263
- sessionId: getSessionState(c)?.sid,
355
+ // Inspecting IS session use — the route shows the caller's own
356
+ // session, so establish one for a first-contact dev visit.
357
+ sessionId: getOrCreateSessionId(c).sessionId,
264
358
  buildId: config.buildId,
265
359
  })
266
360
  c.header('Cache-Control', 'no-cache')
@@ -323,6 +417,25 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
323
417
  })
324
418
  }
325
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
+
326
439
  // SSE endpoint. The connection's runtime + renderState stay alive for
327
440
  // the connection's lifetime — this is the one place per-session state
328
441
  // outlives a request, because the connection *is* one (very long) request.
@@ -375,7 +488,9 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
375
488
 
376
489
  const runtime = new SessionRuntime(sessionId, config.store)
377
490
  await runtime.loadGraph(route.reads)
378
- const { renderState } = await renderRoute(route, routeKey, sessionId, runtime, request)
491
+ const { renderState } = await renderRoute(route, routeKey, sessionId, runtime, request, {
492
+ images: imagesInfo,
493
+ })
379
494
 
380
495
  // The handler parks on `finished` until the connection ends. Both ways
381
496
  // out resolve it: the client aborting, and the server hanging up on a
@@ -531,6 +646,7 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
531
646
  // resolve defer slots here (that would kick their I/O under the lock).
532
647
  const { renderState } = await renderRoute(route, routeKey, sessionId, runtime, request, {
533
648
  resolveDeferred: false,
649
+ images: imagesInfo,
534
650
  })
535
651
 
536
652
  const touched = runtime.processEvent(body.machine, body.event)
@@ -591,24 +707,62 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
591
707
  })
592
708
  }
593
709
 
710
+ // 404 cascades by default: a matched GET route that 404s doesn't
711
+ // permanently claim the path — a lower-priority `[...name]`/`404`
712
+ // catch-all (if one exists) gets a turn, same priority order `matchers`
713
+ // is already sorted in. Only once every candidate 404s does the LAST
714
+ // (lowest-priority) one's response actually go out — so a route's own
715
+ // not-found body/headers/status still reach the client, this just stops
716
+ // the FIRST match from being final when a catch-all could actually
717
+ // serve it. `cascadeCandidates` (not the full `matchAllPaths` list) is
718
+ // what narrows "the next matching route" to specifically mean a
719
+ // catch-all — see its own doc comment for why that boundary matters.
594
720
  app.get('*', async (c, next) => {
595
- const matched = matchPath(matchers, c.req.path)
596
- if (!matched?.route.GET) return next()
597
- const getRoute = matched.route.GET
598
- // Brand decides the GET plane: data routes never touch the HTML path
599
- // (no client-runtime injection, no live meta — a raw Response out).
600
- if (isStatorQueryRoute(getRoute)) {
601
- return runQueryRoute(c, matched.route, getRoute, config.store, matched.params)
602
- }
603
- return handleGet(
604
- c,
605
- matched.route,
606
- getRoute,
607
- matched.params,
608
- config.store,
609
- config.headExtras,
610
- config.buildId,
721
+ const candidates = cascadeCandidates(
722
+ matchAllPaths(matchers, c.req.path).filter(
723
+ (
724
+ m,
725
+ ): m is PathMatch & {
726
+ route: DiscoveredRoute & { GET: NonNullable<DiscoveredRoute['GET']> }
727
+ } => !!m.route.GET,
728
+ ),
611
729
  )
730
+ if (candidates.length === 0) return next()
731
+
732
+ let lastNotFound: (() => Response) | undefined
733
+ for (const matched of candidates) {
734
+ const getRoute = matched.route.GET
735
+ // Brand decides the GET plane: data routes never touch the HTML path
736
+ // (no client-runtime injection, no live meta — a raw Response out).
737
+ if (isStatorQueryRoute(getRoute)) {
738
+ const response = await runQueryRoute(
739
+ c,
740
+ matched.route,
741
+ getRoute,
742
+ config.store,
743
+ matched.params,
744
+ config.caching,
745
+ )
746
+ if (response.status !== 404) return response
747
+ lastNotFound = () => response
748
+ continue
749
+ }
750
+
751
+ const candidate = await renderGetCandidate(
752
+ c,
753
+ matched.route,
754
+ getRoute,
755
+ matched.params,
756
+ config.store,
757
+ config.headExtras,
758
+ config.buildId,
759
+ config.caching,
760
+ imagesInfo,
761
+ )
762
+ if (candidate.status !== 404) return candidate.commit()
763
+ lastNotFound = candidate.commit
764
+ }
765
+ return lastNotFound ? lastNotFound() : next()
612
766
  })
613
767
 
614
768
  return app
@@ -672,7 +826,24 @@ function injectIntoDocument(html: string, parts: { head?: string; bodyEnd?: stri
672
826
  return out
673
827
  }
674
828
 
675
- async function handleGet(
829
+ /** A rendered GET candidate, not yet committed to the shared Hono `Context`.
830
+ * Cascading (see `matchAllPaths`/the GET dispatch loop) speculatively tries
831
+ * several routes for one path — `applyRenderedEffects` writes headers,
832
+ * cookies, and status directly onto `c`, cumulatively, so calling it for a
833
+ * candidate that turns out to 404 would leak into whatever candidate wins
834
+ * next (worse: `applyRenderedEffects` only calls `c.status(...)` when the
835
+ * status isn't 200, so a losing 404 candidate followed by a winning 200 one
836
+ * would ship 200 content under a stale 404 status — confirmed the hard way
837
+ * designing this). Splitting render (safe to call speculatively, side-effect
838
+ * free on `c`) from commit (writes to `c`, only ever called once, for
839
+ * whichever candidate the dispatch loop actually decided to serve) is what
840
+ * makes cascading safe. */
841
+ interface GetCandidate {
842
+ status: number
843
+ commit: () => Response
844
+ }
845
+
846
+ async function renderGetCandidate(
676
847
  c: Context,
677
848
  discovered: DiscoveredRoute,
678
849
  route: RouteDefinition,
@@ -680,64 +851,101 @@ async function handleGet(
680
851
  store: MachineStore,
681
852
  headExtras?: (filePath: string) => string | Promise<string>,
682
853
  buildId?: string,
683
- ): Promise<Response> {
684
- {
685
- const { sessionId } = getOrCreateSessionId(c)
686
- const literalPath = c.req.path
687
- const routeKey = `GET ${literalPath}`
688
- const request = { ...buildRouteRequest(c, discovered.paramNames), params }
689
-
690
- const runtime = new SessionRuntime(sessionId, store)
691
- try {
692
- await runtime.loadGraph(route.reads)
693
- const result = await renderRoute(route, routeKey, sessionId, runtime, request)
694
- let html = result.html
695
-
696
- const headHtml: string[] = []
697
- if (headExtras) {
698
- const extra = await headExtras(discovered.filePath)
699
- if (extra) headHtml.push(extra)
700
- }
701
- if (route.live) {
702
- headHtml.push('<meta name="stator-live" content="true">')
703
- // The build this page was rendered against — the client echoes it on
704
- // /__sse connect so the server can reload a page from a stale build.
705
- // `buildId` is a framework-generated UUID (no HTML-special chars).
706
- if (buildId) {
707
- headHtml.push(`<meta name="stator-build" content="${buildId}">`)
708
- }
854
+ caching?: { sMaxAge: number; staleWhileRevalidate: number },
855
+ images?: ImagesRenderInfo,
856
+ ): Promise<GetCandidate> {
857
+ // Lazy layer 1: only a session-machine read forces establishment. An
858
+ // app-only (or read-free) page renders against the resumed sid when a
859
+ // cookie arrived, or an inert placeholder — loadGraph loads session
860
+ // machines only, so the placeholder never reaches the store.
861
+ const needsSession = route.reads.some((def) => def.lifecycle === 'session')
862
+ const sessionId = needsSession
863
+ ? getOrCreateSessionId(c).sessionId
864
+ : (peekSessionId(c) ?? ANONYMOUS_SID)
865
+ const literalPath = c.req.path
866
+ const routeKey = `GET ${literalPath}`
867
+ const request = { ...buildRouteRequest(c, discovered.paramNames), params }
868
+
869
+ const runtime = new SessionRuntime(sessionId, store)
870
+ try {
871
+ await runtime.loadGraph(route.reads)
872
+ const result = await renderRoute(route, routeKey, sessionId, runtime, request, { images })
873
+ let html = result.html
874
+
875
+ const headHtml: string[] = []
876
+ if (headExtras) {
877
+ const extra = await headExtras(discovered.filePath)
878
+ if (extra) headHtml.push(extra)
879
+ }
880
+ if (route.live) {
881
+ headHtml.push('<meta name="stator-live" content="true">')
882
+ // The build this page was rendered against — the client echoes it on
883
+ // /__sse connect so the server can reload a page from a stale build.
884
+ // `buildId` is a framework-generated UUID (no HTML-special chars).
885
+ if (buildId) {
886
+ headHtml.push(`<meta name="stator-build" content="${buildId}">`)
709
887
  }
888
+ }
710
889
 
711
- // Auto-inject the client runtime (delegated events + patch application).
712
- // Apps never hand-include it — a forgotten <script> is a silently dead
713
- // page (events fire nothing, no patches apply). Idempotent: skipped if the
714
- // document already references it, so a layout that still carries the tag
715
- // (or two passes sharing a doc) never loads it twice.
716
- const bodyHtml: string[] = []
717
- if (!html.includes('/static/client.js')) {
718
- bodyHtml.push('<script src="/static/client.js"></script>')
719
- }
890
+ // Auto-inject the client runtime (delegated events + patch application).
891
+ // Apps never hand-include it — a forgotten <script> is a silently dead
892
+ // page (events fire nothing, no patches apply). Idempotent: skipped if the
893
+ // document already references it, so a layout that still carries the tag
894
+ // (or two passes sharing a doc) never loads it twice.
895
+ const bodyHtml: string[] = []
896
+ if (!html.includes('/static/client.js')) {
897
+ bodyHtml.push('<script src="/static/client.js"></script>')
898
+ }
720
899
 
721
- html = injectIntoDocument(html, {
722
- head: headHtml.join(''),
723
- bodyEnd: bodyHtml.join(''),
724
- })
725
- applyRenderedEffects(c, result.response)
726
-
727
- // A fresh machine that fired its initial entry effect on load must be
728
- // persisted (so the next request hydrates instead of re-firing) and its
729
- // effect scheduled off-lock, after the response. The common GET (no entry
730
- // effect) skips both and stays a pure read.
731
- const entryFired = runtime.entryFiredMachines()
732
- if (entryFired.size > 0) {
733
- await runtime.persistTouched(entryFired)
734
- scheduleSessionEffects(runtime, store, sessionId)
735
- }
900
+ html = injectIntoDocument(html, {
901
+ head: headHtml.join(''),
902
+ bodyEnd: bodyHtml.join(''),
903
+ })
904
+
905
+ // Fired regardless of whether this candidate ends up winning: an entry
906
+ // effect firing is a fact about the MACHINE having been loaded for the
907
+ // first time (shared, cross-cutting state), not about which route's
908
+ // response the caller ultimately serves — a discarded losing candidate
909
+ // still genuinely loaded whatever it read.
910
+ const entryFired = runtime.entryFiredMachines()
911
+ if (entryFired.size > 0) {
912
+ await runtime.persistTouched(entryFired)
913
+ scheduleSessionEffects(runtime, store, sessionId)
914
+ }
736
915
 
737
- return c.html(html)
738
- } finally {
739
- runtime.dispose()
916
+ // Layer 3 — derived Cache-Control: the page is PROVABLY anonymous-
917
+ // identical (no session reads declared, no session use or claims read
918
+ // during handling) and the route didn't hand-set the header. The
919
+ // framework says what it can prove; hand-set always wins. Computed here
920
+ // but only ever WRITTEN from `commit`, below — this render may be a
921
+ // losing, discarded candidate under cascading, and only the winner's
922
+ // headers may reach `c` (the same reason `applyRenderedEffects` is
923
+ // deferred to `commit` rather than called here).
924
+ const use = sessionUse(c)
925
+ const deriveCacheControl =
926
+ !!caching &&
927
+ !needsSession &&
928
+ !use.used &&
929
+ !use.claimsRead &&
930
+ result.response.status < 300 &&
931
+ result.response.cookies.length === 0 &&
932
+ !result.response.headers.has('cache-control')
933
+
934
+ return {
935
+ status: result.response.status,
936
+ commit: () => {
937
+ applyRenderedEffects(c, result.response)
938
+ if (deriveCacheControl) {
939
+ c.header(
940
+ 'Cache-Control',
941
+ `public, max-age=0, s-maxage=${caching!.sMaxAge}, stale-while-revalidate=${caching!.staleWhileRevalidate}`,
942
+ )
943
+ }
944
+ return c.html(html)
945
+ },
740
946
  }
947
+ } finally {
948
+ runtime.dispose()
741
949
  }
742
950
  }
743
951