@statorjs/stator 1.5.2 → 1.6.1

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": "1.5.2",
3
+ "version": "1.6.1",
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 (Vite/tsx-native by design).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -88,7 +88,7 @@
88
88
  "pino-pretty": "^13.1.3",
89
89
  "vite": "^6.0.0",
90
90
  "vitest": "^2.1.0",
91
- "@statorjs/stator": "1.5.2"
91
+ "@statorjs/stator": "1.6.1"
92
92
  },
93
93
  "scripts": {
94
94
  "typecheck": "tsc --noEmit",
@@ -8,3 +8,12 @@ export const clientId: string =
8
8
  typeof crypto !== 'undefined' && 'randomUUID' in crypto
9
9
  ? crypto.randomUUID()
10
10
  : `c${Math.random().toString(36).slice(2)}`
11
+
12
+ /** Per-dispatch idempotency key: the server replays a duplicate POST's cached
13
+ * response instead of re-applying it, which is what makes retrying a lost
14
+ * response safe. Stable across retries of the SAME dispatch, never reused. */
15
+ export function newEventId(): string {
16
+ return typeof crypto !== 'undefined' && 'randomUUID' in crypto
17
+ ? crypto.randomUUID()
18
+ : `e${Math.random().toString(36).slice(2)}`
19
+ }
@@ -1,7 +1,8 @@
1
1
  import type { AnyMachineDef, EventOf } from '../engine/index.ts'
2
2
  import { applyDirectives, applyPatches } from '../wire/apply.ts'
3
3
  import type { WireEnvelope } from '../wire/index.ts'
4
- import { clientId } from './client-id.ts'
4
+ import { newEventId } from './client-id.ts'
5
+ import { type DispatchError, postEvent } from './transport.ts'
5
6
 
6
7
  export interface DispatchResult {
7
8
  /** The POST reached the server and returned 200. */
@@ -12,6 +13,8 @@ export interface DispatchResult {
12
13
  /** Patches applied to THIS page (a committed event may patch zero slots
13
14
  * here if the touched machines aren't bound on the current route). */
14
15
  patchCount: number
16
+ /** Present only when `ok` is false — how the POST failed. */
17
+ error?: DispatchError
15
18
  }
16
19
 
17
20
  /**
@@ -30,28 +33,14 @@ export async function dispatch<D extends AnyMachineDef>(
30
33
  machine: D,
31
34
  event: EventOf<D>,
32
35
  ): Promise<DispatchResult> {
33
- let res: Response
34
- try {
35
- res = await fetch('/__events', {
36
- method: 'POST',
37
- headers: {
38
- 'Content-Type': 'application/json',
39
- Accept: 'application/json',
40
- 'X-Stator-Route': `GET ${location.pathname}${location.search}`,
41
- 'X-Stator-Client': clientId,
42
- },
43
- credentials: 'same-origin',
44
- body: JSON.stringify({ machine: machine.name, event }),
45
- })
46
- } catch (err) {
47
- console.error('stator: dispatch network error', err)
48
- return { ok: false, committed: false, patchCount: 0 }
36
+ const result = await postEvent(
37
+ { machine: machine.name, event, eventId: newEventId() },
38
+ `GET ${location.pathname}${location.search}`,
39
+ )
40
+ if (!result.ok) {
41
+ return { ok: false, committed: false, patchCount: 0, error: result.error }
49
42
  }
50
- if (!res.ok) {
51
- console.error('stator: dispatch failed', res.status)
52
- return { ok: false, committed: false, patchCount: 0 }
53
- }
54
- const data = (await res.json()) as WireEnvelope
43
+ const data = (await result.res.json()) as WireEnvelope
55
44
  applyPatches(data.patches ?? [])
56
45
  applyDirectives(data.directives ?? [])
57
46
  return {
@@ -5,9 +5,11 @@
5
5
  */
6
6
 
7
7
  export { bind, effect } from './bind.ts'
8
+ export type { DispatchResult } from './dispatch.ts'
8
9
  export { dispatch } from './dispatch.ts'
9
10
  export { defineElement, StatorElement } from './element.ts'
10
11
  export type { ClientBehavior, LegacyMachineConfig } from './machine.ts'
11
12
  export { machine } from './machine.ts'
13
+ export type { DispatchError } from './transport.ts'
12
14
  export type { ClientInstance, ClientInstanceBase } from './use.ts'
13
15
  export { use } from './use.ts'
@@ -155,6 +155,21 @@ function mount(): void {
155
155
  )
156
156
  })
157
157
 
