@statorjs/stator 2.10.0-next.1 → 2.11.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -2
- package/src/cli/commands/dev.ts +3 -0
- package/src/cli/commands/start.ts +3 -0
- package/src/compiler/lower.ts +41 -0
- package/src/compiler/virtual-code.ts +1 -1
- package/src/components/images.ts +260 -0
- package/src/components/index.ts +10 -0
- package/src/config.ts +64 -0
- package/src/index.ts +7 -0
- package/src/server/api-route.ts +5 -2
- package/src/server/config-compat.ts +4 -0
- package/src/server/context.ts +13 -4
- package/src/server/create-app.ts +32 -0
- package/src/server/dev-native.ts +3 -0
- package/src/server/dev-vite.ts +3 -0
- package/src/server/dev.ts +14 -0
- package/src/server/http.ts +299 -91
- package/src/server/images.ts +426 -0
- package/src/server/index.ts +7 -0
- package/src/server/query-route.ts +68 -3
- package/src/server/render-context.ts +9 -0
- package/src/server/render.ts +3 -0
- package/src/server/route-discovery.ts +36 -5
- package/src/server/routing.ts +8 -0
- package/src/server/session.ts +58 -11
- package/src/template/html-attributes.ts +10 -0
package/src/server/dev-vite.ts
CHANGED
|
@@ -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
|
package/src/server/http.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
118
|
-
*
|
|
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
|
|
121
|
-
|
|
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
|
-
|
|
158
|
+
matches.push({ route: m.route, params })
|
|
132
159
|
}
|
|
133
|
-
return
|
|
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.
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
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
|
-
|
|
198
|
-
const
|
|
199
|
-
if (
|
|
200
|
-
|
|
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
|
-
//
|
|
204
|
-
//
|
|
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
|
-
|
|
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
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
|