@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 +2 -2
- package/src/client/client-id.ts +9 -0
- package/src/client/dispatch.ts +11 -22
- package/src/client/index.ts +2 -0
- package/src/client/inspector.ts +15 -0
- package/src/client/runtime.ts +129 -77
- package/src/client/transport.ts +110 -0
- package/src/engine/index.ts +2 -0
- package/src/server/api-route.ts +25 -1
- package/src/server/create-app.ts +12 -2
- package/src/server/dev.ts +29 -0
- package/src/server/event-dedupe.ts +54 -0
- package/src/server/http.ts +42 -11
- package/src/server/index.ts +6 -0
- package/src/server/query-route.ts +147 -0
- package/src/server/route-discovery.ts +70 -22
- package/src/server/routing.ts +67 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
91
|
+
"@statorjs/stator": "1.6.1"
|
|
92
92
|
},
|
|
93
93
|
"scripts": {
|
|
94
94
|
"typecheck": "tsc --noEmit",
|
package/src/client/client-id.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/client/dispatch.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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 {
|
package/src/client/index.ts
CHANGED
|
@@ -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'
|
package/src/client/inspector.ts
CHANGED
|
@@ -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` : ''
|
package/src/client/runtime.ts
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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(
|
|
175
|
-
machine: string
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
242
|
+
if (el) beginPending(el)
|
|
189
243
|
try {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
260
|
+
beginPending(form)
|
|
221
261
|
try {
|
|
222
|
-
res
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
+
}
|
package/src/engine/index.ts
CHANGED
|
@@ -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,
|
package/src/server/api-route.ts
CHANGED
|
@@ -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
|
|
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 '/'. */
|
package/src/server/create-app.ts
CHANGED
|
@@ -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
|
|
42
|
-
* server-originated events
|
|
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
|
+
}
|
package/src/server/http.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
matched.params
|
|
390
|
-
|
|
391
|
-
|
|
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
|
package/src/server/index.ts
CHANGED
|
@@ -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)
|
|
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
|
|
28
|
-
GET
|
|
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)
|
|
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
|
|
49
|
-
* recursive walking, the routes tree often contains utility
|
|
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
|
|
65
|
-
|
|
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
|
-
|
|
78
|
-
`
|
|
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 (
|
|
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) >
|
|
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) =>
|
|
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(
|
|
152
|
-
const bRest = kb.includes(
|
|
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
|
-
|
|
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
|
})
|
package/src/server/routing.ts
CHANGED
|
@@ -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
|
+
}
|