@statorjs/stator 2.10.0-next.0 → 2.10.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.
@@ -19,10 +19,17 @@ import { renderRoute } from './render.ts'
19
19
  import type { DiscoveredRoute } from './route-discovery.ts'
20
20
  import { buildRouteRequest } from './route-request.ts'
21
21
  import { isStatorQueryRoute, type RouteDefinition } from './routing.ts'
22
- import { CLAIMS_KEY, getOrCreateSessionId, getSessionState } from './session.ts'
22
+ import {
23
+ CLAIMS_KEY,
24
+ getOrCreateSessionId,
25
+ getSessionState,
26
+ peekSessionId,
27
+ resumeSession,
28
+ sessionUse,
29
+ } from './session.ts'
23
30
  import { withSessionLock } from './session-lock.ts'
24
31
  import { SessionRuntime } from './session-runtime.ts'
25
- import { fanOut, registerConnection, unregisterConnection } from './sse.ts'
32
+ import { enqueue, fanOut, registerConnection, unregisterConnection } from './sse.ts'
26
33
 
27
34
  const httpLog = scopedLogger('http')
28
35
 
@@ -64,8 +71,18 @@ export interface HttpConfig {
64
71
  /** The app's discovered `middleware.ts` definition (if any). `withDefaults`
65
72
  * controls whether the framework security stack is prepended. */
66
73
  middleware?: MiddlewareDefinition
74
+ /** Layer-3 derived Cache-Control: emitted on GET responses the framework can
75
+ * PROVE anonymous-identical (no session reads declared, no session use or
76
+ * claims read while handling, nothing hand-set). Absent → never emitted
77
+ * (the dev servers leave it off so editing always re-renders). */
78
+ caching?: { sMaxAge: number; staleWhileRevalidate: number }
67
79
  }
68
80
 
