@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.
- package/package.json +6 -2
- package/src/build/copy-set.ts +60 -19
- package/src/cli/commands/dev.ts +2 -0
- package/src/cli/commands/start.ts +2 -0
- package/src/compiler/lower.ts +41 -0
- package/src/compiler/virtual-code.ts +1 -1
- package/src/config.ts +16 -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 +13 -0
- package/src/server/http.ts +284 -97
- package/src/server/query-route.ts +68 -3
- package/src/server/route-discovery.ts +36 -5
- package/src/server/routing.ts +8 -0
- package/src/server/session.ts +58 -11
- package/src/server/sse.ts +195 -38
- package/src/template/html-attributes.ts +10 -0
package/src/server/http.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
118
|
-
*
|
|
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
|
|
121
|
-
|
|
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
|
-
|
|
154
|
+
matches.push({ route: m.route, params })
|
|
132
155
|
}
|
|
133
|
-
return
|
|
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.
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
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
|
-
|
|
198
|
-
const
|
|
199
|
-
if (
|
|
200
|
-
|
|
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
|
-
//
|
|
204
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
437
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
-
|
|
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 {
|