@statorjs/stator 1.5.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statorjs/stator",
3
- "version": "1.5.2",
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.5.2"
91
+ "@statorjs/stator": "1.6.0"
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 {
@@ -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
+ }
@@ -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,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'
@@ -44,6 +45,8 @@ const eventSchema = z.object({
44
45
  type: z.string(),
45
46
  })
46
47
  .passthrough(),
48
+ /** Client-generated idempotency key — see server/event-dedupe.ts. */
49
+ eventId: z.string().min(1).max(128).optional(),
47
50
  })
48
51
 
49
52
  /** Compiled matcher: turns `/p/:id` into a regex that captures params. */
@@ -321,6 +324,17 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
321
324
  }
322
325
 
323
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
+ }
324
338
  const runtime = new SessionRuntime(sessionId, config.store)
325
339
  try {
326
340
  await runtime.loadGraph([...route.reads, originDef])
@@ -358,7 +372,9 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
358
372
  // normal event path in server/effects.ts.
359
373
  scheduleSessionEffects(runtime, config.store, sessionId)
360
374
 
361
- return c.json({ patches, directives: [], committed: touched.size > 0 })
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)
362
378
  } finally {
363
379
  runtime.dispose()
364
380
  }