81
+ /** Placeholder sid for session-free page renders — `loadGraph` loads only
82
+ * session-lifecycle machines, so on app-only routes this never reaches the
83
+ * store; it exists to keep SessionRuntime's signature honest. */
84
+ const ANONYMOUS_SID = '@anonymous'
85
+
69
86
  const eventSchema = z.object({
70
87
  machine: z.string(),
71
88
  event: z
@@ -113,14 +130,20 @@ function compileMatcher(route: DiscoveredRoute): RouteMatcher {
113
130
  return { route, regex: new RegExp(`^${pattern}$`) }
114
131
  }
115
132
 
133
+ /** One matcher's result against a literal path: the route plus its
134
+ * extracted path params. */
135
+ interface PathMatch {
136
+ route: DiscoveredRoute
137
+ params: Record<string, string>
138
+ }
139
+
116
140
  /**
117
- * Match a literal URL path against compiled matchers. Returns the matched
118
- * route + extracted params, or null if nothing matches.
141
+ * Match a literal URL path against every compiled matcher that matches it,
142
+ * in `matchers`' own (priority-sorted) order — every candidate a 404-
143
+ * cascading GET dispatch might need to try, not just the first.
119
144
  */
120
- function matchPath(
121
- matchers: RouteMatcher[],
122
- literalPath: string,
123
- ): { route: DiscoveredRoute; params: Record<string, string> } | null {
145
+ function matchAllPaths(matchers: RouteMatcher[], literalPath: string): PathMatch[] {
146
+ const matches: PathMatch[] = []
124
147
  for (const m of matchers) {
125
148
  const result = m.regex.exec(literalPath)
126
149
  if (!result) continue
@@ -128,9 +151,65 @@ function matchPath(
128
151
  m.route.paramNames.forEach((name, i) => {
129
152
  params[name] = decodeURIComponent(result[i + 1] ?? '')
130
153
  })
131
- return { route: m.route, params }
154
+ matches.push({ route: m.route, params })
132
155
  }
133
- return null
156
+ return matches
157
+ }
158
+
159
+ /**
160
+ * Match a literal URL path against compiled matchers. Returns the FIRST
161
+ * matched route + extracted params, or null if nothing matches — for call
162
+ * sites resolving an already-known route key (the SSE/event endpoints),
163
+ * where there is exactly one right answer and no cascading question.
164
+ */
165
+ function matchPath(matchers: RouteMatcher[], literalPath: string): PathMatch | null {
166
+ return matchAllPaths(matchers, literalPath)[0] ?? null
167
+ }
168
+
169
+ /** True when any segment of a urlPath is a rest/catch-all (`*name`). */
170
+ function isRestPattern(urlPath: string): boolean {
171
+ return urlPath.split('/').some((seg) => seg.startsWith('*'))
172
+ }
173
+
174
+ /** True when any segment of a urlPath is a suffixed param (`:name.ext`) —
175
+ * the file-extension-routing pattern (`[id].json.ts` → `/p/:id.json`). */
176
+ function isSuffixedParamPattern(urlPath: string): boolean {
177
+ return urlPath.split('/').some((seg) => seg.startsWith(':') && seg.includes('.'))
178
+ }
179
+
180
+ /**
181
+ * Candidates a 404-cascading GET dispatch may actually try, in priority
182
+ * order.
183
+ *
184
+ * Two matches can structurally overlap for one literal URL in ways that
185
+ * were never meant to form a "try something more general" chain:
186
+ *
187
+ * - A suffixed param (`/p/:id.json`, from `[id].json.ts`) and its bare
188
+ * sibling (`/p/:id`) — `compileMatcher`'s bare `:id` regex doesn't
189
+ * exclude dots, so both structurally match `/p/abc.json`. The suffix
190
+ * is the author's own explicit, dedicated handler for exactly that
191
+ * extension (`sortRoutes`'s own doc comment: "so `/p/:id.json` wins
192
+ * `/p/abc.json` before `/p/:id` can swallow it") — nothing ranked
193
+ * below it, bare OR rest, is a MORE correct answer than what was
194
+ * written on purpose for this exact case. (A rest/catch-all can't
195
+ * even carry a suffix of its own — `filePathToRoute` rejects
196
+ * `[...name].ext` outright — so it's exactly as wrong a fallback here
197
+ * as bare is: confirmed against a real app, examples/live-poll's
198
+ * `/p/:id.json` data route + `/p/:id` HTML page pair, where a 404
199
+ * from the JSON route cascading into the page handed back HTML for a
200
+ * request that explicitly asked for JSON.) So a winning suffixed
201
+ * match is unconditionally terminal — no cascading at all, into
202
+ * anything, once it's the one that matched.
203
+ * - Anything bare/static vs. a rest/catch-all IS a legitimate fallback
204
+ * chain (`routes/media/[...path].ts`, this feature's original
205
+ * motivating case) — a catch-all stays cascade-eligible unless the
206
+ * winning match was itself suffixed.
207
+ */
208
+ function cascadeCandidates<T extends PathMatch>(all: T[]): T[] {
209
+ if (all.length === 0) return all
210
+ const winner = all[0]!
211
+ if (isSuffixedParamPattern(winner.route.urlPath)) return [winner]
212
+ return [winner, ...all.slice(1).filter((m) => isRestPattern(m.route.urlPath))]
134
213
  }
135
214
 
136
215
  /** Parse a route key like "GET /p/abc-123" into method + literal path. */
@@ -179,10 +258,13 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
179
258
  })
180
259
 
181
260
  // 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.
261
+ // middleware) that follows, off `stator(c)`. Runs first. Sessions are LAZY
262
+ // (the cacheable-read-path layer 1): an arriving cookie RESUMES its session
263
+ // (claims eager-loaded, no cookie write), but first contact establishes
264
+ // nothing — creation happens only at a dispatch, a session-machine read, an
265
+ // SSE connect, or an explicit session op. Anonymous reads therefore carry
266
+ // no Set-Cookie and stay CDN-cacheable. Dirty claims persist at the end,
267
+ // to the current (possibly rotated or just-established) sid.
186
268
  app.use('*', async (c, next) => {
187
269
  c.set('stator', {
188
270
  origin: config.origin,
@@ -194,14 +276,15 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
194
276
  c.set('statorStore', config.store)
195
277
  // Stash the signing key so the cookie jar's signed methods can reach it.
196
278
  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
279
+ sessionUse(c)
280
+ const resumed = resumeSession(c)
281
+ if (resumed) {
282
+ resumed.claims = (await config.store.persistence.get(resumed.sid, CLAIMS_KEY)) ?? undefined
201
283
  }
202
284
  await next()
203
- // Same session object (rotation mutates `.sid` in place), so persist to the
204
- // current — possibly rotated — id.
285
+ // Re-read: the session may have been established mid-request. Rotation
286
+ // mutates `.sid` in place, so this persists to the current id.
287
+ const session = getSessionState(c)
205
288
  if (session?.claimsDirty) {
206
289
  await config.store.persistence.set(session.sid, CLAIMS_KEY, session.claims, {
207
290
  ttlSeconds: config.store.sessionTtlSeconds,
@@ -260,7 +343,9 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
260
343
  const payload = await buildInspectPayload({
261
344
  store: config.store,
262
345
  routes: config.routes,
263
- sessionId: getSessionState(c)?.sid,
346
+ // Inspecting IS session use — the route shows the caller's own
347
+ // session, so establish one for a first-contact dev visit.
348
+ sessionId: getOrCreateSessionId(c).sessionId,
264
349
  buildId: config.buildId,
265
350
  })
266
351
  c.header('Cache-Control', 'no-cache')
@@ -423,7 +508,7 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
423
508
  // otherwise never reach this page.
424
509
  const sync = initialSyncPatches(renderState, runtime)
425
510
  if (sync.length > 0) {
426
- await conn.send(JSON.stringify({ patches: sync }))
511
+ enqueue(conn, JSON.stringify({ patches: sync }))
427
512
  }
428
513
 
429
514
  // Heartbeat every 25s. This is a real DATA message, not a comment, on
@@ -433,9 +518,11 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
433
518
  // ever arrives, no error fires) unless liveness is OBSERVABLE. The
434
519
  // runtime tracks last-message time and reconnects when pings stop.
435
520
  const keepAlive = setInterval(() => {
436
- void conn.send('{"ping":true}').catch(() => {
437
- // Stream closed; the abort handler cleans up.
438
- })
521
+ // Through the same queue fan-out uses, so it is bounded like any other
522
+ // write: a half-open socket never settles its writes, and the heartbeat
523
+ // is what reaps it when the app is otherwise idle and no dispatch would
524
+ // ever notice.
525
+ enqueue(conn, '{"ping":true}')
439
526
  }, config.ssePingMs ?? 25_000)
440
527
 
441
528
  try {
@@ -547,7 +634,9 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
547
634
  // `touched`) — so it isn't re-created and re-fired next request.
548
635
  await runtime.persistTouched(new Set([...touched, ...runtime.entryFiredMachines()]))
549
636
 
550
- await fanOut(touched, {
637
+ // Queues patches on every affected channel and returns; no socket is
638
+ // awaited under the lock.
639
+ const { originatorQueued } = await fanOut(touched, {
551
640
  sessionId,
552
641
  originClientId: c.req.header('X-Stator-Client'),
553
642
  })
@@ -557,7 +646,15 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
557
646
  // normal event path in server/effects.ts.
558
647
  scheduleSessionEffects(runtime, config.store, sessionId)
559
648
 
560
- const envelope = { patches, directives: [], committed: touched.size > 0 }
649
+ // This page's patches normally ride the response. When its live
650
+ // channel already had a backlog, fan-out queued them behind it instead
651
+ // (two channels to one page must not reorder a positional list op), so
652
+ // the response carries none.
653
+ const envelope = {
654
+ patches: originatorQueued ? [] : patches,
655
+ directives: [],
656
+ committed: touched.size > 0,
657
+ }
561
658
  if (body.eventId) record(sessionId, body.eventId, JSON.stringify(envelope))
562
659
  return c.json(envelope)
563
660
  } finally {
@@ -579,24 +676,61 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
579
676
  })
580
677
  }
581
678
 
679
+ // 404 cascades by default: a matched GET route that 404s doesn't
680
+ // permanently claim the path — a lower-priority `[...name]`/`404`
681
+ // catch-all (if one exists) gets a turn, same priority order `matchers`
682
+ // is already sorted in. Only once every candidate 404s does the LAST
683
+ // (lowest-priority) one's response actually go out — so a route's own
684
+ // not-found body/headers/status still reach the client, this just stops
685
+ // the FIRST match from being final when a catch-all could actually
686
+ // serve it. `cascadeCandidates` (not the full `matchAllPaths` list) is
687
+ // what narrows "the next matching route" to specifically mean a
688
+ // catch-all — see its own doc comment for why that boundary matters.
582
689
  app.get('*', async (c, next) => {
583
- const matched = matchPath(matchers, c.req.path)
584
- if (!matched?.route.GET) return next()
585
- const getRoute = matched.route.GET
586
- // Brand decides the GET plane: data routes never touch the HTML path
587
- // (no client-runtime injection, no live meta — a raw Response out).
588
- if (isStatorQueryRoute(getRoute)) {
589
- return runQueryRoute(c, matched.route, getRoute, config.store, matched.params)
590
- }
591
- return handleGet(
592
- c,
593
- matched.route,
594
- getRoute,
595
- matched.params,
596
- config.store,
597
- config.headExtras,
598
- config.buildId,
690
+ const candidates = cascadeCandidates(
691
+ matchAllPaths(matchers, c.req.path).filter(
692
+ (
693
+ m,
694
+ ): m is PathMatch & {
695
+ route: DiscoveredRoute & { GET: NonNullable<DiscoveredRoute['GET']> }
696
+ } => !!m.route.GET,
697
+ ),
599
698
  )
699
+ if (candidates.length === 0) return next()
700
+
701
+ let lastNotFound: (() => Response) | undefined
702
+ for (const matched of candidates) {
703
+ const getRoute = matched.route.GET
704
+ // Brand decides the GET plane: data routes never touch the HTML path
705
+ // (no client-runtime injection, no live meta — a raw Response out).
706
+ if (isStatorQueryRoute(getRoute)) {
707
+ const response = await runQueryRoute(
708
+ c,
709
+ matched.route,
710
+ getRoute,
711
+ config.store,
712
+ matched.params,
713
+ config.caching,
714
+ )
715
+ if (response.status !== 404) return response
716
+ lastNotFound = () => response
717
+ continue
718
+ }
719
+
720
+ const candidate = await renderGetCandidate(
721
+ c,
722
+ matched.route,
723
+ getRoute,
724
+ matched.params,
725
+ config.store,
726
+ config.headExtras,
727
+ config.buildId,
728
+ config.caching,
729
+ )
730
+ if (candidate.status !== 404) return candidate.commit()
731
+ lastNotFound = candidate.commit
732
+ }
733
+ return lastNotFound ? lastNotFound() : next()
600
734
  })
601
735
 
602
736
  return app
@@ -660,7 +794,24 @@ function injectIntoDocument(html: string, parts: { head?: string; bodyEnd?: stri
660
794
  return out
661
795
  }
662
796
 
663
- async function handleGet(
797
+ /** A rendered GET candidate, not yet committed to the shared Hono `Context`.
798
+ * Cascading (see `matchAllPaths`/the GET dispatch loop) speculatively tries
799
+ * several routes for one path — `applyRenderedEffects` writes headers,
800
+ * cookies, and status directly onto `c`, cumulatively, so calling it for a
801
+ * candidate that turns out to 404 would leak into whatever candidate wins
802
+ * next (worse: `applyRenderedEffects` only calls `c.status(...)` when the
803
+ * status isn't 200, so a losing 404 candidate followed by a winning 200 one
804
+ * would ship 200 content under a stale 404 status — confirmed the hard way
805
+ * designing this). Splitting render (safe to call speculatively, side-effect
806
+ * free on `c`) from commit (writes to `c`, only ever called once, for
807
+ * whichever candidate the dispatch loop actually decided to serve) is what
808
+ * makes cascading safe. */
809
+ interface GetCandidate {
810
+ status: number
811
+ commit: () => Response
812
+ }
813
+
814
+ async function renderGetCandidate(
664
815
  c: Context,
665
816
  discovered: DiscoveredRoute,
666
817
  route: RouteDefinition,
@@ -668,64 +819,100 @@ async function handleGet(
668
819
  store: MachineStore,
669
820
  headExtras?: (filePath: string) => string | Promise<string>,
670
821
  buildId?: string,
671
- ): Promise<Response> {
672
- {
673
- const { sessionId } = getOrCreateSessionId(c)
674
- const literalPath = c.req.path
675
- const routeKey = `GET ${literalPath}`
676
- const request = { ...buildRouteRequest(c, discovered.paramNames), params }
677
-
678
- const runtime = new SessionRuntime(sessionId, store)
679
- try {
680
- await runtime.loadGraph(route.reads)
681
- const result = await renderRoute(route, routeKey, sessionId, runtime, request)
682
- let html = result.html
683
-
684
- const headHtml: string[] = []
685
- if (headExtras) {
686
- const extra = await headExtras(discovered.filePath)
687
- if (extra) headHtml.push(extra)
688
- }
689
- if (route.live) {
690
- headHtml.push('<meta name="stator-live" content="true">')
691
- // The build this page was rendered against — the client echoes it on
692
- // /__sse connect so the server can reload a page from a stale build.
693
- // `buildId` is a framework-generated UUID (no HTML-special chars).
694
- if (buildId) {
695
- headHtml.push(`<meta name="stator-build" content="${buildId}">`)
696
- }
822
+ caching?: { sMaxAge: number; staleWhileRevalidate: number },
823
+ ): Promise<GetCandidate> {
824
+ // Lazy layer 1: only a session-machine read forces establishment. An
825
+ // app-only (or read-free) page renders against the resumed sid when a
826
+ // cookie arrived, or an inert placeholder — loadGraph loads session
827
+ // machines only, so the placeholder never reaches the store.
828
+ const needsSession = route.reads.some((def) => def.lifecycle === 'session')
829
+ const sessionId = needsSession
830
+ ? getOrCreateSessionId(c).sessionId
831
+ : (peekSessionId(c) ?? ANONYMOUS_SID)
832
+ const literalPath = c.req.path
833
+ const routeKey = `GET ${literalPath}`
834
+ const request = { ...buildRouteRequest(c, discovered.paramNames), params }
835
+
836
+ const runtime = new SessionRuntime(sessionId, store)
837
+ try {
838
+ await runtime.loadGraph(route.reads)
839
+ const result = await renderRoute(route, routeKey, sessionId, runtime, request)
840
+ let html = result.html
841
+
842
+ const headHtml: string[] = []
843
+ if (headExtras) {
844
+ const extra = await headExtras(discovered.filePath)
845
+ if (extra) headHtml.push(extra)
846
+ }
847
+ if (route.live) {
848
+ headHtml.push('<meta name="stator-live" content="true">')
849
+ // The build this page was rendered against — the client echoes it on
850
+ // /__sse connect so the server can reload a page from a stale build.
851
+ // `buildId` is a framework-generated UUID (no HTML-special chars).
852
+ if (buildId) {
853
+ headHtml.push(`<meta name="stator-build" content="${buildId}">`)
697
854
  }
855
+ }
698
856
 
699
- // Auto-inject the client runtime (delegated events + patch application).
700
- // Apps never hand-include it — a forgotten <script> is a silently dead
701
- // page (events fire nothing, no patches apply). Idempotent: skipped if the
702
- // document already references it, so a layout that still carries the tag
703
- // (or two passes sharing a doc) never loads it twice.
704
- const bodyHtml: string[] = []
705
- if (!html.includes('/static/client.js')) {
706
- bodyHtml.push('<script src="/static/client.js"></script>')
707
- }
857
+ // Auto-inject the client runtime (delegated events + patch application).
858
+ // Apps never hand-include it — a forgotten <script> is a silently dead
859
+ // page (events fire nothing, no patches apply). Idempotent: skipped if the
860
+ // document already references it, so a layout that still carries the tag
861
+ // (or two passes sharing a doc) never loads it twice.
862
+ const bodyHtml: string[] = []
863
+ if (!html.includes('/static/client.js')) {
864
+ bodyHtml.push('<script src="/static/client.js"></script>')
865
+ }
708
866
 
709
- html = injectIntoDocument(html, {
710
- head: headHtml.join(''),
711
- bodyEnd: bodyHtml.join(''),
712
- })
713
- applyRenderedEffects(c, result.response)
714
-
715
- // A fresh machine that fired its initial entry effect on load must be
716
- // persisted (so the next request hydrates instead of re-firing) and its
717
- // effect scheduled off-lock, after the response. The common GET (no entry
718
- // effect) skips both and stays a pure read.
719
- const entryFired = runtime.entryFiredMachines()
720
- if (entryFired.size > 0) {
721
- await runtime.persistTouched(entryFired)
722
- scheduleSessionEffects(runtime, store, sessionId)
723
- }
867
+ html = injectIntoDocument(html, {
868
+ head: headHtml.join(''),
869
+ bodyEnd: bodyHtml.join(''),
870
+ })
871
+
872
+ // Fired regardless of whether this candidate ends up winning: an entry
873
+ // effect firing is a fact about the MACHINE having been loaded for the
874
+ // first time (shared, cross-cutting state), not about which route's
875
+ // response the caller ultimately serves — a discarded losing candidate
876
+ // still genuinely loaded whatever it read.
877
+ const entryFired = runtime.entryFiredMachines()
878
+ if (entryFired.size > 0) {
879
+ await runtime.persistTouched(entryFired)
880
+ scheduleSessionEffects(runtime, store, sessionId)
881
+ }
724
882
 
725
- return c.html(html)
726
- } finally {
727
- runtime.dispose()
883
+ // Layer 3 — derived Cache-Control: the page is PROVABLY anonymous-
884
+ // identical (no session reads declared, no session use or claims read
885
+ // during handling) and the route didn't hand-set the header. The
886
+ // framework says what it can prove; hand-set always wins. Computed here
887
+ // but only ever WRITTEN from `commit`, below — this render may be a
888
+ // losing, discarded candidate under cascading, and only the winner's
889
+ // headers may reach `c` (the same reason `applyRenderedEffects` is
890
+ // deferred to `commit` rather than called here).
891
+ const use = sessionUse(c)
892
+ const deriveCacheControl =
893
+ !!caching &&
894
+ !needsSession &&
895
+ !use.used &&
896
+ !use.claimsRead &&
897
+ result.response.status < 300 &&
898
+ result.response.cookies.length === 0 &&
899
+ !result.response.headers.has('cache-control')
900
+
901
+ return {
902
+ status: result.response.status,
903
+ commit: () => {
904
+ applyRenderedEffects(c, result.response)
905
+ if (deriveCacheControl) {
906
+ c.header(
907
+ 'Cache-Control',
908
+ `public, max-age=0, s-maxage=${caching!.sMaxAge}, stale-while-revalidate=${caching!.staleWhileRevalidate}`,
909
+ )
910
+ }
911
+ return c.html(html)
912
+ },
728
913
  }
914
+ } finally {
915
+ runtime.dispose()
729
916
  }
730
917
  }
731
918
 
@@ -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
- const { sessionId } = getOrCreateSessionId(c)
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
- return synthesizeDataResponse(c, result)
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 {