@statorjs/stator 2.10.0-next.1 → 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/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 +265 -90
- 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/template/html-attributes.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "2.10.0
|
|
3
|
+
"version": "2.10.0",
|
|
4
4
|
"description": "Server-canonical web framework: business logic in composable state machines, UI as a thin renderer binding machine outputs to DOM positions. Ships TypeScript source, loaded natively by its own toolchain — no build step.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
"stator": "./src/cli/stator.js"
|
|
28
28
|
},
|
|
29
29
|
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./src/index.ts",
|
|
32
|
+
"default": "./src/index.ts"
|
|
33
|
+
},
|
|
30
34
|
"./server": {
|
|
31
35
|
"types": "./src/server/index.ts",
|
|
32
36
|
"default": "./src/server/index.ts"
|
|
@@ -95,7 +99,7 @@
|
|
|
95
99
|
"pino-pretty": "^13.1.3",
|
|
96
100
|
"vite": "^6.0.0",
|
|
97
101
|
"vitest": "^2.1.0",
|
|
98
|
-
"@statorjs/stator": "2.10.0
|
|
102
|
+
"@statorjs/stator": "2.10.0"
|
|
99
103
|
},
|
|
100
104
|
"scripts": {
|
|
101
105
|
"typecheck": "tsc --noEmit",
|
package/src/cli/commands/dev.ts
CHANGED
|
@@ -23,6 +23,8 @@ export async function run(ctx: CliContext): Promise<void> {
|
|
|
23
23
|
trustedOrigins: config.trustedOrigins,
|
|
24
24
|
origin: config.origin,
|
|
25
25
|
cors: config.cors,
|
|
26
|
+
secret: config.secret,
|
|
27
|
+
logging: config.logging,
|
|
26
28
|
})
|
|
27
29
|
await app.listen(resolvePort(ctx.portFlag, config.port))
|
|
28
30
|
}
|
package/src/compiler/lower.ts
CHANGED
|
@@ -277,6 +277,47 @@ export function lowerTemplate(template: string, opts: LowerOptions = {}): string
|
|
|
277
277
|
}
|
|
278
278
|
checkNoReadInDefer(fragment, false)
|
|
279
279
|
|
|
280
|
+
// Build-time gate for RCDATA elements: inside <textarea> and <title> the
|
|
281
|
+
// parser treats children as RAW TEXT — a live-slot <span> (text-position
|
|
282
|
+
// read()) or a region comment marker (each/when/match/defer) is rendered as
|
|
283
|
+
// literal markup instead of becoming an element. Found in the wild: a
|
|
284
|
+
// textarea pre-filled via read() displayed `<span data-slot="…"></span>` to
|
|
285
|
+
// the user. Attributes on the element itself stay legal (attribute patches
|
|
286
|
+
// need no wrapper) — the gate covers children only.
|
|
287
|
+
const RCDATA_BINDINGS = new Set(['read', 'each', 'when', 'match', 'defer'])
|
|
288
|
+
const checkNoBindingInRcdata = (node: ts.Node, rcdataTag: string | null): void => {
|
|
289
|
+
if (
|
|
290
|
+
rcdataTag &&
|
|
291
|
+
ts.isCallExpression(node) &&
|
|
292
|
+
ts.isIdentifier(node.expression) &&
|
|
293
|
+
RCDATA_BINDINGS.has(node.expression.text)
|
|
294
|
+
) {
|
|
295
|
+
const callee = node.expression.text
|
|
296
|
+
throw new CompileError(
|
|
297
|
+
`stator: ${callee}() cannot appear inside <${rcdataTag}> — its content is raw text ` +
|
|
298
|
+
`(RCDATA), so the ${callee === 'read' ? 'live-slot wrapper' : 'region markers'} would ` +
|
|
299
|
+
`render as literal markup. Interpolate a static value instead (a selector property ` +
|
|
300
|
+
`like {machine.someSelector}, or a frontmatter constant); if the value must be live, ` +
|
|
301
|
+
`bind an attribute or use an island.`,
|
|
302
|
+
loc(node),
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
if (ts.isJsxElement(node)) {
|
|
306
|
+
const opening = node.openingElement
|
|
307
|
+
const tagName = ts.isIdentifier(opening.tagName) ? opening.tagName.text : null
|
|
308
|
+
const isRcdata = tagName === 'textarea' || tagName === 'title'
|
|
309
|
+
// Attributes keep the OUTER context; children enter the RCDATA scope.
|
|
310
|
+
checkNoBindingInRcdata(opening, rcdataTag)
|
|
311
|
+
for (const child of node.children) {
|
|
312
|
+
checkNoBindingInRcdata(child, isRcdata ? tagName : rcdataTag)
|
|
313
|
+
}
|
|
314
|
+
checkNoBindingInRcdata(node.closingElement, rcdataTag)
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
ts.forEachChild(node, (child) => checkNoBindingInRcdata(child, rcdataTag))
|
|
318
|
+
}
|
|
319
|
+
checkNoBindingInRcdata(fragment, null)
|
|
320
|
+
|
|
280
321
|
// Build-time gate for item-read placement: a `read(<item>, …)` binding is
|
|
281
322
|
// OWNED by its each() row — the row render supplies the item and collects the
|
|
282
323
|
// binding, and the list's recompute is what re-diffs it. Three positions break
|
|
@@ -102,7 +102,7 @@ const STATOR_AMBIENT = `declare const Stator: {
|
|
|
102
102
|
reads<const T extends readonly __SMachineDef[]>(defs: T): { -readonly [K in keyof T]: __SInstanceOf<T[K]> };
|
|
103
103
|
forwarded(name: \`on:\${string}\`): (() => unknown) | undefined;
|
|
104
104
|
request: any;
|
|
105
|
-
response: { status: number; headers:
|
|
105
|
+
response: { status: number; headers: Headers; cookies: any };
|
|
106
106
|
};
|
|
107
107
|
declare global {
|
|
108
108
|
namespace JSX {
|
package/src/config.ts
CHANGED
|
@@ -97,6 +97,22 @@ export interface StatorConfig {
|
|
|
97
97
|
* Opt in per route/app with the `cors()` middleware; `origins` defaults to
|
|
98
98
|
* `trustedOrigins`. */
|
|
99
99
|
cors?: { origins?: string[]; credentials?: boolean }
|
|
100
|
+
/** Derived Cache-Control (the cacheable read path, layer 3): on GET
|
|
101
|
+
* responses the framework can PROVE anonymous-identical — every declared
|
|
102
|
+
* read app-lifecycle, no session use or claims read while handling — it
|
|
103
|
+
* emits `Cache-Control: public, s-maxage, stale-while-revalidate` so a
|
|
104
|
+
* CDN can absorb anonymous traffic. Hand-set headers always win; pages
|
|
105
|
+
* that read session machines are never marked. On by default under
|
|
106
|
+
* `stator start` (60s / 300s); set to `false` to disable; dev never
|
|
107
|
+
* emits (editing must always re-render). */
|
|
108
|
+
caching?:
|
|
109
|
+
| {
|
|
110
|
+
/** Shared-cache lifetime in seconds. Default: 60. */
|
|
111
|
+
sMaxAge?: number
|
|
112
|
+
/** Serve-stale-while-refreshing window in seconds. Default: 300. */
|
|
113
|
+
staleWhileRevalidate?: number
|
|
114
|
+
}
|
|
115
|
+
| false
|
|
100
116
|
/** Logging policy. */
|
|
101
117
|
logging?: {
|
|
102
118
|
/** Minimum level to emit. Default: `warn` in production, `info` in dev.
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Root export = the config-authoring surface only. `stator.config.ts` is the
|
|
2
|
+
// first file anyone writes against the bare package name, and it must never
|
|
3
|
+
// drag server-only dependencies (sharp, ioredis) into a config load — anything
|
|
4
|
+
// beyond config keeps its own subpath.
|
|
5
|
+
|
|
6
|
+
export type { LogLevel, StatorConfig } from './config.ts'
|
|
7
|
+
export { defineConfig } from './config.ts'
|
package/src/server/api-route.ts
CHANGED
|
@@ -15,7 +15,7 @@ import type {
|
|
|
15
15
|
Directive,
|
|
16
16
|
RouteRequest,
|
|
17
17
|
} from './routing.ts'
|
|
18
|
-
import { getOrCreateSessionId, getSessionState, rotateSessionNow } from './session.ts'
|
|
18
|
+
import { getOrCreateSessionId, getSessionState, rotateSessionNow, sessionUse } from './session.ts'
|
|
19
19
|
import { withSessionLock } from './session-lock.ts'
|
|
20
20
|
import { SessionRuntime } from './session-runtime.ts'
|
|
21
21
|
import { fanOut } from './sse.ts'
|
|
@@ -76,7 +76,10 @@ export async function runApiRoute(
|
|
|
76
76
|
clearSession: () => {
|
|
77
77
|
rotation = { clear: true }
|
|
78
78
|
},
|
|
79
|
-
claims: <T = unknown>() =>
|
|
79
|
+
claims: <T = unknown>() => {
|
|
80
|
+
sessionUse(c).claimsRead = true
|
|
81
|
+
return getSessionState(c)?.claims as T | undefined
|
|
82
|
+
},
|
|
80
83
|
setClaims: (claims) => {
|
|
81
84
|
const s = getSessionState(c)
|
|
82
85
|
if (s) {
|
|
@@ -34,6 +34,7 @@ interface NestedReads {
|
|
|
34
34
|
host?: string
|
|
35
35
|
secret?: string
|
|
36
36
|
cors?: { origins?: readonly string[]; credentials?: boolean }
|
|
37
|
+
caching?: { sMaxAge?: number; staleWhileRevalidate?: number } | false
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
/** The flat internal values `createApp`/`createDevApp` actually consume. */
|
|
@@ -51,6 +52,8 @@ export interface ResolvedAppConfig {
|
|
|
51
52
|
/** Signed-cookie signing key. `config.secret` ?? `STATOR_SECRET`. */
|
|
52
53
|
secret?: string
|
|
53
54
|
cors?: { origins: readonly string[]; credentials: boolean }
|
|
55
|
+
/** Derived Cache-Control knobs; `false` disables emission entirely. */
|
|
56
|
+
caching?: { sMaxAge?: number; staleWhileRevalidate?: number } | false
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
/**
|
|
@@ -93,5 +96,6 @@ export function resolveAppConfig(config: NestedReads & DeprecatedFlatConfig): Re
|
|
|
93
96
|
credentials: config.cors.credentials ?? false,
|
|
94
97
|
}
|
|
95
98
|
: undefined,
|
|
99
|
+
caching: config.caching,
|
|
96
100
|
}
|
|
97
101
|
}
|
package/src/server/context.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Context } from 'hono'
|
|
2
2
|
import { type CookieJar, cookieJar } from './cookies.ts'
|
|
3
|
-
import { getSessionState, rotateSessionNow } from './session.ts'
|
|
3
|
+
import { getOrCreateSessionId, getSessionState, rotateSessionNow, sessionUse } from './session.ts'
|
|
4
4
|
import type { Store } from './store.ts'
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -64,25 +64,34 @@ const DEFAULT: StatorConfigData = { trustedOrigins: [], sameSite: 'Lax' }
|
|
|
64
64
|
*/
|
|
65
65
|
export function stator(c: Context): StatorContext {
|
|
66
66
|
const cfg = c.get('stator') ?? DEFAULT
|
|
67
|
-
const session = getSessionState(c)
|
|
68
67
|
return {
|
|
69
68
|
origin: cfg.origin,
|
|
70
69
|
trustedOrigins: cfg.trustedOrigins,
|
|
71
70
|
sameSite: cfg.sameSite,
|
|
72
71
|
cors: cfg.cors,
|
|
72
|
+
// Live reads (not a captured snapshot): the session may be established
|
|
73
|
+
// mid-request, after this context object was built.
|
|
73
74
|
get sid() {
|
|
74
|
-
return
|
|
75
|
+
return getSessionState(c)?.sid ?? ''
|
|
75
76
|
},
|
|
76
77
|
claims<T = unknown>(): T | undefined {
|
|
77
|
-
|
|
78
|
+
// A claims READ is a peek — it never establishes (an anonymous /admin
|
|
79
|
+
// probe redirects without minting state) — but it does make the
|
|
80
|
+
// response claims-dependent, so layer 3 marks it unprovable.
|
|
81
|
+
sessionUse(c).claimsRead = true
|
|
82
|
+
return getSessionState(c)?.claims as T | undefined
|
|
78
83
|
},
|
|
79
84
|
setClaims(claims: unknown): void {
|
|
85
|
+
// Writing claims is an explicit session op — an establishment trigger.
|
|
86
|
+
getOrCreateSessionId(c)
|
|
87
|
+
const session = getSessionState(c)
|
|
80
88
|
if (session) {
|
|
81
89
|
session.claims = claims
|
|
82
90
|
session.claimsDirty = true
|
|
83
91
|
}
|
|
84
92
|
},
|
|
85
93
|
clearClaims(): void {
|
|
94
|
+
const session = getSessionState(c)
|
|
86
95
|
if (session) {
|
|
87
96
|
session.claims = undefined
|
|
88
97
|
session.claimsDirty = true
|
package/src/server/create-app.ts
CHANGED
|
@@ -89,6 +89,9 @@ export interface CreateAppConfig extends DeprecatedFlatConfig {
|
|
|
89
89
|
* keyed by file relative to `machinesDir`. Omitted ⇒ hashed live at boot;
|
|
90
90
|
* a discovered machine missing from a supplied map is a boot error. */
|
|
91
91
|
machineHashes?: Readonly<Record<string, string>>
|
|
92
|
+
/** Derived Cache-Control (cacheable read path, layer 3). Defaults on with
|
|
93
|
+
* modest values (60s / 300s); `false` disables emission. */
|
|
94
|
+
caching?: { sMaxAge?: number; staleWhileRevalidate?: number } | false
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
export interface StatorApp {
|
|
@@ -163,6 +166,16 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
|
|
|
163
166
|
buildId: config.buildId,
|
|
164
167
|
cors: resolved.cors,
|
|
165
168
|
middleware,
|
|
169
|
+
// Layer-3 derived Cache-Control: on by default with modest values; the
|
|
170
|
+
// whole block set to `false` disables. (createDevApp never passes it —
|
|
171
|
+
// dev pages must always re-render while editing.)
|
|
172
|
+
caching:
|
|
173
|
+
resolved.caching === false
|
|
174
|
+
? undefined
|
|
175
|
+
: {
|
|
176
|
+
sMaxAge: resolved.caching?.sMaxAge ?? 60,
|
|
177
|
+
staleWhileRevalidate: resolved.caching?.staleWhileRevalidate ?? 300,
|
|
178
|
+
},
|
|
166
179
|
})
|
|
167
180
|
|
|
168
181
|
return {
|
package/src/server/http.ts
CHANGED
|
@@ -19,7 +19,14 @@ 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
32
|
import { enqueue, fanOut, registerConnection, unregisterConnection } from './sse.ts'
|
|
@@ -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')
|
|
@@ -591,24 +676,61 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
|
|
|
591
676
|
})
|
|
592
677
|
}
|
|
593
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.
|
|
594
689
|
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,
|
|
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
|
+
),
|
|
611
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()
|
|
612
734
|
})
|
|
613
735
|
|
|
614
736
|
return app
|
|
@@ -672,7 +794,24 @@ function injectIntoDocument(html: string, parts: { head?: string; bodyEnd?: stri
|
|
|
672
794
|
return out
|
|
673
795
|
}
|
|
674
796
|
|
|
675
|
-
|
|
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(
|
|
676
815
|
c: Context,
|
|
677
816
|
discovered: DiscoveredRoute,
|
|
678
817
|
route: RouteDefinition,
|
|
@@ -680,64 +819,100 @@ async function handleGet(
|
|
|
680
819
|
store: MachineStore,
|
|
681
820
|
headExtras?: (filePath: string) => string | Promise<string>,
|
|
682
821
|
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
|
-
|
|
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}">`)
|
|
709
854
|
}
|
|
855
|
+
}
|
|
710
856
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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
|
+
}
|
|
720
866
|
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
+
}
|
|
736
882
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
+
},
|
|
740
913
|
}
|
|
914
|
+
} finally {
|
|
915
|
+
runtime.dispose()
|
|
741
916
|
}
|
|
742
917
|
}
|
|
743
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 {
|
|
@@ -35,6 +35,14 @@ export interface DiscoveredRoute {
|
|
|
35
35
|
PUT?: ApiRouteDefinition
|
|
36
36
|
PATCH?: ApiRouteDefinition
|
|
37
37
|
DELETE?: ApiRouteDefinition
|
|
38
|
+
/** True for a `404.stator`/`404.ts` file — sugar for a `[...name]`
|
|
39
|
+
* catch-all (same synthesized pattern, same matcher), except
|
|
40
|
+
* `sortRoutes` forces it to the absolute lowest priority: below even a
|
|
41
|
+
* real `[...name]` at the same depth. It's the last resort, only ever
|
|
42
|
+
* reached once nothing else — including a genuine catch-all a request
|
|
43
|
+
* matched first — could actually serve the request (a 404 cascades by
|
|
44
|
+
* default; see the GET dispatch loop in http.ts). */
|
|
45
|
+
isNotFoundFallback?: boolean
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
/**
|
|
@@ -47,6 +55,11 @@ export interface DiscoveredRoute {
|
|
|
47
55
|
* - `routes/[a]/[b].ts` → `/:a/:b`
|
|
48
56
|
* - `routes/foo/[...rest].ts` → `/foo/*rest` (catch-all: terminal, matches
|
|
49
57
|
* zero+ segments, param is the raw remainder incl. slashes)
|
|
58
|
+
* - `routes/foo/404.ts` → sugar for `routes/foo/[...___].ts` — a
|
|
59
|
+
* catch-all without needing to know the bracket-rest convention, EXCEPT
|
|
60
|
+
* `sortRoutes` also forces it to the absolute lowest match priority,
|
|
61
|
+
* below even a real `[...rest]` at the same depth — see
|
|
62
|
+
* `DiscoveredRoute.isNotFoundFallback`.
|
|
50
63
|
*
|
|
51
64
|
* Files may export any combination of `GET`/`POST`/`PUT`/`PATCH`/`DELETE`.
|
|
52
65
|
* GET is a `defineRoute` (page renderer) or a `defineApiRoute` declaring
|
|
@@ -123,11 +136,12 @@ export async function discoverRoutes(
|
|
|
123
136
|
continue
|
|
124
137
|
}
|
|
125
138
|
|
|
126
|
-
const { urlPath, paramNames } = filePathToRoute(absDir, filePath)
|
|
139
|
+
const { urlPath, paramNames, isNotFoundFallback } = filePathToRoute(absDir, filePath)
|
|
127
140
|
routes.push({
|
|
128
141
|
urlPath,
|
|
129
142
|
paramNames,
|
|
130
143
|
filePath,
|
|
144
|
+
...(isNotFoundFallback ? { isNotFoundFallback } : {}),
|
|
131
145
|
GET: get,
|
|
132
146
|
POST: post,
|
|
133
147
|
PUT: put,
|
|
@@ -169,6 +183,10 @@ function mergeByUrlPath(routes: DiscoveredRoute[]): DiscoveredRoute[] {
|
|
|
169
183
|
/**
|
|
170
184
|
* Sort routes most-specific-first (Astro's model). At match time the first
|
|
171
185
|
* matcher that matches wins, so order encodes priority:
|
|
186
|
+
* - a `404.stator`/`404.ts` file (`isNotFoundFallback`) ranks absolute
|
|
187
|
+
* last, below even a real `[...name]` at the same depth — it's the
|
|
188
|
+
* final resort, only reached once nothing else (a genuine catch-all
|
|
189
|
+
* included) could serve the request
|
|
172
190
|
* - routes without a rest/catch-all segment rank before those with one
|
|
173
191
|
* - per segment, left to right: static (0) > suffixed param `:id.json` (1)
|
|
174
192
|
* > bare param (2) > rest (3) — a suffixed param outranks a bare one so
|
|
@@ -186,6 +204,10 @@ export function sortRoutes(routes: DiscoveredRoute[]): DiscoveredRoute[] {
|
|
|
186
204
|
)
|
|
187
205
|
|
|
188
206
|
return [...routes].sort((a, b) => {
|
|
207
|
+
const aFallback = !!a.isNotFoundFallback
|
|
208
|
+
const bFallback = !!b.isNotFoundFallback
|
|
209
|
+
if (aFallback !== bFallback) return aFallback ? 1 : -1 // 404 fallback always last
|
|
210
|
+
|
|
189
211
|
const ka = kinds(a.urlPath)
|
|
190
212
|
const kb = kinds(b.urlPath)
|
|
191
213
|
const aRest = ka.includes(3)
|
|
@@ -230,7 +252,7 @@ async function walkRouteFiles(dir: string): Promise<string[]> {
|
|
|
230
252
|
export function filePathToRoute(
|
|
231
253
|
absDir: string,
|
|
232
254
|
filePath: string,
|
|
233
|
-
): { urlPath: string; paramNames: string[] } {
|
|
255
|
+
): { urlPath: string; paramNames: string[]; isNotFoundFallback: boolean } {
|
|
234
256
|
const ext = extname(filePath)
|
|
235
257
|
const rel = relative(absDir, filePath)
|
|
236
258
|
const dirSegments = dirname(rel)
|
|
@@ -240,9 +262,18 @@ export function filePathToRoute(
|
|
|
240
262
|
// sibling `<name>.stator.ts` files, which must map to the same URL as the
|
|
241
263
|
// `<name>.stator` source did in dev.
|
|
242
264
|
const fileBase = basename(rel, ext).replace(/\.stator$/, '')
|
|
243
|
-
|
|
265
|
+
// `404` is sugar for a synthesized `[...name]` at this exact position —
|
|
266
|
+
// reuses the rest-segment branch below verbatim, so it gets the same
|
|
267
|
+
// matcher/regex a real catch-all would. `sortRoutes` is what actually
|
|
268
|
+
// forces it below a real `[...name]` at the same depth; this alone would
|
|
269
|
+
// just be an ordinary (if oddly-named) catch-all.
|
|
270
|
+
const isNotFoundFallback = fileBase === '404'
|
|
271
|
+
const segments =
|
|
272
|
+
fileBase === 'index'
|
|
273
|
+
? dirSegments
|
|
274
|
+
: [...dirSegments, isNotFoundFallback ? '[...__stator_notfound__]' : fileBase]
|
|
244
275
|
|
|
245
|
-
if (segments.length === 0) return { urlPath: '/', paramNames: [] }
|
|
276
|
+
if (segments.length === 0) return { urlPath: '/', paramNames: [], isNotFoundFallback }
|
|
246
277
|
|
|
247
278
|
const paramNames: string[] = []
|
|
248
279
|
const urlSegments = segments.map((seg) => {
|
|
@@ -272,5 +303,5 @@ export function filePathToRoute(
|
|
|
272
303
|
return seg
|
|
273
304
|
})
|
|
274
305
|
|
|
275
|
-
return { urlPath: `/${urlSegments.join('/')}`, paramNames }
|
|
306
|
+
return { urlPath: `/${urlSegments.join('/')}`, paramNames, isNotFoundFallback }
|
|
276
307
|
}
|
package/src/server/routing.ts
CHANGED
|
@@ -198,6 +198,7 @@ export function defineApiRoute<TReads extends ReadonlyArray<AnyMachineDef>>(
|
|
|
198
198
|
__isStatorQueryRoute: true,
|
|
199
199
|
reads: config.reads ? [...config.reads] : [],
|
|
200
200
|
handler: config.handler,
|
|
201
|
+
revision: config.revision,
|
|
201
202
|
}
|
|
202
203
|
}
|
|
203
204
|
return {
|
|
@@ -248,6 +249,7 @@ export interface QueryRouteDefinition {
|
|
|
248
249
|
request: RouteRequest,
|
|
249
250
|
helpers: QueryRouteHelpers,
|
|
250
251
|
) => Promise<QueryRouteResult> | QueryRouteResult
|
|
252
|
+
revision?: () => string | number | Promise<string | number>
|
|
251
253
|
}
|
|
252
254
|
|
|
253
255
|
export interface DefineQueryRouteConfig<TReads extends ReadonlyArray<AnyMachineDef> = readonly []> {
|
|
@@ -259,6 +261,12 @@ export interface DefineQueryRouteConfig<TReads extends ReadonlyArray<AnyMachineD
|
|
|
259
261
|
request: RouteRequest,
|
|
260
262
|
helpers: QueryRouteHelpers<TReads>,
|
|
261
263
|
) => Promise<QueryRouteResult> | QueryRouteResult
|
|
264
|
+
/** The revision ledger: a CHEAP fingerprint of everything the body depends
|
|
265
|
+
* on (e.g. `() => String(maxUpdatedAt())` — one indexed SELECT). When
|
|
266
|
+
* present, the ETag derives from it and a matching `If-None-Match`
|
|
267
|
+
* answers 304 WITHOUT invoking the handler — a polling crawler costs the
|
|
268
|
+
* fingerprint, not the render. Must change whenever the body could. */
|
|
269
|
+
revision?: () => string | number | Promise<string | number>
|
|
262
270
|
}
|
|
263
271
|
|
|
264
272
|
export function isStatorQueryRoute(v: unknown): v is QueryRouteDefinition {
|
package/src/server/session.ts
CHANGED
|
@@ -23,9 +23,18 @@ export interface SessionState {
|
|
|
23
23
|
claimsDirty: boolean
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** Layer-3 provability tracking: did this request USE the session plane
|
|
27
|
+
* (establish/dispatch/session-read) or READ claims? Either makes the
|
|
28
|
+
* response unprovable as anonymous-identical, so no derived Cache-Control. */
|
|
29
|
+
export interface SessionUse {
|
|
30
|
+
used: boolean
|
|
31
|
+
claimsRead: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
26
34
|
declare module 'hono' {
|
|
27
35
|
interface ContextVariableMap {
|
|
28
36
|
statorSession: SessionState
|
|
37
|
+
statorSessionUse: SessionUse
|
|
29
38
|
}
|
|
30
39
|
}
|
|
31
40
|
|
|
@@ -34,6 +43,40 @@ export function getSessionState(c: Context): SessionState | undefined {
|
|
|
34
43
|
return c.get('statorSession')
|
|
35
44
|
}
|
|
36
45
|
|
|
46
|
+
/** The request's session-use flags (created on first access). */
|
|
47
|
+
export function sessionUse(c: Context): SessionUse {
|
|
48
|
+
let use = c.get('statorSessionUse')
|
|
49
|
+
if (!use) {
|
|
50
|
+
use = { used: false, claimsRead: false }
|
|
51
|
+
c.set('statorSessionUse', use)
|
|
52
|
+
}
|
|
53
|
+
return use
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The session id this request WOULD have — established state or the arriving
|
|
57
|
+
* cookie — without creating anything. Null for a first-contact visitor. */
|
|
58
|
+
export function peekSessionId(c: Context): string | null {
|
|
59
|
+
return getSessionState(c)?.sid ?? getCookie(c, SESSION_COOKIE) ?? null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Resume an EXISTING session from the arriving cookie (populate the shared
|
|
63
|
+
* state, no cookie write) — or return null for first contact. Resuming is
|
|
64
|
+
* not establishment: laziness governs CREATION only. */
|
|
65
|
+
export function resumeSession(c: Context): SessionState | null {
|
|
66
|
+
const established = getSessionState(c)
|
|
67
|
+
if (established) return established
|
|
68
|
+
const cookieSid = getCookie(c, SESSION_COOKIE)
|
|
69
|
+
if (cookieSid === undefined) return null
|
|
70
|
+
const state: SessionState = {
|
|
71
|
+
sid: cookieSid,
|
|
72
|
+
isNew: false,
|
|
73
|
+
claims: undefined,
|
|
74
|
+
claimsDirty: false,
|
|
75
|
+
}
|
|
76
|
+
c.set('statorSession', state)
|
|
77
|
+
return state
|
|
78
|
+
}
|
|
79
|
+
|
|
37
80
|
/** Secure cookie flag — set when running behind HTTPS. Enabled by
|
|
38
81
|
* NODE_ENV=production; can be overridden via STATOR_SECURE_COOKIE. */
|
|
39
82
|
function shouldUseSecureCookie(): boolean {
|
|
@@ -65,22 +108,26 @@ export function setSessionCookie(c: Context, sessionId: string): void {
|
|
|
65
108
|
})
|
|
66
109
|
}
|
|
67
110
|
|
|
111
|
+
/**
|
|
112
|
+
* ESTABLISH the session — the lazy-creation trigger (dispatches, session-
|
|
113
|
+
* machine reads, SSE connects, explicit session ops call this; anonymous
|
|
114
|
+
* page/data GETs never do). Idempotent per request: the first caller
|
|
115
|
+
* establishes, everyone after reads the same state. A first-contact visitor
|
|
116
|
+
* gets a fresh sid + Set-Cookie HERE and nowhere earlier — the whole
|
|
117
|
+
* cacheable-read-path layer 1.
|
|
118
|
+
*/
|
|
68
119
|
export function getOrCreateSessionId(c: Context): {
|
|
69
120
|
sessionId: string
|
|
70
121
|
isNew: boolean
|
|
71
122
|
} {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (established) return { sessionId: established.sid, isNew: established.isNew }
|
|
76
|
-
|
|
77
|
-
const cookieSid = getCookie(c, SESSION_COOKIE)
|
|
78
|
-
const sessionId = cookieSid ?? randomUUID()
|
|
79
|
-
const isNew = cookieSid === undefined
|
|
80
|
-
if (isNew) setSessionCookie(c, sessionId)
|
|
123
|
+
sessionUse(c).used = true
|
|
124
|
+
const resumed = resumeSession(c)
|
|
125
|
+
if (resumed) return { sessionId: resumed.sid, isNew: resumed.isNew }
|
|
81
126
|
|
|
82
|
-
|
|
83
|
-
|
|
127
|
+
const sessionId = randomUUID()
|
|
128
|
+
setSessionCookie(c, sessionId)
|
|
129
|
+
c.set('statorSession', { sid: sessionId, isNew: true, claims: undefined, claimsDirty: false })
|
|
130
|
+
return { sessionId, isNew: true }
|
|
84
131
|
}
|
|
85
132
|
|
|
86
133
|
/**
|
|
@@ -94,6 +94,11 @@ export interface ElementSpecificAttributes {
|
|
|
94
94
|
autofocus?: boolean
|
|
95
95
|
accept?: string
|
|
96
96
|
multiple?: boolean
|
|
97
|
+
/** Associates this element with a `<form>` elsewhere in the document by
|
|
98
|
+
* id — the standard way to submit an element that can't live inside
|
|
99
|
+
* the form's own DOM subtree (e.g. a per-row control alongside a
|
|
100
|
+
* page-wide bulk-action form). `button` already carries this. */
|
|
101
|
+
form?: string
|
|
97
102
|
}
|
|
98
103
|
label: { for?: string }
|
|
99
104
|
form: {
|
|
@@ -101,6 +106,11 @@ export interface ElementSpecificAttributes {
|
|
|
101
106
|
method?: 'get' | 'post' | 'GET' | 'POST' | 'dialog'
|
|
102
107
|
enctype?: 'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain'
|
|
103
108
|
novalidate?: boolean
|
|
109
|
+
/** Native inline event-handler attribute — a literal JS source string
|
|
110
|
+
* (`"return confirm(...)"`), distinct from the `on:submit={...}`
|
|
111
|
+
* Stator directive. Useful for a plain, no-hydration `confirm()`
|
|
112
|
+
* guard that doesn't otherwise need an island. */
|
|
113
|
+
onsubmit?: string
|
|
104
114
|
}
|
|
105
115
|
select: {
|
|
106
116
|
name?: string
|