@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statorjs/stator",
3
- "version": "2.10.0-next.1",
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-next.1"
102
+ "@statorjs/stator": "2.10.0"
99
103
  },
100
104
  "scripts": {
101
105
  "typecheck": "tsc --noEmit",
@@ -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
  }
@@ -95,6 +95,8 @@ export async function run(ctx: CliContext): Promise<void> {
95
95
  host: config.host,
96
96
  secret: config.secret,
97
97
  cors: config.cors,
98
+ logging: config.logging,
99
+ caching: config.caching,
98
100
  headExtras,
99
101
  buildId,
100
102
  machineHashes: machines,
@@ -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: Record<string, string>; cookies: any };
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'
@@ -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>() => getSessionState(c)?.claims as T | undefined,
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
  }
@@ -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 session?.sid ?? ''
75
+ return getSessionState(c)?.sid ?? ''
75
76
  },
76
77
  claims<T = unknown>(): T | undefined {
77
- return session?.claims as T | undefined
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
@@ -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 {
@@ -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 { CLAIMS_KEY, getOrCreateSessionId, getSessionState } from './session.ts'
22
+ import {
23
+ CLAIMS_KEY,
24
+ getOrCreateSessionId,
25
+ getSessionState,
26
+ peekSessionId,
27
+ resumeSession,
28
+ sessionUse,
29
+ } from './session.ts'
23
30
  import { withSessionLock } from './session-lock.ts'
24
31
  import { SessionRuntime } from './session-runtime.ts'
25
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 matchers. Returns the matched
118
- * route + extracted params, or null if nothing matches.
141
+ * Match a literal URL path against every compiled matcher that matches it,
142
+ * in `matchers`' own (priority-sorted) order — every candidate a 404-
143
+ * cascading GET dispatch might need to try, not just the first.
119
144
  */
120
- function matchPath(
121
- matchers: RouteMatcher[],
122
- literalPath: string,
123
- ): { route: DiscoveredRoute; params: Record<string, string> } | null {
145
+ function matchAllPaths(matchers: RouteMatcher[], literalPath: string): PathMatch[] {
146
+ const matches: PathMatch[] = []
124
147
  for (const m of matchers) {
125
148
  const result = m.regex.exec(literalPath)
126
149
  if (!result) continue
@@ -128,9 +151,65 @@ function matchPath(
128
151
  m.route.paramNames.forEach((name, i) => {
129
152
  params[name] = decodeURIComponent(result[i + 1] ?? '')
130
153
  })
131
- return { route: m.route, params }
154
+ matches.push({ route: m.route, params })
132
155
  }
133
- return null
156
+ return matches
157
+ }
158
+
159
+ /**
160
+ * Match a literal URL path against compiled matchers. Returns the FIRST
161
+ * matched route + extracted params, or null if nothing matches — for call
162
+ * sites resolving an already-known route key (the SSE/event endpoints),
163
+ * where there is exactly one right answer and no cascading question.
164
+ */
165
+ function matchPath(matchers: RouteMatcher[], literalPath: string): PathMatch | null {
166
+ return matchAllPaths(matchers, literalPath)[0] ?? null
167
+ }
168
+
169
+ /** True when any segment of a urlPath is a rest/catch-all (`*name`). */
170
+ function isRestPattern(urlPath: string): boolean {
171
+ return urlPath.split('/').some((seg) => seg.startsWith('*'))
172
+ }
173
+
174
+ /** True when any segment of a urlPath is a suffixed param (`:name.ext`) —
175
+ * the file-extension-routing pattern (`[id].json.ts` → `/p/:id.json`). */
176
+ function isSuffixedParamPattern(urlPath: string): boolean {
177
+ return urlPath.split('/').some((seg) => seg.startsWith(':') && seg.includes('.'))
178
+ }
179
+
180
+ /**
181
+ * Candidates a 404-cascading GET dispatch may actually try, in priority
182
+ * order.
183
+ *
184
+ * Two matches can structurally overlap for one literal URL in ways that
185
+ * were never meant to form a "try something more general" chain:
186
+ *
187
+ * - A suffixed param (`/p/:id.json`, from `[id].json.ts`) and its bare
188
+ * sibling (`/p/:id`) — `compileMatcher`'s bare `:id` regex doesn't
189
+ * exclude dots, so both structurally match `/p/abc.json`. The suffix
190
+ * is the author's own explicit, dedicated handler for exactly that
191
+ * extension (`sortRoutes`'s own doc comment: "so `/p/:id.json` wins
192
+ * `/p/abc.json` before `/p/:id` can swallow it") — nothing ranked
193
+ * below it, bare OR rest, is a MORE correct answer than what was
194
+ * written on purpose for this exact case. (A rest/catch-all can't
195
+ * even carry a suffix of its own — `filePathToRoute` rejects
196
+ * `[...name].ext` outright — so it's exactly as wrong a fallback here
197
+ * as bare is: confirmed against a real app, examples/live-poll's
198
+ * `/p/:id.json` data route + `/p/:id` HTML page pair, where a 404
199
+ * from the JSON route cascading into the page handed back HTML for a
200
+ * request that explicitly asked for JSON.) So a winning suffixed
201
+ * match is unconditionally terminal — no cascading at all, into
202
+ * anything, once it's the one that matched.
203
+ * - Anything bare/static vs. a rest/catch-all IS a legitimate fallback
204
+ * chain (`routes/media/[...path].ts`, this feature's original
205
+ * motivating case) — a catch-all stays cascade-eligible unless the
206
+ * winning match was itself suffixed.
207
+ */
208
+ function cascadeCandidates<T extends PathMatch>(all: T[]): T[] {
209
+ if (all.length === 0) return all
210
+ const winner = all[0]!
211
+ if (isSuffixedParamPattern(winner.route.urlPath)) return [winner]
212
+ return [winner, ...all.slice(1).filter((m) => isRestPattern(m.route.urlPath))]
134
213
  }
135
214
 
136
215
  /** Parse a route key like "GET /p/abc-123" into method + literal path. */
@@ -179,10 +258,13 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
179
258
  })
180
259
 
181
260
  // Context bridge — expose resolved config to every middleware (cors, app
182
- // middleware) that follows, off `stator(c)`. Runs first. Also establishes the
183
- // session once and eager-loads its claims (for a returning session), so the
184
- // whole request shares one sid + claims snapshot; dirty claims persist at the
185
- // end, to the current (possibly rotated) sid.
261
+ // middleware) that follows, off `stator(c)`. Runs first. Sessions are LAZY
262
+ // (the cacheable-read-path layer 1): an arriving cookie RESUMES its session
263
+ // (claims eager-loaded, no cookie write), but first contact establishes
264
+ // nothing — creation happens only at a dispatch, a session-machine read, an
265
+ // SSE connect, or an explicit session op. Anonymous reads therefore carry
266
+ // no Set-Cookie and stay CDN-cacheable. Dirty claims persist at the end,
267
+ // to the current (possibly rotated or just-established) sid.
186
268
  app.use('*', async (c, next) => {
187
269
  c.set('stator', {
188
270
  origin: config.origin,
@@ -194,14 +276,15 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
194
276
  c.set('statorStore', config.store)
195
277
  // Stash the signing key so the cookie jar's signed methods can reach it.
196
278
  if (config.secret !== undefined) c.set('statorSecret', config.secret)
197
- const { sessionId, isNew } = getOrCreateSessionId(c)
198
- const session = getSessionState(c)
199
- if (!isNew && session) {
200
- session.claims = (await config.store.persistence.get(sessionId, CLAIMS_KEY)) ?? undefined
279
+ sessionUse(c)
280
+ const resumed = resumeSession(c)
281
+ if (resumed) {
282
+ resumed.claims = (await config.store.persistence.get(resumed.sid, CLAIMS_KEY)) ?? undefined
201
283
  }
202
284
  await next()
203
- // Same session object (rotation mutates `.sid` in place), so persist to the
204
- // current — possibly rotated — id.
285
+ // Re-read: the session may have been established mid-request. Rotation
286
+ // mutates `.sid` in place, so this persists to the current id.
287
+ const session = getSessionState(c)
205
288
  if (session?.claimsDirty) {
206
289
  await config.store.persistence.set(session.sid, CLAIMS_KEY, session.claims, {
207
290
  ttlSeconds: config.store.sessionTtlSeconds,
@@ -260,7 +343,9 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
260
343
  const payload = await buildInspectPayload({
261
344
  store: config.store,
262
345
  routes: config.routes,
263
- sessionId: getSessionState(c)?.sid,
346
+ // Inspecting IS session use — the route shows the caller's own
347
+ // session, so establish one for a first-contact dev visit.
348
+ sessionId: getOrCreateSessionId(c).sessionId,
264
349
  buildId: config.buildId,
265
350
  })
266
351
  c.header('Cache-Control', 'no-cache')
@@ -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 matched = matchPath(matchers, c.req.path)
596
- if (!matched?.route.GET) return next()
597
- const getRoute = matched.route.GET
598
- // Brand decides the GET plane: data routes never touch the HTML path
599
- // (no client-runtime injection, no live meta — a raw Response out).
600
- if (isStatorQueryRoute(getRoute)) {
601
- return runQueryRoute(c, matched.route, getRoute, config.store, matched.params)
602
- }
603
- return handleGet(
604
- c,
605
- matched.route,
606
- getRoute,
607
- matched.params,
608
- config.store,
609
- config.headExtras,
610
- config.buildId,
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
- async function handleGet(
797
+ /** A rendered GET candidate, not yet committed to the shared Hono `Context`.
798
+ * Cascading (see `matchAllPaths`/the GET dispatch loop) speculatively tries
799
+ * several routes for one path — `applyRenderedEffects` writes headers,
800
+ * cookies, and status directly onto `c`, cumulatively, so calling it for a
801
+ * candidate that turns out to 404 would leak into whatever candidate wins
802
+ * next (worse: `applyRenderedEffects` only calls `c.status(...)` when the
803
+ * status isn't 200, so a losing 404 candidate followed by a winning 200 one
804
+ * would ship 200 content under a stale 404 status — confirmed the hard way
805
+ * designing this). Splitting render (safe to call speculatively, side-effect
806
+ * free on `c`) from commit (writes to `c`, only ever called once, for
807
+ * whichever candidate the dispatch loop actually decided to serve) is what
808
+ * makes cascading safe. */
809
+ interface GetCandidate {
810
+ status: number
811
+ commit: () => Response
812
+ }
813
+
814
+ async function renderGetCandidate(
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
- ): Promise<Response> {
684
- {
685
- const { sessionId } = getOrCreateSessionId(c)
686
- const literalPath = c.req.path
687
- const routeKey = `GET ${literalPath}`
688
- const request = { ...buildRouteRequest(c, discovered.paramNames), params }
689
-
690
- const runtime = new SessionRuntime(sessionId, store)
691
- try {
692
- await runtime.loadGraph(route.reads)
693
- const result = await renderRoute(route, routeKey, sessionId, runtime, request)
694
- let html = result.html
695
-
696
- const headHtml: string[] = []
697
- if (headExtras) {
698
- const extra = await headExtras(discovered.filePath)
699
- if (extra) headHtml.push(extra)
700
- }
701
- if (route.live) {
702
- headHtml.push('<meta name="stator-live" content="true">')
703
- // The build this page was rendered against — the client echoes it on
704
- // /__sse connect so the server can reload a page from a stale build.
705
- // `buildId` is a framework-generated UUID (no HTML-special chars).
706
- if (buildId) {
707
- headHtml.push(`<meta name="stator-build" content="${buildId}">`)
708
- }
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
- // Auto-inject the client runtime (delegated events + patch application).
712
- // Apps never hand-include it — a forgotten <script> is a silently dead
713
- // page (events fire nothing, no patches apply). Idempotent: skipped if the
714
- // document already references it, so a layout that still carries the tag
715
- // (or two passes sharing a doc) never loads it twice.
716
- const bodyHtml: string[] = []
717
- if (!html.includes('/static/client.js')) {
718
- bodyHtml.push('<script src="/static/client.js"></script>')
719
- }
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
- html = injectIntoDocument(html, {
722
- head: headHtml.join(''),
723
- bodyEnd: bodyHtml.join(''),
724
- })
725
- applyRenderedEffects(c, result.response)
726
-
727
- // A fresh machine that fired its initial entry effect on load must be
728
- // persisted (so the next request hydrates instead of re-firing) and its
729
- // effect scheduled off-lock, after the response. The common GET (no entry
730
- // effect) skips both and stays a pure read.
731
- const entryFired = runtime.entryFiredMachines()
732
- if (entryFired.size > 0) {
733
- await runtime.persistTouched(entryFired)
734
- scheduleSessionEffects(runtime, store, sessionId)
735
- }
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
- return c.html(html)
738
- } finally {
739
- runtime.dispose()
883
+ // Layer 3 — derived Cache-Control: the page is PROVABLY anonymous-
884
+ // identical (no session reads declared, no session use or claims read
885
+ // during handling) and the route didn't hand-set the header. The
886
+ // framework says what it can prove; hand-set always wins. Computed here
887
+ // but only ever WRITTEN from `commit`, below — this render may be a
888
+ // losing, discarded candidate under cascading, and only the winner's
889
+ // headers may reach `c` (the same reason `applyRenderedEffects` is
890
+ // deferred to `commit` rather than called here).
891
+ const use = sessionUse(c)
892
+ const deriveCacheControl =
893
+ !!caching &&
894
+ !needsSession &&
895
+ !use.used &&
896
+ !use.claimsRead &&
897
+ result.response.status < 300 &&
898
+ result.response.cookies.length === 0 &&
899
+ !result.response.headers.has('cache-control')
900
+
901
+ return {
902
+ status: result.response.status,
903
+ commit: () => {
904
+ applyRenderedEffects(c, result.response)
905
+ if (deriveCacheControl) {
906
+ c.header(
907
+ 'Cache-Control',
908
+ `public, max-age=0, s-maxage=${caching!.sMaxAge}, stale-while-revalidate=${caching!.staleWhileRevalidate}`,
909
+ )
910
+ }
911
+ return c.html(html)
912
+ },
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
- const { sessionId } = getOrCreateSessionId(c)
62
+ // Lazy layer 1: only session-machine reads establish; an app-only data
63
+ // route (a feed, a sitemap) renders sessionless and stays CDN-cacheable.
64
+ const needsSession = route.reads.some((def) => def.lifecycle === 'session')
65
+
66
+ // The revision ledger: when the route declares a cheap fingerprint, the
67
+ // ETag derives from it and a match answers 304 BEFORE the handler — the
68
+ // designed upgrade the body-hash ETag always pointed at. The fingerprint
69
+ // runs outside any session machinery, so the derived-caching proof holds.
70
+ let revisionEtag: string | undefined
71
+ if (route.revision) {
72
+ const rev = await route.revision()
73
+ revisionEtag = `"r-${createHash('sha1').update(String(rev)).digest('base64url').slice(0, 16)}"`
74
+ if ((c.req.header('if-none-match') ?? '').includes(revisionEtag)) {
75
+ const notModified = new Response(null, { status: 304, headers: { ETag: revisionEtag } })
76
+ applyDerivedCaching(c, notModified, { caching, needsSession })
77
+ return notModified
78
+ }
79
+ }
80
+ const sessionId = needsSession
81
+ ? getOrCreateSessionId(c).sessionId
82
+ : (peekSessionId(c) ?? '@anonymous')
60
83
  const request = params
61
84
  ? { ...buildRouteRequest(c, discovered.paramNames), params }
62
85
  : buildRouteRequest(c, discovered.paramNames)
@@ -99,12 +122,54 @@ export async function runQueryRoute(
99
122
  scheduleSessionEffects(runtime, store, sessionId)
100
123
  }
101
124
 
102
- return synthesizeDataResponse(c, result)
125
+ const res = synthesizeDataResponse(c, result)
126
+ // A revision route's responses carry the revision ETag (overriding the
127
+ // body hash) so the cheap 304 path and full responses validate against
128
+ // one token.
129
+ if (revisionEtag) {
130
+ try {
131
+ res.headers.set('etag', revisionEtag)
132
+ } catch {
133
+ // Immutable headers (proxied Response) — the handler's call.
134
+ }
135
+ }
136
+ applyDerivedCaching(c, res, { caching, needsSession })
137
+ return res
103
138
  } finally {
104
139
  runtime.dispose()
105
140
  }
106
141
  }
107
142
 
143
+ /** Layer 3 — derived Cache-Control, same proof as pages: no session reads
144
+ * declared, no session use/claims read while handling, handler set nothing
145
+ * itself. Applied to 200 and 304 alike (a 304's headers update the cached
146
+ * entry's lifetime). */
147
+ function applyDerivedCaching(
148
+ c: Context,
149
+ res: Response,
150
+ opts: { caching?: { sMaxAge: number; staleWhileRevalidate: number }; needsSession: boolean },
151
+ ): void {
152
+ const use = sessionUse(c)
153
+ if (
154
+ !opts.caching ||
155
+ opts.needsSession ||
156
+ use.used ||
157
+ use.claimsRead ||
158
+ res.status >= 400 ||
159
+ res.headers.has('cache-control')
160
+ ) {
161
+ return
162
+ }
163
+ try {
164
+ res.headers.set(
165
+ 'cache-control',
166
+ `public, max-age=0, s-maxage=${opts.caching.sMaxAge}, stale-while-revalidate=${opts.caching.staleWhileRevalidate}`,
167
+ )
168
+ } catch {
169
+ // Immutable headers (proxied Response) — the handler's call.
170
+ }
171
+ }
172
+
108
173
  /** The URL's extension (`/rss.xml` → `.xml`). A leading dot alone
109
174
  * (`/.well-known`) is not an extension. */
110
175
  function urlExtension(path: string): string | undefined {
@@ -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
- const segments = fileBase === 'index' ? dirSegments : [...dirSegments, fileBase]
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
  }
@@ -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 {
@@ -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
- // Idempotent per request: the first caller (middleware or handler) establishes
73
- // the session; everyone after reads the same one. Closes the double-create bug.
74
- const established = c.get('statorSession')
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
- c.set('statorSession', { sid: sessionId, isNew, claims: undefined, claimsDirty: false })
83
- return { sessionId, isNew }
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