158
+ window.addEventListener('stator:dispatch-error', (e: Event) => {
159
+ const { machine, phase, status, timestamp } = (e as CustomEvent).detail
160
+ addEntry(
161
+ 'up',
162
+ `<div class="stator-inspector-summary">
163
+ <span class="stator-inspector-time">${fmtTime(timestamp)}</span>
164
+ <span class="stator-inspector-arrow">✕</span>
165
+ <span class="stator-inspector-machine">${escapeHtml(machine ?? 'form')}</span>
166
+ <span class="stator-inspector-event-type">${escapeHtml(phase)}</span>
167
+ <span class="stator-inspector-params">${status != null ? escapeHtml(String(status)) : ''}</span>
168
+ </div>`,
169
+ (e as CustomEvent).detail,
170
+ )
171
+ })
172
+
158
173
  window.addEventListener('stator:patches-received', (e: Event) => {
159
174
  const { patches, source, durationMs, timestamp } = (e as CustomEvent).detail
160
175
  const timing = durationMs != null ? `${durationMs}ms` : ''
@@ -19,10 +19,35 @@
19
19
 
20
20
  import { applyDirectives, applyPatches } from '../wire/apply.ts'
21
21
  import type { WireEnvelope } from '../wire/index.ts'
22
- import { clientId } from './client-id.ts'
22
+ import { clientId, newEventId } from './client-id.ts'
23
+ import { emitDispatchError, fetchWithTimeout, postEvent, TimeoutError } from './transport.ts'
23
24
 
24
25
  const EVENT_TYPES = ['click', 'submit', 'change', 'input'] as const
25
26
 
27
+ /* ------------------------------------------------------------------ */
28
+ /* In-flight affordance */
29
+ /* ------------------------------------------------------------------ */
30
+
31
+ /** `data-stator-pending` marks the element whose event POST is in flight
32
+ * (CSS hook: `[data-stator-pending]`). Counted per element so rapid repeat
33
+ * dispatches keep the attribute until the LAST one settles. */
34
+ const pendingCounts = new WeakMap<Element, number>()
35
+
36
+ function beginPending(el: Element): void {
37
+ pendingCounts.set(el, (pendingCounts.get(el) ?? 0) + 1)
38
+ el.setAttribute('data-stator-pending', '')
39
+ }
40
+
41
+ function endPending(el: Element): void {
42
+ const count = (pendingCounts.get(el) ?? 1) - 1
43
+ if (count <= 0) {
44
+ pendingCounts.delete(el)
45
+ el.removeAttribute('data-stator-pending')
46
+ } else {
47
+ pendingCounts.set(el, count)
48
+ }
49
+ }
50
+
26
51
  /* ------------------------------------------------------------------ */
27
52
  /* Observability hooks */
28
53
  /* ------------------------------------------------------------------ */
@@ -42,44 +67,29 @@ function init(): void {
42
67
  initLiveChannel()
43
68
  }
44
69
 
45
- function initLiveChannel(): void {
70
+ function initLiveChannel(): { close(): void } | undefined {
46
71
  const meta = document.querySelector('meta[name="stator-live"][content="true"]')
47
72
  if (!meta) return
48
73
 
49
74
  const routeKey = `GET ${location.pathname}${location.search}`
50
75
  const url = `/__sse?route=${encodeURIComponent(routeKey)}&client=${encodeURIComponent(clientId)}`
51
- const sse = new EventSource(url, { withCredentials: true })
52
76
 
53
- let everOpened = false
54
- let lastSeen = Date.now()
55
- sse.addEventListener('open', () => {
56
- if (everOpened) {
57
- // Reconnect — reload rather than risk stale state.
58
- location.reload()
59
- return
60
- }
61
- everOpened = true
62
- })
63
-
64
- // Zombie watchdog. A half-open connection (device sleep, silent NAT drop)
65
- // never fires `error` — EventSource just sits "open" and silent forever,
66
- // and the page quietly stops being live. The server sends an observable
67
- // ping every 25s; two missed pings while the page is VISIBLE means the
68
- // channel is dead, and a reload re-syncs (same strategy as reconnect).
69
- // Hidden tabs wait for visibility — no reloading pages nobody is watching.
70
- const STALE_MS = 65_000
71
- const staleReload = (): void => {
72
- if (!everOpened || document.hidden) return
73
- if (Date.now() - lastSeen > STALE_MS) {
74
- console.warn('stator: SSE channel stale — reloading to re-sync')
75
- sse.close()
76
- location.reload()
77
- }
77
+ // Connection-state signal: `data-stator-connection` on the root element
78
+ // (CSS hook for offline banners etc.) plus a `stator:connection-state`
79
+ // event. Change-guarded — EventSource fires `error` on every failed
80
+ // auto-reconnect attempt, and only transitions are worth announcing.
81
+ let connectionState: string | undefined
82
+ const setConnectionState = (state: 'connected' | 'disconnected' | 'stale'): void => {
83
+ if (state === connectionState) return
84
+ connectionState = state
85
+ document.documentElement.setAttribute('data-stator-connection', state)
86
+ emit('stator:connection-state', { state, timestamp: Date.now() })
78
87
  }
79
- setInterval(staleReload, 10_000)
80
- document.addEventListener('visibilitychange', staleReload)
81
88
 
82
- sse.addEventListener('message', (e) => {
89
+ let sse: EventSource | null = null
90
+ let lastSeen = Date.now()
91
+
92
+ const onMessage = (e: MessageEvent): void => {
83
93
  lastSeen = Date.now()
84
94
  let data: WireEnvelope
85
95
  try {
@@ -103,13 +113,57 @@ function initLiveChannel(): void {
103
113
  if (data.directives && data.directives.length > 0) {
104
114
  applyDirectives(data.directives)
105
115
  }
106
- })
116
+ }
107
117
 
108
- sse.addEventListener('error', () => {
109
- if (sse.readyState === EventSource.CLOSED) {
110
- console.warn('stator: SSE permanently closed')
118
+ // Reconnect = resync, never reload. Every fresh /__sse connection gets the
119
+ // server's initial sync (every binding's current value, keyed lists reset
120
+ // wholesale), so a rebuilt channel converges the DOM in place — and the
121
+ // browser's own auto-reconnect on the same instance re-runs that server
122
+ // path too, its sync arriving as an ordinary message. Directives fired
123
+ // during an outage (e.g. a navigate) are not replayed; resync converges
124
+ // state only.
125
+ const connect = (): void => {
126
+ sse?.close()
127
+ const es = new EventSource(url, { withCredentials: true })
128
+ sse = es
129
+ lastSeen = Date.now() // fresh grace period — no instant re-stale loop
130
+ es.addEventListener('open', () => setConnectionState('connected'))
131
+ es.addEventListener('message', onMessage)
132
+ es.addEventListener('error', () => {
133
+ setConnectionState('disconnected')
134
+ if (es.readyState === EventSource.CLOSED) {
135
+ console.warn('stator: SSE permanently closed')
136
+ }
137
+ })
138
+ }
139
+
140
+ // Zombie watchdog. A half-open connection (device sleep, silent NAT drop)
141
+ // never fires `error` — EventSource just sits "open" and silent forever,
142
+ // and the page quietly stops being live. The server sends an observable
143
+ // ping every 25s; two missed pings while the page is VISIBLE means the
144
+ // channel is dead, and a rebuilt connection re-syncs. Hidden tabs wait for
145
+ // visibility — no churn for pages nobody is watching.
146
+ const STALE_MS = 65_000
147
+ const checkStale = (): void => {
148
+ if (document.hidden) return
149
+ if (Date.now() - lastSeen > STALE_MS) {
150
+ console.warn('stator: SSE channel stale — reconnecting to re-sync')
151
+ setConnectionState('stale')
152
+ connect()
111
153
  }
112
- })
154
+ }
155
+ const watchdog = setInterval(checkStale, 10_000)
156
+ document.addEventListener('visibilitychange', checkStale)
157
+
158
+ connect()
159
+
160
+ return {
161
+ close(): void {
162
+ clearInterval(watchdog)
163
+ document.removeEventListener('visibilitychange', checkStale)
164
+ sse?.close()
165
+ },
166
+ }
113
167
  }
114
168
 
115
169
  function handleEvent(e: Event): void {
@@ -131,7 +185,7 @@ function handleEvent(e: Event): void {
131
185
  console.error('stator: malformed event descriptor on form', form, descriptorAttr)
132
186
  return
133
187
  }
134
- void dispatchEvent(descriptor)
188
+ void dispatchEvent(descriptor, form)
135
189
  return
136
190
  }
137
191
  // Opt-in interception via `data-stator-enhance`. Plain forms without
@@ -168,13 +222,13 @@ function handleEvent(e: Event): void {
168
222
  return
169
223
  }
170
224
 
171
- void dispatchEvent(descriptor)
225
+ void dispatchEvent(descriptor, el)
172
226
  }
173
227
 
174
- async function dispatchEvent(descriptor: {
175
- machine: string
176
- event: { type: string }
177
- }): Promise<void> {
228
+ async function dispatchEvent(
229
+ descriptor: { machine: string; event: { type: string } },
230
+ el?: Element,
231
+ ): Promise<void> {
178
232
  const routeKey = `GET ${location.pathname}${location.search}`
179
233
 
180
234
  emit('stator:event-sent', {
@@ -185,28 +239,14 @@ async function dispatchEvent(descriptor: {
185
239
  })
186
240
 
187
241
  const startedAt = performance.now()
188
- let res: Response
242
+ if (el) beginPending(el)
189
243
  try {
190
- res = await fetch('/__events', {
191
- method: 'POST',
192
- headers: {
193
- 'Content-Type': 'application/json',
194
- Accept: 'application/json',
195
- 'X-Stator-Route': routeKey,
196
- 'X-Stator-Client': clientId,
197
- },
198
- credentials: 'same-origin',
199
- body: JSON.stringify(descriptor),
200
- })
201
- } catch (err) {
202
- console.error('stator: network error during dispatch', err)
203
- return
244
+ const result = await postEvent({ ...descriptor, eventId: newEventId() }, routeKey)
245
+ if (!result.ok) return
246
+ await applyEnvelopeFromResponse(result.res, startedAt, 'post')
247
+ } finally {
248
+ if (el) endPending(el)
204
249
  }
205
- if (!res.ok) {
206
- console.error('stator: event POST failed', res.status, await res.text())
207
- return
208
- }
209
- await applyEnvelopeFromResponse(res, startedAt, 'post')
210
250
  }
211
251
 
212
252
  /**
@@ -217,23 +257,30 @@ async function dispatchEvent(descriptor: {
217
257
  async function submitForm(form: HTMLFormElement): Promise<void> {
218
258
  const formData = new FormData(form)
219
259
  const startedAt = performance.now()
220
- let res: Response
260
+ beginPending(form)
221
261
  try {
222
- res = await fetch(form.action, {
223
- method: 'POST',
224
- headers: { Accept: 'application/json' },
225
- credentials: 'same-origin',
226
- body: formData,
227
- })
228
- } catch (err) {
229
- console.error('stator: network error during form submit', err)
230
- return
231
- }
232
- if (!res.ok) {
233
- console.error('stator: form submit failed', res.status, await res.text())
234
- return
262
+ let res: Response
263
+ try {
264
+ res = await fetchWithTimeout(form.action, {
265
+ method: 'POST',
266
+ headers: { Accept: 'application/json' },
267
+ credentials: 'same-origin',
268
+ body: formData,
269
+ })
270
+ } catch (err) {
271
+ console.error('stator: network error during form submit', err)
272
+ emitDispatchError({ phase: err instanceof TimeoutError ? 'timeout' : 'network' }, {})
273
+ return
274
+ }
275
+ if (!res.ok) {
276
+ console.error('stator: form submit failed', res.status, await res.text())
277
+ emitDispatchError({ phase: 'http', status: res.status }, {})
278
+ return
279
+ }
280
+ await applyEnvelopeFromResponse(res, startedAt, 'post')
281
+ } finally {
282
+ endPending(form)
235
283
  }
236
- await applyEnvelopeFromResponse(res, startedAt, 'post')
237
284
  }
238
285
 
239
286
  async function applyEnvelopeFromResponse(
@@ -268,3 +315,8 @@ if (document.readyState === 'loading') {
268
315
  } else {
269
316
  init()
270
317
  }
318
+
319
+ // Exported for tests only — the runtime self-initializes above, and the
320
+ // client bundle (esbuild IIFE) ignores exports. Re-calling init() is safe:
321
+ // addEventListener dedupes an identical listener reference.
322
+ export { init, initLiveChannel }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Shared POST transport for the two `/__events` callers — the delegated page
3
+ * runtime and island `dispatch()`. Lives apart from runtime.ts because that
4
+ * module self-initializes on import; islands importing `dispatch` must not
5
+ * drag the page runtime (and its document-wide listeners) into their bundle.
6
+ *
7
+ * Failure contract: network errors, timeouts, and non-2xx responses resolve
8
+ * (never throw) as `{ ok: false, error }`, and a `stator:dispatch-error`
9
+ * CustomEvent fires on window once per dispatch when the failure is final.
10
+ */
11
+
12
+ import { clientId } from './client-id.ts'
13
+
14
+ /** App-level deadline for event POSTs — browsers' own connection timeouts are
15
+ * far too long to leave an interaction hanging on a flaky link. */
16
+ export const FETCH_TIMEOUT_MS = 10_000
17
+
18
+ /** Backoff before each retry of a network/timeout failure. Retries carry the
19
+ * same eventId, so a duplicate that actually reached the server replays. */
20
+ export const RETRY_DELAYS_MS = [300, 1000]
21
+
22
+ export interface DispatchError {
23
+ phase: 'network' | 'timeout' | 'http'
24
+ /** HTTP status — present only for `phase: 'http'`. */
25
+ status?: number
26
+ }
27
+
28
+ /** Thrown by `fetchWithTimeout` when the deadline aborts the request, so
29
+ * callers can tell a dead-slow connection from an unreachable one. */
30
+ export class TimeoutError extends Error {
31
+ constructor() {
32
+ super(`request exceeded ${FETCH_TIMEOUT_MS}ms`)
33
+ this.name = 'TimeoutError'
34
+ }
35
+ }
36
+
37
+ function emit(name: string, detail: unknown): void {
38
+ window.dispatchEvent(new CustomEvent(name, { detail }))
39
+ }
40
+
41
+ export function emitDispatchError(
42
+ error: DispatchError,
43
+ context: { machine?: string; event?: { type: string } },
44
+ ): void {
45
+ emit('stator:dispatch-error', { ...context, ...error, timestamp: Date.now() })
46
+ }
47
+
48
+ /** `fetch` with the app-level deadline applied via AbortController. */
49
+ export async function fetchWithTimeout(input: string, init: RequestInit): Promise<Response> {
50
+ const controller = new AbortController()
51
+ let timedOut = false
52
+ const timer = setTimeout(() => {
53
+ timedOut = true
54
+ controller.abort()
55
+ }, FETCH_TIMEOUT_MS)
56
+ try {
57
+ return await fetch(input, { ...init, signal: controller.signal })
58
+ } catch (err) {
59
+ throw timedOut ? new TimeoutError() : err
60
+ } finally {
61
+ clearTimeout(timer)
62
+ }
63
+ }
64
+
65
+ export type PostEventResult = { ok: true; res: Response } | { ok: false; error: DispatchError }
66
+
67
+ /**
68
+ * POST an event descriptor to `/__events`. Resolves — never throws — and
69
+ * emits `stator:dispatch-error` once when a failure is final. Network and
70
+ * timeout failures retry with backoff, but ONLY when the body carries an
71
+ * `eventId` (the server's replay cache is what makes a duplicate safe —
72
+ * keyed-list patches are not idempotent). Non-2xx responses are terminal
73
+ * immediately: the server saw the event and answered.
74
+ */
75
+ export async function postEvent(
76
+ body: { machine: string; event: { type: string }; eventId?: string },
77
+ routeKey: string,
78
+ ): Promise<PostEventResult> {
79
+ let error: DispatchError
80
+ for (let attempt = 0; ; attempt++) {
81
+ try {
82
+ const res = await fetchWithTimeout('/__events', {
83
+ method: 'POST',
84
+ headers: {
85
+ 'Content-Type': 'application/json',
86
+ Accept: 'application/json',
87
+ 'X-Stator-Route': routeKey,
88
+ 'X-Stator-Client': clientId,
89
+ },
90
+ credentials: 'same-origin',
91
+ body: JSON.stringify(body),
92
+ })
93
+ if (res.ok) return { ok: true, res }
94
+ error = { phase: 'http', status: res.status }
95
+ console.error('stator: event POST failed', res.status, await res.text())
96
+ break
97
+ } catch (err) {
98
+ error = { phase: err instanceof TimeoutError ? 'timeout' : 'network' }
99
+ const delay = RETRY_DELAYS_MS[attempt]
100
+ if (delay === undefined || !body.eventId) {
101
+ console.error('stator: network error during dispatch', err)
102
+ break
103
+ }
104
+ console.warn(`stator: dispatch ${error.phase} — retrying in ${delay}ms`)
105
+ await new Promise((r) => setTimeout(r, delay))
106
+ }
107
+ }
108
+ emitDispatchError(error, body)
109
+ return { ok: false, error }
110
+ }
@@ -13,6 +13,7 @@ export { defineMachine } from './define-machine.ts'
13
13
  export type {
14
14
  Action,
15
15
  ActionHelpers,
16
+ AfterEntry,
16
17
  AnyMachineDef,
17
18
  Capabilities,
18
19
  Effect,
@@ -20,6 +21,7 @@ export type {
20
21
  EffectMeta,
21
22
  EmitDeclaration,
22
23
  EmitsConfig,
24
+ EntryEffect,
23
25
  EventObject,
24
26
  EventOf,
25
27
  Guard,
@@ -121,9 +121,17 @@ export async function runApiRoute(
121
121
  scheduleSessionEffects(runtime, store, effectsSessionId)
122
122
 
123
123
  // Escape hatch: handler returned a real Response. Pass through.
124
- if (result instanceof Response) return result
124
+ if (isResponseLike(result)) return result
125
125
 
126
126
  const envelope = result as ApiRouteEnvelope
127
+ if (envelope.patches === undefined && envelope.directives === undefined) {
128
+ // Neither Response-shaped nor envelope-shaped: synthesizing an empty
129
+ // envelope would silently discard whatever the handler meant to send.
130
+ apiLog.warn(
131
+ { path: c.req.path },
132
+ 'api route returned a value that is neither a Response nor a {patches, directives} envelope — treating it as an empty envelope',
133
+ )
134
+ }
127
135
  return synthesizeResponse(c, request, envelope)
128
136
  } finally {
129
137
  runtime.dispose()
@@ -131,6 +139,22 @@ export async function runApiRoute(
131
139
  })
132
140
  }
133
141
 
142
+ /** `instanceof Response` alone misses Response objects from another realm or
143
+ * a wrapping library (Vite SSR loaders, @hono/node-server's lightweight
144
+ * class) — and a miss here silently reinterprets the handler's Response as
145
+ * an envelope. Duck-type the shape the passthrough actually needs. */
146
+ export function isResponseLike(v: unknown): v is Response {
147
+ if (v instanceof Response) return true
148
+ const r = v as Response | null
149
+ return (
150
+ typeof r === 'object' &&
151
+ r !== null &&
152
+ typeof r.status === 'number' &&
153
+ typeof r.headers?.get === 'function' &&
154
+ typeof r.arrayBuffer === 'function'
155
+ )
156
+ }
157
+
134
158
  /** The Referer header is attacker-controllable, so redirecting back to it is an
135
159
  * open-redirect vector. Return a same-origin relative path (pathname+search)
136
160
  * when the referer matches this request's origin, else '/'. */
@@ -1,5 +1,7 @@
1
1
  import { resolve } from 'node:path'
2
2
  import { serve } from '@hono/node-server'
3
+ import type { AnyMachineDef, EventOf } from '../engine/index.ts'
4
+ import { dispatchToApp } from './app-dispatch.ts'
3
5
  import type { AppStore } from './app-store.ts'
4
6
  import { installGracefulShutdown } from './banner.ts'
5
7
  import { discoverMachines } from './discovery.ts'
@@ -38,9 +40,16 @@ export interface StatorApp {
38
40
  listen(port: number): Promise<void>
39
41
  /** For tests — get the underlying Hono fetch handler. */
40
42
  fetch: (request: Request) => Response | Promise<Response>
41
- /** The machine registry + app actors — pass to `dispatchToApp` for
42
- * server-originated events (webhooks, cron). */
43
+ /** The machine registry + app actors. Kept exposed for advanced use;
44
+ * prefer the `dispatchToApp` method for server-originated events. */
43
45
  store: MachineStore
46
+ /** Server-originated dispatch to an APP-lifecycle machine — the entry
47
+ * point for webhooks and cron. Same contract as the standalone
48
+ * `dispatchToApp(store, …)`, bound to this app's store. */
49
+ dispatchToApp<D extends AnyMachineDef>(
50
+ machine: D,
51
+ event: EventOf<D>,
52
+ ): Promise<{ committed: boolean }>
44
53
  }
45
54
 
46
55
  export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
@@ -100,5 +109,6 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
100
109
  },
101
110
  fetch: (request: Request) => app.fetch(request),
102
111
  store,
112
+ dispatchToApp: (machine, event) => dispatchToApp(store, machine, event),
103
113
  }
104
114
  }
package/src/server/dev.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { existsSync } from 'node:fs'
1
2
  import { readFile } from 'node:fs/promises'
2
3
  import { createServer as createHttpServer } from 'node:http'
3
4
  import { relative, resolve } from 'node:path'
@@ -5,12 +6,14 @@ import { getRequestListener } from '@hono/node-server'
5
6
  import type { Hono } from 'hono'
6
7
  import { createServer as createViteServer, type ViteDevServer } from 'vite'
7
8
  import { compile } from '../compiler/index.ts'
9
+ import type { AnyMachineDef, EventOf } from '../engine/index.ts'
8
10
  import { machineStub, stator } from '../vite/index.ts'
9
11
  import type { AppStore } from './app-store.ts'
10
12
  import { findFreePort, installGracefulShutdown, printDevBanner } from './banner.ts'
11
13
  import { logger } from './logger.ts'
12
14
  import type { MachineStore } from './machine-store.ts'
13
15
  import type { DiscoveredRoute } from './route-discovery.ts'
16
+ import { isStatorQueryRoute } from './routing.ts'
14
17
  import type { Store } from './store.ts'
15
18
 
16
19
  /**
@@ -47,6 +50,16 @@ export interface DevAppConfig {
47
50
  export interface DevApp {
48
51
  fetch: (request: Request) => Response | Promise<Response>
49
52
  vite: ViteDevServer
53
+ /** Server-originated dispatch to an APP-lifecycle machine (webhooks, cron)
54
+ * — the dev counterpart of `StatorApp.dispatchToApp`. Closes over the
55
+ * current store (surviving machine-edit rebuilds) and runs through the
56
+ * Vite-loaded runtime, so SSE fan-out reaches live connections; a
57
+ * natively-imported `dispatchToApp` would be a second module instance
58
+ * whose connection registry is empty. */
59
+ dispatchToApp<D extends AnyMachineDef>(
60
+ machine: D,
61
+ event: EventOf<D>,
62
+ ): Promise<{ committed: boolean }>
50
63
  listen: (port: number) => Promise<void>
51
64
  close: () => Promise<void>
52
65
  }
@@ -153,6 +166,19 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
153
166
  }
154
167
  const rebuildRoutes = async (): Promise<void> => {
155
168
  routes = await runtime.discoverRoutes(routesDir, loader)
169
+ // Vite serves `public/` at the root ahead of Hono in dev, so a static
170
+ // file at a data route's URL silently wins. Warn at discovery time.
171
+ // (The brand is a plain property, so this native-import check works on
172
+ // defs the Vite-loaded runtime created.)
173
+ for (const r of routes) {
174
+ if (!r.GET || !isStatorQueryRoute(r.GET) || r.paramNames.length > 0) continue
175
+ if (existsSync(resolve(root, 'public', r.urlPath.slice(1)))) {
176
+ console.warn(
177
+ `stator: public/${r.urlPath.slice(1)} shadows the data route ${r.urlPath} in dev — ` +
178
+ `the static file wins. Rename one of them.`,
179
+ )
180
+ }
181
+ }
156
182
  }
157
183
  const rebuildServer = async (): Promise<void> => {
158
184
  app = await runtime.buildHonoApp({
@@ -204,6 +230,9 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
204
230
  return {
205
231
  fetch: (request) => app.fetch(request),
206
232
  vite,
233
+ // `store` is read at call time, not captured — a machine-edit rebuild
234
+ // swaps it and the method follows.
235
+ dispatchToApp: (machine, event) => runtime.dispatchToApp(store, machine, event),
207
236
  listen(port: number): Promise<void> {
208
237
  // Vite's middlewares serve client modules + HMR to the browser; anything
209
238
  // it doesn't handle (routes, /__events, /__sse) falls through to Hono.
@@ -0,0 +1,54 @@
1
+ /**
2
+ * In-process replay cache for `/__events` idempotency keys. A retried POST
3
+ * whose first attempt committed (but whose response was lost on the wire)
4
+ * must not re-apply — keyed-list patches are positional and not idempotent —
5
+ * so the exact response body is cached per (session, eventId) and replayed
6
+ * verbatim on a duplicate.
7
+ *
8
+ * Module-level and in-process, like the SSE connection registry and timers:
9
+ * single-replica is a stated 1.0 constraint, and the cache carries the same
10
+ * non-durable contract (a restart forgets it — the retry window is seconds,
11
+ * the cost of a lost entry is one duplicate apply, and durable delivery is
12
+ * 1.x inbox work).
13
+ */
14
+
15
+ /** Ample for the retry window (client retries at ~0.3s/1s); bounds memory. */
16
+ const MAX_PER_SESSION = 32
17
+ const SESSION_IDLE_MS = 30 * 60_000
18
+
19
+ interface SessionCache {
20
+ /** eventId → serialized response body, insertion-ordered for eviction. */
21
+ byId: Map<string, string>
22
+ lastTouched: number
23
+ }
24
+
25
+ const sessions = new Map<string, SessionCache>()
26
+
27
+ export function replayFor(sessionId: string, eventId: string): string | undefined {
28
+ const cache = sessions.get(sessionId)
29
+ if (!cache) return undefined
30
+ cache.lastTouched = Date.now()
31
+ return cache.byId.get(eventId)
32
+ }
33
+
34
+ export function record(sessionId: string, eventId: string, body: string): void {
35
+ let cache = sessions.get(sessionId)
36
+ if (!cache) {
37
+ sweepIdle()
38
+ cache = { byId: new Map(), lastTouched: 0 }
39
+ sessions.set(sessionId, cache)
40
+ }
41
+ cache.lastTouched = Date.now()
42
+ cache.byId.set(eventId, body)
43
+ if (cache.byId.size > MAX_PER_SESSION) {
44
+ const oldest = cache.byId.keys().next().value
45
+ if (oldest !== undefined) cache.byId.delete(oldest)
46
+ }
47
+ }
48
+
49
+ function sweepIdle(): void {
50
+ const cutoff = Date.now() - SESSION_IDLE_MS
51
+ for (const [id, cache] of sessions) {
52
+ if (cache.lastTouched < cutoff) sessions.delete(id)
53
+ }
54
+ }
@@ -8,13 +8,15 @@ import { z } from 'zod'
8
8
  import { applyRenderedEffects, runApiRoute } from './api-route.ts'
9
9
  import { isBlockedCrossSite } from './csrf.ts'
10
10
  import { scheduleSessionEffects } from './effects.ts'
11
+ import { record, replayFor } from './event-dedupe.ts'
11
12
  import { scopedLogger } from './logger.ts'
12
13
  import type { MachineStore } from './machine-store.ts'
14
+ import { runQueryRoute } from './query-route.ts'
13
15
  import { initialSyncPatches, recompute } from './recompute.ts'
14
16
  import { renderRoute } from './render.ts'
15
17
  import type { DiscoveredRoute } from './route-discovery.ts'
16
18
  import { buildRouteRequest } from './route-request.ts'
17
- import type { RouteDefinition } from './routing.ts'
19
+ import { isStatorQueryRoute, type RouteDefinition } from './routing.ts'
18
20
  import { getOrCreateSessionId } from './session.ts'
19
21
  import { withSessionLock } from './session-lock.ts'
20
22
  import { SessionRuntime } from './session-runtime.ts'
@@ -44,6 +46,8 @@ const eventSchema = z.object({
44
46
  type: z.string(),
45
47
  })
46
48
  .passthrough(),
49
+ /** Client-generated idempotency key — see server/event-dedupe.ts. */
50
+ eventId: z.string().min(1).max(128).optional(),
47
51
  })
48
52
 
49
53
  /** Compiled matcher: turns `/p/:id` into a regex that captures params. */
@@ -65,7 +69,14 @@ function compileMatcher(route: DiscoveredRoute): RouteMatcher {
65
69
  // Absorb the preceding `/` and match the (possibly empty) remainder.
66
70
  pattern = `${pattern.replace(/\/$/, '')}(?:/(.*))?`
67
71
  } else if (seg.startsWith(':')) {
68
- pattern += '([^/]+)'
72
+ // A param may carry a literal suffix (`:id.json`): the capture stops
73
+ // before the suffix, lazily, so `/p/a.b.json` yields id `a.b`.
74
+ const dot = seg.indexOf('.')
75
+ if (dot === -1) {
76
+ pattern += '([^/]+)'
77
+ } else {
78
+ pattern += `([^/]+?)${seg.slice(dot).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`
79
+ }
69
80
  if (i < parts.length - 1) pattern += '/'
70
81
  } else {
71
82
  pattern += seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
@@ -201,6 +212,9 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
201
212
  return c.text(`unknown route "${routeKey}"`, 404)
202
213
  }
203
214
  const route = matched.route.GET
215
+ if (isStatorQueryRoute(route)) {
216
+ return c.text(`route "${routeKey}" is a data route — data routes are not live`, 400)
217
+ }
204
218
  if (!route.live) {
205
219
  return c.text(`route "${routeKey}" is not declared live: true`, 400)
206
220
  }
@@ -292,6 +306,11 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
292
306
  return c.json({ error: `unknown route "${routeKey}"` }, 404)
293
307
  }
294
308
  const route = matched.route.GET
309
+ // A data route serves no HTML, so no page runtime ever posts under its
310
+ // key — a request that does is malformed or hand-crafted.
311
+ if (isStatorQueryRoute(route)) {
312
+ return c.json({ error: `route key "${routeKey}" targets a data route` }, 404)
313
+ }
295
314
  const request = {
296
315
  ...buildRouteRequest(c, matched.route.paramNames),
297
316
  params: matched.params,
@@ -321,6 +340,17 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
321
340
  }
322
341
 
323
342
  return withSessionLock(sessionId, async () => {
343
+ // Idempotent replay: a duplicate POST (client retry after a lost
344
+ // response) returns the original response body verbatim instead of
345
+ // re-applying — keyed-list patches are positional, not idempotent.
346
+ // Checked under the session lock so a retry racing its first attempt
347
+ // queues behind it and always sees the recorded body.
348
+ if (body.eventId) {
349
+ const cached = replayFor(sessionId, body.eventId)
350
+ if (cached !== undefined) {
351
+ return c.body(cached, 200, { 'Content-Type': 'application/json' })
352
+ }
353
+ }
324
354
  const runtime = new SessionRuntime(sessionId, config.store)
325
355
  try {
326
356
  await runtime.loadGraph([...route.reads, originDef])
@@ -358,7 +388,9 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
358
388
  // normal event path in server/effects.ts.
359
389
  scheduleSessionEffects(runtime, config.store, sessionId)
360
390
 
361
- return c.json({ patches, directives: [], committed: touched.size > 0 })
391
+ const envelope = { patches, directives: [], committed: touched.size > 0 }
392
+ if (body.eventId) record(sessionId, body.eventId, JSON.stringify(envelope))
393
+ return c.json(envelope)
362
394
  } finally {
363
395
  runtime.dispose()
364
396
  }
@@ -382,14 +414,13 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
382
414
  app.get('*', async (c, next) => {
383
415
  const matched = matchPath(matchers, c.req.path)
384
416
  if (!matched?.route.GET) return next()
385
- return handleGet(
386
- c,
387
- matched.route,
388
- matched.route.GET,
389
- matched.params,
390
- config.store,
391
- config.headExtras,
392
- )
417
+ const getRoute = matched.route.GET
418
+ // Brand decides the GET plane: data routes never touch the HTML path
419
+ // (no client-runtime injection, no live meta — a raw Response out).
420
+ if (isStatorQueryRoute(getRoute)) {
421
+ return runQueryRoute(c, matched.route, getRoute, config.store, matched.params)
422
+ }
423
+ return handleGet(c, matched.route, getRoute, matched.params, config.store, config.headExtras)
393
424
  })
394
425
 
395
426
  return app
@@ -39,6 +39,7 @@ export type { InstanceHandle } from './instance-proxy.ts'
39
39
  export { createInstanceProxy, defForProxy } from './instance-proxy.ts'
40
40
  export { logger, scopedLogger } from './logger.ts'
41
41
  export { MachineStore } from './machine-store.ts'
42
+ export { runQueryRoute } from './query-route.ts'
42
43
  export { recompute } from './recompute.ts'
43
44
  export { RedisAppStore, RedisStore } from './redis-store.ts'
44
45
  export type { RenderResult } from './render.ts'
@@ -75,8 +76,12 @@ export type {
75
76
  ApiRouteHelpers,
76
77
  ApiRouteResult,
77
78
  DefineApiRouteConfig,
79
+ DefineQueryRouteConfig,
78
80
  DefineRouteConfig,
79
81
  Directive,
82
+ QueryRouteDefinition,
83
+ QueryRouteHelpers,
84
+ QueryRouteResult,
80
85
  RouteContext,
81
86
  RouteCookieOptions,
82
87
  RouteDefinition,
@@ -88,6 +93,7 @@ export {
88
93
  defineApiRoute,
89
94
  defineRoute,
90
95
  isStatorApiRoute,
96
+ isStatorQueryRoute,
91
97
  isStatorRoute,
92
98
  } from './routing.ts'
93
99
  export { getOrCreateSessionId, SESSION_COOKIE } from './session.ts'
@@ -0,0 +1,147 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type { Context } from 'hono'
3
+ import { isResponseLike } from './api-route.ts'
4
+ import { scheduleSessionEffects } from './effects.ts'
5
+ import { scopedLogger } from './logger.ts'
6
+ import type { MachineStore } from './machine-store.ts'
7
+ import type { DiscoveredRoute } from './route-discovery.ts'
8
+ import { buildRouteRequest } from './route-request.ts'
9
+ import type { QueryRouteDefinition, QueryRouteResult, RouteContext } from './routing.ts'
10
+ import { getOrCreateSessionId } from './session.ts'
11
+ import { withSessionLock } from './session-lock.ts'
12
+ import { SessionRuntime } from './session-runtime.ts'
13
+
14
+ const queryLog = scopedLogger('query')
15
+
16
+ /** Default Content-Type by URL extension for data GET responses. The URL —
17
+ * not the file on disk — carries the extension (`rss.xml.ts` serves
18
+ * `/rss.xml`), so inference reads the request path. */
19
+ const DATA_CONTENT_TYPES: Record<string, string> = {
20
+ '.json': 'application/json; charset=utf-8',
21
+ '.xml': 'application/xml; charset=utf-8',
22
+ '.txt': 'text/plain; charset=utf-8',
23
+ '.ics': 'text/calendar; charset=utf-8',
24
+ '.csv': 'text/csv; charset=utf-8',
25
+ }
26
+
27
+ /** Second-level file extensions (`feed.xml.ts`) that mark a route file as
28
+ * unambiguously a data route — discovery hard-errors on malformed exports
29
+ * in one instead of skipping it as a utility. `.stator.ts` keeps its own,
30
+ * different second-level meaning and is not in this set. */
31
+ export const dataFileExtensions: ReadonlySet<string> = new Set(
32
+ Object.keys(DATA_CONTENT_TYPES).map((e) => e.slice(1)),
33
+ )
34
+
35
+ /**
36
+ * Run a data GET route: hydrate the reads graph, run the read-only handler,
37
+ * synthesize the response.
38
+ *
39
+ * Lock discipline: hydration happens under the session lock so the snapshot
40
+ * is coherent ACROSS machines (no half-committed view of a concurrent POST),
41
+ * and the lock is released before the handler runs — the handler cannot
42
+ * dispatch, so there is nothing to interleave with, and handler I/O never
43
+ * holds the lock.
44
+ */
45
+ export async function runQueryRoute(
46
+ c: Context,
47
+ discovered: DiscoveredRoute,
48
+ route: QueryRouteDefinition,
49
+ store: MachineStore,
50
+ /** Path params from the framework's own matcher (the GET catch-all
51
+ * bypasses Hono's per-route param extraction). */
52
+ params?: Record<string, string>,
53
+ ): Promise<Response> {
54
+ const { sessionId } = getOrCreateSessionId(c)
55
+ const request = params
56
+ ? { ...buildRouteRequest(c, discovered.paramNames), params }
57
+ : buildRouteRequest(c, discovered.paramNames)
58
+
59
+ const runtime = new SessionRuntime(sessionId, store)
60
+ try {
61
+ await withSessionLock(sessionId, () => runtime.loadGraph(route.reads))
62
+
63
+ // Same shape as a page's render context: proxies keyed by machine name.
64
+ // Session machines come from the hydrated runtime, app machines resolve
65
+ // through the long-lived app-instance fallback.
66
+ const machines: RouteContext = {}
67
+ for (const def of route.reads) {
68
+ const proxy = runtime.proxyFor(def.name)
69
+ if (!proxy) {
70
+ throw new Error(`stator: route reads "${def.name}" but it's not loaded into the runtime`)
71
+ }
72
+ machines[def.name] = proxy
73
+ }
74
+
75
+ let result: QueryRouteResult
76
+ try {
77
+ result = await route.handler(request, { machines })
78
+ } catch (err) {
79
+ queryLog.error({ err: String(err), path: c.req.path }, 'data route handler threw')
80
+ return c.text('Internal Server Error', 500)
81
+ }
82
+
83
+ // A fresh machine whose hydrate fired a load entry effect: persist so the
84
+ // next request restores instead of re-firing, and schedule the effect
85
+ // off-lock. The common query is a pure read and skips both (mirrors GET
86
+ // page handling).
87
+ const entryFired = runtime.entryFiredMachines()
88
+ if (entryFired.size > 0) {
89
+ await runtime.persistTouched(entryFired)
90
+ scheduleSessionEffects(runtime, store, sessionId)
91
+ }
92
+
93
+ return synthesizeDataResponse(c, result)
94
+ } finally {
95
+ runtime.dispose()
96
+ }
97
+ }
98
+
99
+ /** The URL's extension (`/rss.xml` → `.xml`). A leading dot alone
100
+ * (`/.well-known`) is not an extension. */
101
+ function urlExtension(path: string): string | undefined {
102
+ const last = path.slice(path.lastIndexOf('/') + 1)
103
+ const dot = last.lastIndexOf('.')
104
+ return dot > 0 ? last.slice(dot).toLowerCase() : undefined
105
+ }
106
+
107
+ /**
108
+ * Response synthesis for data GET results:
109
+ * - Response-like → passthrough, Content-Type filled from the URL extension
110
+ * only when the handler set none.
111
+ * - string → body as-is, Content-Type from the URL extension
112
+ * (`text/plain` fallback).
113
+ * - anything else → JSON, always — a plain value is data, whatever the URL
114
+ * looks like.
115
+ *
116
+ * Synthesized responses carry a strong body-hash ETag and answer
117
+ * `If-None-Match` with 304, so polling consumers stop paying for unchanged
118
+ * bodies. (The designed upgrade — 304 from the machine-revision ledger
119
+ * WITHOUT invoking the handler — rides the same header contract.)
120
+ */
121
+ function synthesizeDataResponse(c: Context, result: QueryRouteResult): Response {
122
+ const ext = urlExtension(c.req.path)
123
+ const inferred = ext ? DATA_CONTENT_TYPES[ext] : undefined
124
+
125
+ if (isResponseLike(result)) {
126
+ if (inferred && !result.headers.get('content-type')) {
127
+ try {
128
+ result.headers.set('content-type', inferred)
129
+ } catch {
130
+ // Immutable headers (e.g. a proxied response) — the handler's call.
131
+ }
132
+ }
133
+ return result
134
+ }
135
+
136
+ const isString = typeof result === 'string'
137
+ const body = isString ? result : (JSON.stringify(result) ?? 'null')
138
+ const contentType = isString
139
+ ? (inferred ?? 'text/plain; charset=utf-8')
140
+ : 'application/json; charset=utf-8'
141
+
142
+ const etag = `"${createHash('sha1').update(body).digest('base64url')}"`
143
+ if ((c.req.header('if-none-match') ?? '').includes(etag)) {
144
+ return new Response(null, { status: 304, headers: { ETag: etag } })
145
+ }
146
+ return new Response(body, { headers: { 'Content-Type': contentType, ETag: etag } })
147
+ }
@@ -2,18 +2,21 @@ import { readdir } from 'node:fs/promises'
2
2
  import { basename, dirname, extname, relative, resolve, sep } from 'node:path'
3
3
  import { pathToFileURL } from 'node:url'
4
4
  import type { ModuleLoader } from './discovery.ts'
5
+ import { dataFileExtensions } from './query-route.ts'
5
6
  import {
6
7
  type ApiRouteDefinition,
7
8
  isStatorApiRoute,
9
+ isStatorQueryRoute,
8
10
  isStatorRoute,
11
+ type QueryRouteDefinition,
9
12
  type RouteDefinition,
10
13
  } from './routing.ts'
11
14
 
12
15
  const nativeLoader: ModuleLoader = (file) => import(/* @vite-ignore */ pathToFileURL(file).href)
13
16
 
14
17
  /** HTTP methods a route file may export. GET goes through `defineRoute`
15
- * (page rendering); the rest go through `defineApiRoute` (mutation/API
16
- * handlers). */
18
+ * (page rendering) or `defineApiRoute({ method: 'GET' })` (data route);
19
+ * the rest go through `defineApiRoute` (command handlers). */
17
20
  export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const
18
21
  export type HttpMethod = (typeof HTTP_METHODS)[number]
19
22
 
@@ -24,8 +27,9 @@ export interface DiscoveredRoute {
24
27
  * Empty for static routes. */
25
28
  paramNames: string[]
26
29
  filePath: string
27
- /** GET route (page render). At most one. */
28
- GET?: RouteDefinition
30
+ /** GET route: a page (defineRoute) or a data route (defineApiRoute with
31
+ * method: 'GET'), discriminated by brand. At most one. */
32
+ GET?: RouteDefinition | QueryRouteDefinition
29
33
  /** API routes by method. */
30
34
  POST?: ApiRouteDefinition
31
35
  PUT?: ApiRouteDefinition
@@ -43,12 +47,15 @@ export interface DiscoveredRoute {
43
47
  * - `routes/[a]/[b].ts` → `/:a/:b`
44
48
  *
45
49
  * Files may export any combination of `GET`/`POST`/`PUT`/`PATCH`/`DELETE`.
46
- * GET is a `defineRoute` (page renderer); the others are `defineApiRoute`.
50
+ * GET is a `defineRoute` (page renderer) or a `defineApiRoute` declaring
51
+ * `method: 'GET'` (data route); the others are `defineApiRoute` commands.
47
52
  *
48
- * Files that don't export anything route-shaped are silently skipped. With
49
- * recursive walking, the routes tree often contains utility files
50
- * (templates, helpers) that aren't routes, and throwing on them would
51
- * force separate trees.
53
+ * Files that don't export anything under an HTTP-method name are silently
54
+ * skipped. With recursive walking, the routes tree often contains utility
55
+ * files (templates, helpers) that aren't routes, and throwing on them would
56
+ * force separate trees. A file that DOES export an HTTP-method name but with
57
+ * the wrong constructor is a hard error, never a skip — a skip here surfaces
58
+ * as an unexplained 404 and a dev banner counting one route short.
52
59
  */
53
60
  export async function discoverRoutes(
54
61
  dir: string,
@@ -61,25 +68,39 @@ export async function discoverRoutes(
61
68
  for (const filePath of files) {
62
69
  const mod = await load(filePath)
63
70
 
64
- // GET is a page route, POST/PUT/PATCH/DELETE are API routes.
65
- const get = isStatorRoute(mod.GET) ? (mod.GET as RouteDefinition) : undefined
71
+ // GET is a page (defineRoute) or a data route (defineApiRoute with
72
+ // method: 'GET'); POST/PUT/PATCH/DELETE are command API routes.
73
+ const get =
74
+ isStatorRoute(mod.GET) || isStatorQueryRoute(mod.GET)
75
+ ? (mod.GET as RouteDefinition | QueryRouteDefinition)
76
+ : undefined
66
77
  const post = isStatorApiRoute(mod.POST) ? (mod.POST as ApiRouteDefinition) : undefined
67
78
  const put = isStatorApiRoute(mod.PUT) ? (mod.PUT as ApiRouteDefinition) : undefined
68
79
  const patch = isStatorApiRoute(mod.PATCH) ? (mod.PATCH as ApiRouteDefinition) : undefined
69
80
  const del = isStatorApiRoute(mod.DELETE) ? (mod.DELETE as ApiRouteDefinition) : undefined
70
81
 
71
- if (!get && !post && !put && !patch && !del) continue
72
-
73
82
  // Catch the easy mistake: GET defined with `defineApiRoute`, or a
74
83
  // mutation method defined with `defineRoute`. Throw with a clear hint.
84
+ // These run BEFORE the not-a-route skip below: a file whose only export
85
+ // is a mis-constructed method name must error, not vanish as a utility.
75
86
  if (mod.GET && !get) {
76
87
  throw new Error(
77
- `stator: ${filePath} exports GET but it is not a defineRoute. ` +
78
- `GET handlers must be created with defineRoute(); use defineApiRoute() for POST/PUT/PATCH/DELETE.`,
88
+ isStatorApiRoute(mod.GET)
89
+ ? `stator: ${filePath} exports GET from defineApiRoute() without method: 'GET'. ` +
90
+ `A GET is a page (defineRoute) or a data route (defineApiRoute({ method: 'GET', … })).`
91
+ : `stator: ${filePath} exports GET but it is neither a defineRoute (page) nor a ` +
92
+ `defineApiRoute({ method: 'GET' }) (data route).`,
79
93
  )
80
94
  }
81
95
  for (const m of ['POST', 'PUT', 'PATCH', 'DELETE'] as const) {
82
- if (mod[m] && !isStatorApiRoute(mod[m])) {
96
+ if (!mod[m]) continue
97
+ if (isStatorQueryRoute(mod[m])) {
98
+ throw new Error(
99
+ `stator: ${filePath} exports ${m} created with method: 'GET' — the declared method ` +
100
+ `must match the export name. Data routes are GET-only.`,
101
+ )
102
+ }
103
+ if (!isStatorApiRoute(mod[m])) {
83
104
  throw new Error(
84
105
  `stator: ${filePath} exports ${m} but it is not a defineApiRoute. ` +
85
106
  `${m} handlers must be created with defineApiRoute(); defineRoute() is GET-only.`,
@@ -87,6 +108,19 @@ export async function discoverRoutes(
87
108
  }
88
109
  }
89
110
 
111
+ if (!get && !post && !put && !patch && !del) {
112
+ // An extension-named file (feed.xml.ts) is unambiguously a route file —
113
+ // exporting nothing route-shaped from one is a mistake, not a utility.
114
+ const secondExt = extname(basename(filePath, extname(filePath))).slice(1)
115
+ if (dataFileExtensions.has(secondExt)) {
116
+ throw new Error(
117
+ `stator: ${filePath} is named like a data route (.${secondExt}${extname(filePath)}) ` +
118
+ `but exports no route. Export GET = defineApiRoute({ method: 'GET', … }) or rename the file.`,
119
+ )
120
+ }
121
+ continue
122
+ }
123
+
90
124
  const { urlPath, paramNames } = filePathToRoute(absDir, filePath)
91
125
  routes.push({
92
126
  urlPath,
@@ -134,7 +168,9 @@ function mergeByUrlPath(routes: DiscoveredRoute[]): DiscoveredRoute[] {
134
168
  * Sort routes most-specific-first (Astro's model). At match time the first
135
169
  * matcher that matches wins, so order encodes priority:
136
170
  * - routes without a rest/catch-all segment rank before those with one
137
- * - per segment, left to right: static (0) > named param (1) > rest (2)
171
+ * - per segment, left to right: static (0) > suffixed param `:id.json` (1)
172
+ * > bare param (2) > rest (3) — a suffixed param outranks a bare one so
173
+ * `/p/:id.json` wins `/p/abc.json` before `/p/:id` can swallow it
138
174
  * - more segments before fewer
139
175
  * - ties alphabetically by urlPath
140
176
  */
@@ -143,13 +179,15 @@ export function sortRoutes(routes: DiscoveredRoute[]): DiscoveredRoute[] {
143
179
  urlPath
144
180
  .split('/')
145
181
  .filter(Boolean)
146
- .map((seg) => (seg.startsWith('*') ? 2 : seg.startsWith(':') ? 1 : 0))
182
+ .map((seg) =>
183
+ seg.startsWith('*') ? 3 : seg.startsWith(':') ? (seg.includes('.') ? 1 : 2) : 0,
184
+ )
147
185
 
148
186
  return [...routes].sort((a, b) => {
149
187
  const ka = kinds(a.urlPath)
150
188
  const kb = kinds(b.urlPath)
151
- const aRest = ka.includes(2)
152
- const bRest = kb.includes(2)
189
+ const aRest = ka.includes(3)
190
+ const bRest = kb.includes(3)
153
191
  if (aRest !== bRest) return aRest ? 1 : -1 // no-rest first
154
192
  const len = Math.min(ka.length, kb.length)
155
193
  for (let i = 0; i < len; i++) {
@@ -213,11 +251,21 @@ export function filePathToRoute(
213
251
  paramNames.push(name)
214
252
  return `*${name}`
215
253
  }
216
- const m = seg.match(/^\[(.+)\]$/)
254
+ // Param, optionally with a literal extension suffix: `[id].json` →
255
+ // `:id.json` (param `id`; the URL literally ends in `.json`). This is the
256
+ // data-route extension convention composing with dynamic segments — the
257
+ // suffix stays out of the captured value at match time.
258
+ const m = seg.match(/^\[([^\]]+)\](\.[\w.]+)?$/)
217
259
  if (m) {
218
260
  const name = m[1]!
261
+ if (name.startsWith('...')) {
262
+ throw new Error(
263
+ `stator: rest segment [${name}]${m[2] ?? ''} cannot carry an extension suffix — ` +
264
+ `a rest param spans whole segments.`,
265
+ )
266
+ }
219
267
  paramNames.push(name)
220
- return `:${name}`
268
+ return `:${name}${m[2] ?? ''}`
221
269
  }
222
270
  return seg
223
271
  })
@@ -167,13 +167,29 @@ export interface DefineApiRouteConfig<TReads extends ReadonlyArray<AnyMachineDef
167
167
  ) => Promise<ApiRouteResult> | ApiRouteResult
168
168
  }
169
169
 
170
+ /** `method: 'GET'` discriminates: with it, the handler is a read-only data
171
+ * route typed with `{ machines }`; without it, a command route typed with
172
+ * `{ dispatch, rotateSession }` — exactly as before. */
173
+ export function defineApiRoute<TReads extends ReadonlyArray<AnyMachineDef>>(
174
+ config: DefineQueryRouteConfig<TReads>,
175
+ ): QueryRouteDefinition
170
176
  export function defineApiRoute<TReads extends ReadonlyArray<AnyMachineDef>>(
171
177
  config: DefineApiRouteConfig<TReads>,
172
- ): ApiRouteDefinition {
178
+ ): ApiRouteDefinition
179
+ export function defineApiRoute<TReads extends ReadonlyArray<AnyMachineDef>>(
180
+ config: DefineQueryRouteConfig<TReads> | DefineApiRouteConfig<TReads>,
181
+ ): QueryRouteDefinition | ApiRouteDefinition {
182
+ if ('method' in config && config.method === 'GET') {
183
+ return {
184
+ __isStatorQueryRoute: true,
185
+ reads: config.reads ? [...config.reads] : [],
186
+ handler: config.handler,
187
+ }
188
+ }
173
189
  return {
174
190
  __isStatorApiRoute: true,
175
191
  reads: config.reads ? [...config.reads] : [],
176
- handler: config.handler,
192
+ handler: (config as DefineApiRouteConfig<TReads>).handler,
177
193
  }
178
194
  }
179
195
 
@@ -184,3 +200,52 @@ export function isStatorApiRoute(v: unknown): v is ApiRouteDefinition {
184
200
  (v as Record<string, unknown>).__isStatorApiRoute === true
185
201
  )
186
202
  }
203
+
204
+ /* ------------------------------------------------------------------ */
205
+ /* Data GET routes (defineApiRoute with method: 'GET') */
206
+ /* ------------------------------------------------------------------ */
207
+
208
+ /** Helpers available inside a data GET handler. Read-only by construction:
209
+ * there is no `dispatch` here, and that structural absence is what makes
210
+ * handler reads safe — a handler that cannot dispatch has nothing to
211
+ * interleave with effect completions or other sessions' commits. */
212
+ export interface QueryRouteHelpers {
213
+ /** Read proxies keyed by machine name — the same shape a page's render
214
+ * context uses (selector + context reads; no send). */
215
+ machines: RouteContext
216
+ }
217
+
218
+ /** What a data GET handler may return: a raw `Response` (passed through
219
+ * verbatim, `Content-Type` filled from the URL extension only when the
220
+ * handler set none), a string (`Content-Type` from the URL extension,
221
+ * `text/plain` fallback), or any JSON-serializable value (always
222
+ * `application/json`). */
223
+ export type QueryRouteResult = Response | string | object
224
+
225
+ export interface QueryRouteDefinition {
226
+ readonly __isStatorQueryRoute: true
227
+ reads: AnyMachineDef[]
228
+ handler: (
229
+ request: RouteRequest,
230
+ helpers: QueryRouteHelpers,
231
+ ) => Promise<QueryRouteResult> | QueryRouteResult
232
+ }
233
+
234
+ export interface DefineQueryRouteConfig<TReads extends ReadonlyArray<AnyMachineDef>> {
235
+ /** The capability discriminant: `'GET'` declares a read-only data route.
236
+ * Discovery cross-checks it against the export name. */
237
+ method: 'GET'
238
+ reads?: TReads
239
+ handler: (
240
+ request: RouteRequest,
241
+ helpers: QueryRouteHelpers,
242
+ ) => Promise<QueryRouteResult> | QueryRouteResult
243
+ }
244
+
245
+ export function isStatorQueryRoute(v: unknown): v is QueryRouteDefinition {
246
+ return (
247
+ typeof v === 'object' &&
248
+ v !== null &&
249
+ (v as Record<string, unknown>).__isStatorQueryRoute === true
250
+ )
251
+ }