@statorjs/stator 1.5.1 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/client/client-id.ts +9 -0
- package/src/client/dispatch.ts +11 -22
- package/src/client/inspector.ts +15 -0
- package/src/client/runtime.ts +131 -59
- package/src/client/transport.ts +110 -0
- package/src/server/create-app.ts +3 -0
- package/src/server/event-dedupe.ts +54 -0
- package/src/server/http.ts +28 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Server-canonical web framework: business logic in composable state machines, UI as a thin renderer binding machine outputs to DOM positions. Ships TypeScript source (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.0"
|
|
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/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,25 +67,30 @@ 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
|
-
|
|
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() })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let sse: EventSource | null = null
|
|
90
|
+
let lastSeen = Date.now()
|
|
62
91
|
|
|
63
|
-
|
|
92
|
+
const onMessage = (e: MessageEvent): void => {
|
|
93
|
+
lastSeen = Date.now()
|
|
64
94
|
let data: WireEnvelope
|
|
65
95
|
try {
|
|
66
96
|
data = JSON.parse(e.data)
|
|
@@ -83,13 +113,57 @@ function initLiveChannel(): void {
|
|
|
83
113
|
if (data.directives && data.directives.length > 0) {
|
|
84
114
|
applyDirectives(data.directives)
|
|
85
115
|
}
|
|
86
|
-
}
|
|
116
|
+
}
|
|
87
117
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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()
|
|
91
153
|
}
|
|
92
|
-
}
|
|
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
|
+
}
|
|
93
167
|
}
|
|
94
168
|
|
|
95
169
|
function handleEvent(e: Event): void {
|
|
@@ -111,7 +185,7 @@ function handleEvent(e: Event): void {
|
|
|
111
185
|
console.error('stator: malformed event descriptor on form', form, descriptorAttr)
|
|
112
186
|
return
|
|
113
187
|
}
|
|
114
|
-
void dispatchEvent(descriptor)
|
|
188
|
+
void dispatchEvent(descriptor, form)
|
|
115
189
|
return
|
|
116
190
|
}
|
|
117
191
|
// Opt-in interception via `data-stator-enhance`. Plain forms without
|
|
@@ -148,13 +222,13 @@ function handleEvent(e: Event): void {
|
|
|
148
222
|
return
|
|
149
223
|
}
|
|
150
224
|
|
|
151
|
-
void dispatchEvent(descriptor)
|
|
225
|
+
void dispatchEvent(descriptor, el)
|
|
152
226
|
}
|
|
153
227
|
|
|
154
|
-
async function dispatchEvent(
|
|
155
|
-
machine: string
|
|
156
|
-
|
|
157
|
-
|
|
228
|
+
async function dispatchEvent(
|
|
229
|
+
descriptor: { machine: string; event: { type: string } },
|
|
230
|
+
el?: Element,
|
|
231
|
+
): Promise<void> {
|
|
158
232
|
const routeKey = `GET ${location.pathname}${location.search}`
|
|
159
233
|
|
|
160
234
|
emit('stator:event-sent', {
|
|
@@ -165,28 +239,14 @@ async function dispatchEvent(descriptor: {
|
|
|
165
239
|
})
|
|
166
240
|
|
|
167
241
|
const startedAt = performance.now()
|
|
168
|
-
|
|
242
|
+
if (el) beginPending(el)
|
|
169
243
|
try {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
'X-Stator-Route': routeKey,
|
|
176
|
-
'X-Stator-Client': clientId,
|
|
177
|
-
},
|
|
178
|
-
credentials: 'same-origin',
|
|
179
|
-
body: JSON.stringify(descriptor),
|
|
180
|
-
})
|
|
181
|
-
} catch (err) {
|
|
182
|
-
console.error('stator: network error during dispatch', err)
|
|
183
|
-
return
|
|
184
|
-
}
|
|
185
|
-
if (!res.ok) {
|
|
186
|
-
console.error('stator: event POST failed', res.status, await res.text())
|
|
187
|
-
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)
|
|
188
249
|
}
|
|
189
|
-
await applyEnvelopeFromResponse(res, startedAt, 'post')
|
|
190
250
|
}
|
|
191
251
|
|
|
192
252
|
/**
|
|
@@ -197,23 +257,30 @@ async function dispatchEvent(descriptor: {
|
|
|
197
257
|
async function submitForm(form: HTMLFormElement): Promise<void> {
|
|
198
258
|
const formData = new FormData(form)
|
|
199
259
|
const startedAt = performance.now()
|
|
200
|
-
|
|
260
|
+
beginPending(form)
|
|
201
261
|
try {
|
|
202
|
-
res
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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)
|
|
215
283
|
}
|
|
216
|
-
await applyEnvelopeFromResponse(res, startedAt, 'post')
|
|
217
284
|
}
|
|
218
285
|
|
|
219
286
|
async function applyEnvelopeFromResponse(
|
|
@@ -248,3 +315,8 @@ if (document.readyState === 'loading') {
|
|
|
248
315
|
} else {
|
|
249
316
|
init()
|
|
250
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/server/create-app.ts
CHANGED
|
@@ -24,6 +24,8 @@ export interface CreateAppConfig {
|
|
|
24
24
|
/** Per-session TTL in seconds. Every set to any of the session's
|
|
25
25
|
* machines refreshes this expiry. Defaults to 24h (86400). */
|
|
26
26
|
sessionTtlSeconds?: number
|
|
27
|
+
/** SSE heartbeat interval in ms (default 25s). Tests shorten it. */
|
|
28
|
+
ssePingMs?: number
|
|
27
29
|
/** Extra `<head>` HTML per GET route. A production build uses this to link the
|
|
28
30
|
* prebuilt `components.css`; ignored if omitted. */
|
|
29
31
|
headExtras?: (filePath: string) => string | Promise<string>
|
|
@@ -69,6 +71,7 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
|
|
|
69
71
|
}
|
|
70
72
|
: config.headExtras,
|
|
71
73
|
inspector: config.inspector,
|
|
74
|
+
ssePingMs: config.ssePingMs,
|
|
72
75
|
})
|
|
73
76
|
|
|
74
77
|
return {
|
|
@@ -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,6 +8,7 @@ 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'
|
|
13
14
|
import { initialSyncPatches, recompute } from './recompute.ts'
|
|
@@ -33,6 +34,8 @@ export interface HttpConfig {
|
|
|
33
34
|
/** Serve the dev inspector asset at `/@stator/inspector.js`. The dev server
|
|
34
35
|
* sets this and injects the script tag; production leaves it off. */
|
|
35
36
|
inspector?: boolean
|
|
37
|
+
/** SSE heartbeat interval in ms (default 25s). Tests shorten it. */
|
|
38
|
+
ssePingMs?: number
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
const eventSchema = z.object({
|
|
@@ -42,6 +45,8 @@ const eventSchema = z.object({
|
|
|
42
45
|
type: z.string(),
|
|
43
46
|
})
|
|
44
47
|
.passthrough(),
|
|
48
|
+
/** Client-generated idempotency key — see server/event-dedupe.ts. */
|
|
49
|
+
eventId: z.string().min(1).max(128).optional(),
|
|
45
50
|
})
|
|
46
51
|
|
|
47
52
|
/** Compiled matcher: turns `/p/:id` into a regex that captures params. */
|
|
@@ -249,13 +254,17 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
|
|
|
249
254
|
await conn.send(JSON.stringify({ patches: sync }))
|
|
250
255
|
}
|
|
251
256
|
|
|
252
|
-
//
|
|
253
|
-
//
|
|
257
|
+
// Heartbeat every 25s. This is a real DATA message, not a comment, on
|
|
258
|
+
// purpose: SSE comments keep proxies from reaping the connection but
|
|
259
|
+
// are invisible to the browser's EventSource API — a client can't
|
|
260
|
+
// detect a half-open zombie (device sleep, silent NAT drop: no FIN
|
|
261
|
+
// ever arrives, no error fires) unless liveness is OBSERVABLE. The
|
|
262
|
+
// runtime tracks last-message time and reconnects when pings stop.
|
|
254
263
|
const keepAlive = setInterval(() => {
|
|
255
|
-
|
|
256
|
-
// Stream closed;
|
|
264
|
+
void conn.send('{"ping":true}').catch(() => {
|
|
265
|
+
// Stream closed; the abort handler cleans up.
|
|
257
266
|
})
|
|
258
|
-
}, 25_000)
|
|
267
|
+
}, config.ssePingMs ?? 25_000)
|
|
259
268
|
|
|
260
269
|
try {
|
|
261
270
|
await new Promise<void>((resolveFn) => {
|
|
@@ -315,6 +324,17 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
|
|
|
315
324
|
}
|
|
316
325
|
|
|
317
326
|
return withSessionLock(sessionId, async () => {
|
|
327
|
+
// Idempotent replay: a duplicate POST (client retry after a lost
|
|
328
|
+
// response) returns the original response body verbatim instead of
|
|
329
|
+
// re-applying — keyed-list patches are positional, not idempotent.
|
|
330
|
+
// Checked under the session lock so a retry racing its first attempt
|
|
331
|
+
// queues behind it and always sees the recorded body.
|
|
332
|
+
if (body.eventId) {
|
|
333
|
+
const cached = replayFor(sessionId, body.eventId)
|
|
334
|
+
if (cached !== undefined) {
|
|
335
|
+
return c.body(cached, 200, { 'Content-Type': 'application/json' })
|
|
336
|
+
}
|
|
337
|
+
}
|
|
318
338
|
const runtime = new SessionRuntime(sessionId, config.store)
|
|
319
339
|
try {
|
|
320
340
|
await runtime.loadGraph([...route.reads, originDef])
|
|
@@ -352,7 +372,9 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
|
|
|
352
372
|
// normal event path in server/effects.ts.
|
|
353
373
|
scheduleSessionEffects(runtime, config.store, sessionId)
|
|
354
374
|
|
|
355
|
-
|
|
375
|
+
const envelope = { patches, directives: [], committed: touched.size > 0 }
|
|
376
|
+
if (body.eventId) record(sessionId, body.eventId, JSON.stringify(envelope))
|
|
377
|
+
return c.json(envelope)
|
|
356
378
|
} finally {
|
|
357
379
|
runtime.dispose()
|
|
358
380
|
}
|