@kinocode/dsh-mobile-bridge 0.0.0-stage → 0.3.2

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.
@@ -0,0 +1,226 @@
1
+ // Control API served to registered phones, directly on the gateway or through the relay tunnel
2
+ // (docs/PROTOCOL.md v2 §2-§3).
3
+
4
+ import { AuthError, parseDevicePublicKey, verifySignedRequest } from './auth.mjs'
5
+ import { describe, validateAnswer } from './preview.mjs'
6
+ import { summaryTitle } from './sessions.mjs'
7
+ import { MAX_WATCHES_PER_DEVICE } from './state.mjs'
8
+
9
+ const MAX_BODY_BYTES = 64 * 1024
10
+ const PATH = /^\/v1\/[a-z0-9/-]{1,64}$/
11
+ const FCM_TOKEN = /^[A-Za-z0-9_:\-.]{20,4096}$/
12
+ const SESSION_ID = /^[A-Za-z0-9_:.-]{1,200}$/
13
+
14
+ /**
15
+ * @typedef {object} ControlDeps
16
+ * @property {import('./state.mjs').StateStore} state
17
+ * @property {import('./auth.mjs').NonceCache} nonces
18
+ * @property {import('./auth.mjs').RegistrationCodes} codes
19
+ * @property {import('./gateway-client.mjs').GatewayClient} gateway
20
+ * @property {import('./sessions.mjs').SessionTracker} sessions
21
+ * @property {import('./web-sessions.mjs').WebSessions} webSessions
22
+ * @property {() => string[]} origins - gateway origins
23
+ * @property {string} relayUrl
24
+ * @property {() => Promise<Array<{sessionId: string, running: boolean, updatedAt: number, cwd?: string, parentSessionId?: string, origin?: string, projections?: {values?: Record<string, any>}}>>} listSessions
25
+ * @property {(eventId: string, deviceId: string) => void} [onAnswered]
26
+ * @property {string} hostLabel
27
+ * @property {string} version
28
+ * @property {number} previewMaxChars
29
+ * @property {() => number} [now]
30
+ * @property {(...args: unknown[]) => void} log
31
+ */
32
+
33
+ /**
34
+ * @param {ControlDeps} deps
35
+ * @returns {{handle: (request: {method: string, path: string, headers: Record<string, string>, body: string, direct?: boolean}) => Promise<{status: number, body: unknown}>, pair: (body: string) => Promise<{status: number, body: unknown}>}}
36
+ */
37
+ export function createControlHandler(deps) {
38
+ const now = deps.now ?? Date.now
39
+
40
+ async function handle(request) {
41
+ try {
42
+ const method = String(request.method).toUpperCase()
43
+ const path = String(request.path)
44
+ if (!PATH.test(path)) throw new AuthError(404, 'not_found')
45
+ if (Buffer.byteLength(request.body ?? '', 'utf8') > MAX_BODY_BYTES) throw new AuthError(413, 'payload_too_large')
46
+ const headers = lowerCaseHeaders(request.headers)
47
+
48
+ const deviceId = verifySignedRequest({
49
+ method,
50
+ path,
51
+ headers,
52
+ body: request.body ?? '',
53
+ hostId: deps.state.hostId,
54
+ lookup: id => deps.state.device(id),
55
+ nonces: deps.nonces,
56
+ now,
57
+ })
58
+ deps.state.updateDevice(deviceId, (record) => { record.lastSeenAt = now() })
59
+
60
+ if (method === 'POST' && path === '/v1/session') {
61
+ // WebView sessions are only handed out on the gateway itself, never through the relay.
62
+ if (request.direct !== true) throw new AuthError(404, 'not_found')
63
+ return ok(deps.webSessions.create(deviceId))
64
+ }
65
+ if (method === 'GET' && path === '/v1/status') return ok(status(deviceId))
66
+ if (method === 'GET' && path === '/v1/pending') return ok({ items: deps.gateway.list().map(p => describe(p, deps.sessions, deps.previewMaxChars)) })
67
+ if (method === 'POST' && path === '/v1/answer') return await answer(deviceId, parseJson(request.body))
68
+ if (method === 'POST' && path === '/v1/watch') return watch(deviceId, parseJson(request.body))
69
+ if (method === 'POST' && path === '/v1/settings') return settings(deviceId, parseJson(request.body))
70
+ if (method === 'GET' && path === '/v1/sessions') return ok({ items: await sessionList() })
71
+ if (method === 'POST' && path === '/v1/unregister') {
72
+ deps.state.removeDevice(deviceId)
73
+ deps.webSessions.revokeDevice(deviceId)
74
+ return ok({ ok: true })
75
+ }
76
+ throw new AuthError(404, 'not_found')
77
+ } catch (error) {
78
+ if (error instanceof AuthError) return { status: error.status, body: { error: error.code } }
79
+ if (error?.code === 'invalid_value') return { status: 400, body: { error: 'invalid_value', message: error.message } }
80
+ deps.log('control request failed:', error?.message ?? String(error))
81
+ return { status: 500, body: { error: 'internal_error' } }
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Unsigned pairing on the gateway: a one-time code from the desktop QR plus the device key.
87
+ * @param {string} text
88
+ */
89
+ async function pair(text) {
90
+ try {
91
+ const body = parseJson(text)
92
+ deps.codes.consume(body.code)
93
+ parseDevicePublicKey(body.publicKey)
94
+ const fcmToken = body.fcmToken === undefined || body.fcmToken === null ? null : String(body.fcmToken)
95
+ if (fcmToken !== null && !FCM_TOKEN.test(fcmToken)) throw new AuthError(400, 'invalid_request')
96
+ const deviceId = deps.state.addDevice({
97
+ publicKey: body.publicKey,
98
+ label: typeof body.label === 'string' ? body.label.slice(0, 80) : '',
99
+ fcmToken,
100
+ previewMode: body.previewMode === 'full' ? 'full' : 'redacted',
101
+ })
102
+ deps.log('device paired', JSON.stringify({ deviceId }))
103
+ return ok({ deviceId, hostId: deps.state.hostId, hostLabel: deps.hostLabel, relayUrl: deps.relayUrl, origins: deps.origins() })
104
+ } catch (error) {
105
+ if (error instanceof AuthError) return { status: error.status, body: { error: error.code } }
106
+ deps.log('pairing failed:', error?.message ?? String(error))
107
+ return { status: 500, body: { error: 'internal_error' } }
108
+ }
109
+ }
110
+
111
+ function status(deviceId) {
112
+ const device = deps.state.device(deviceId)
113
+ return {
114
+ hostId: deps.state.hostId,
115
+ hostLabel: deps.hostLabel,
116
+ deviceId,
117
+ previewMode: device.previewMode,
118
+ pushEnabled: device.pushEnabled,
119
+ watchAll: device.watchAll,
120
+ pluginVersion: deps.version,
121
+ gatewayReady: deps.gateway.ready,
122
+ relayUrl: deps.relayUrl,
123
+ origins: deps.origins(),
124
+ }
125
+ }
126
+
127
+ async function answer(deviceId, body) {
128
+ if (typeof body.eventId !== 'string' || body.eventId.length > 100) throw new AuthError(400, 'invalid_request')
129
+ const pending = deps.gateway.get(body.eventId)
130
+ if (pending !== undefined) {
131
+ const value = validateAnswer(describe(pending, deps.sessions, deps.previewMaxChars), body.value)
132
+ const result = await deps.gateway.answer(body.eventId, value, `device:${deviceId}`)
133
+ return answerResponse(result, deviceId, body.eventId)
134
+ }
135
+ // Not pending: either already settled (409) or unknown/expired (404).
136
+ const result = await deps.gateway.answer(body.eventId, body.value, `device:${deviceId}`)
137
+ return answerResponse(result, deviceId, body.eventId)
138
+ }
139
+
140
+ function answerResponse(result, deviceId, eventId) {
141
+ switch (result.status) {
142
+ case 200:
143
+ deps.onAnswered?.(eventId, deviceId)
144
+ return ok({ ok: true, eventId })
145
+ case 409:
146
+ return { status: 409, body: { error: 'already_resolved', by: result.by === `device:${deviceId}` ? 'this_device' : 'other' } }
147
+ case 404:
148
+ return { status: 404, body: { error: 'unknown_event' } }
149
+ default:
150
+ return { status: 503, body: { error: 'gateway_unavailable' } }
151
+ }
152
+ }
153
+
154
+ function watch(deviceId, body) {
155
+ const ids = Array.isArray(body.sessionIds) ? body.sessionIds : []
156
+ if (ids.length > MAX_WATCHES_PER_DEVICE || !ids.every(id => typeof id === 'string' && SESSION_ID.test(id))) {
157
+ throw new AuthError(400, 'invalid_request')
158
+ }
159
+ const ttlSec = body.ttlSec === undefined ? 86_400 : Number(body.ttlSec)
160
+ if (!Number.isInteger(ttlSec) || ttlSec < 60 || ttlSec > 604_800) throw new AuthError(400, 'invalid_request')
161
+ if (body.watchAll !== undefined && typeof body.watchAll !== 'boolean') throw new AuthError(400, 'invalid_request')
162
+ const current = now()
163
+ deps.state.updateDevice(deviceId, (record) => {
164
+ for (const [id, expiry] of Object.entries(record.watches)) if (expiry <= current) delete record.watches[id]
165
+ for (const id of ids) record.watches[id] = current + ttlSec * 1000
166
+ const entries = Object.entries(record.watches).sort((a, b) => b[1] - a[1])
167
+ record.watches = Object.fromEntries(entries.slice(0, MAX_WATCHES_PER_DEVICE))
168
+ if (body.watchAll !== undefined) record.watchAll = body.watchAll
169
+ })
170
+ return ok({ ok: true })
171
+ }
172
+
173
+ function settings(deviceId, body) {
174
+ if (body.fcmToken !== undefined && body.fcmToken !== null && (typeof body.fcmToken !== 'string' || !FCM_TOKEN.test(body.fcmToken))) {
175
+ throw new AuthError(400, 'invalid_request')
176
+ }
177
+ if (body.previewMode !== undefined && body.previewMode !== 'full' && body.previewMode !== 'redacted') throw new AuthError(400, 'invalid_request')
178
+ if (body.pushEnabled !== undefined && typeof body.pushEnabled !== 'boolean') throw new AuthError(400, 'invalid_request')
179
+ deps.state.updateDevice(deviceId, (record) => {
180
+ if (body.fcmToken !== undefined) record.fcmToken = body.fcmToken
181
+ if (body.previewMode !== undefined) record.previewMode = body.previewMode
182
+ if (body.pushEnabled !== undefined) record.pushEnabled = body.pushEnabled
183
+ }, true)
184
+ return ok({ ok: true })
185
+ }
186
+
187
+ async function sessionList() {
188
+ let summaries = []
189
+ try {
190
+ summaries = await deps.listSessions()
191
+ } catch (error) {
192
+ deps.log('session list unavailable:', error?.message ?? String(error))
193
+ }
194
+ deps.sessions.learnTitles(summaries)
195
+ return summaries
196
+ .filter(s => s.parentSessionId === undefined && s.origin !== 'subagent')
197
+ .sort((a, b) => b.updatedAt - a.updatedAt)
198
+ .slice(0, 100)
199
+ .map(s => ({ sessionId: s.sessionId, title: deps.sessions.title(s.sessionId) || summaryTitle(s), running: s.running, updatedAt: s.updatedAt, cwd: s.cwd ?? '' }))
200
+ }
201
+
202
+ return { handle, pair }
203
+ }
204
+
205
+ function ok(body) {
206
+ return { status: 200, body }
207
+ }
208
+
209
+ function parseJson(text) {
210
+ if (text === undefined || text === '') return {}
211
+ try {
212
+ const value = JSON.parse(text)
213
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error('not an object')
214
+ return value
215
+ } catch {
216
+ throw new AuthError(400, 'invalid_request')
217
+ }
218
+ }
219
+
220
+ function lowerCaseHeaders(headers) {
221
+ /** @type {Record<string, string>} */
222
+ const out = {}
223
+ if (headers === null || typeof headers !== 'object') return out
224
+ for (const [key, value] of Object.entries(headers)) if (typeof value === 'string') out[key.toLowerCase()] = value
225
+ return out
226
+ }
@@ -0,0 +1,102 @@
1
+ // Owner-only DSH `/api` routes used by the plugin's sidebar panel on the desktop
2
+ // (docs/PROTOCOL.md v2 §2.1). Registered through ctx.connection.fetch, which admits only the owner.
3
+
4
+ import qrcode from './vendor/qrcode.mjs'
5
+
6
+ const PREFIX = '/api/dsh-mobile-bridge'
7
+
8
+ /**
9
+ * @param {object} deps
10
+ * @param {import('./state.mjs').StateStore} deps.state
11
+ * @param {import('./auth.mjs').RegistrationCodes} deps.codes
12
+ * @param {import('./gateway.mjs').Gateway} deps.gateway
13
+ * @param {import('./tls-store.mjs').TlsStore} deps.tls
14
+ * @param {import('./web-sessions.mjs').WebSessions} deps.webSessions
15
+ * @param {() => {configured: boolean, connected: boolean}} deps.relay
16
+ * @param {string} deps.hostLabel
17
+ * @param {string} deps.version
18
+ * @param {(...a: unknown[]) => void} deps.log
19
+ * @returns {Array<{path: string, methods: string[], requestBody: 'buffered', fetch: (req: Request) => Promise<Response>}>}
20
+ */
21
+ export function desktopRoutes(deps) {
22
+ const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } })
23
+
24
+ return [
25
+ {
26
+ path: `${PREFIX}/pairing`,
27
+ methods: ['POST'],
28
+ requestBody: 'buffered',
29
+ async fetch() {
30
+ const origins = deps.gateway.origins()
31
+ if (origins.length === 0) return json(503, { error: 'gateway_not_listening' })
32
+ const { code, expiresAt } = deps.codes.issue('desktop')
33
+ const params = new URLSearchParams({ v: '2', h: deps.state.hostId, fp: deps.tls.ca.fingerprint, c: code, n: deps.hostLabel })
34
+ for (const origin of origins) params.append('o', origin)
35
+ const qr = `dshmb://pair?${params.toString()}`
36
+ const symbol = qrcode(0, 'M')
37
+ symbol.addData(qr)
38
+ symbol.make()
39
+ return json(200, {
40
+ qr,
41
+ qrSvg: symbol.createSvgTag({ cellSize: 4, margin: 4, scalable: true }),
42
+ code,
43
+ expiresAt,
44
+ origins,
45
+ caFingerprint: deps.tls.ca.fingerprint,
46
+ hostId: deps.state.hostId,
47
+ hostLabel: deps.hostLabel,
48
+ })
49
+ },
50
+ },
51
+ {
52
+ path: `${PREFIX}/status`,
53
+ methods: ['GET'],
54
+ requestBody: 'buffered',
55
+ async fetch() {
56
+ return json(200, {
57
+ hostId: deps.state.hostId,
58
+ hostLabel: deps.hostLabel,
59
+ version: deps.version,
60
+ gateway: { running: deps.gateway.running, port: deps.gateway.deps.port, origins: deps.gateway.origins(), caFingerprint: deps.tls.ca?.fingerprint ?? '' },
61
+ relay: deps.relay(),
62
+ })
63
+ },
64
+ },
65
+ {
66
+ path: `${PREFIX}/devices`,
67
+ methods: ['GET'],
68
+ requestBody: 'buffered',
69
+ async fetch() {
70
+ return json(200, {
71
+ devices: deps.state.devices().map(([deviceId, d]) => ({
72
+ deviceId,
73
+ label: d.label,
74
+ createdAt: d.createdAt,
75
+ lastSeenAt: d.lastSeenAt,
76
+ pushEnabled: d.pushEnabled,
77
+ hasPush: Boolean(d.fcmToken),
78
+ })),
79
+ })
80
+ },
81
+ },
82
+ {
83
+ path: `${PREFIX}/devices/revoke`,
84
+ methods: ['POST'],
85
+ requestBody: 'buffered',
86
+ async fetch(req) {
87
+ let body
88
+ try {
89
+ body = await req.json()
90
+ } catch {
91
+ return json(400, { error: 'invalid_request' })
92
+ }
93
+ const deviceId = typeof body?.deviceId === 'string' ? body.deviceId : ''
94
+ if (!/^[a-f0-9]{32}$/.test(deviceId)) return json(400, { error: 'invalid_request' })
95
+ if (!deps.state.removeDevice(deviceId)) return json(404, { error: 'unknown_device' })
96
+ deps.webSessions.revokeDevice(deviceId)
97
+ deps.log('device removed from desktop', JSON.stringify({ deviceId }))
98
+ return json(200, { ok: true })
99
+ },
100
+ },
101
+ ]
102
+ }
@@ -0,0 +1,236 @@
1
+ // Joins the DSH Gateway forwarded-event stream (`$events`) as an in-process owner client,
2
+ // exactly like one more GUI window (ADR-0001 §3.1).
3
+ //
4
+ // - It never registers an `approval/request` / `user-questions/request` listener, so it can
5
+ // neither steal requests nor skip the GUI.
6
+ // - It answers with `$events/result`; the Gateway keeps the first answer from any client and
7
+ // sends `cancel` to the others, which closes the desktop card.
8
+ // - It never answers `{kind:'next'}`: if every client did, the waterfall would move past the GUI.
9
+
10
+ import { randomUUID } from 'node:crypto'
11
+
12
+ const HANDLED_EVENTS = new Set(['approval/request', 'user-questions/request'])
13
+ const SETTLED_TTL_MS = 30 * 60 * 1000
14
+ /** Time to let an already-queued `cancel` frame arrive after our result was accepted. */
15
+ const RACE_SETTLE_MS = 150
16
+
17
+ /**
18
+ * @typedef {object} PendingEvent
19
+ * @property {string} eventId
20
+ * @property {'approval'|'question'} kind
21
+ * @property {string} sessionId
22
+ * @property {Record<string, unknown>} request - JSON request fields from the Gateway
23
+ * @property {number} receivedAt
24
+ */
25
+
26
+ /**
27
+ * @typedef {object} GatewayClientHooks
28
+ * @property {(event: PendingEvent) => void} [onPending]
29
+ * @property {(eventId: string, info: {by: string, pending: PendingEvent|undefined}) => void} [onResolved]
30
+ * @property {(ready: boolean) => void} [onReadyChange]
31
+ */
32
+
33
+ export class GatewayClient {
34
+ /**
35
+ * @param {object} deps
36
+ * @param {() => {createPeer: Function, createSharedFetchHandler: Function}} deps.connection
37
+ * @param {() => {wireStream: {open: Function}}} deps.gateway
38
+ * @param {GatewayClientHooks} deps.hooks
39
+ * @param {(...args: unknown[]) => void} deps.log
40
+ * @param {() => number} [deps.now]
41
+ */
42
+ constructor({ connection, gateway, hooks, log, now = Date.now }) {
43
+ this.connection = connection
44
+ this.gateway = gateway
45
+ this.hooks = hooks
46
+ this.log = log
47
+ this.now = now
48
+ /** @type {Map<string, PendingEvent>} */
49
+ this.pending = new Map()
50
+ /** @type {Map<string, {by: string, at: number}>} */
51
+ this.settled = new Map()
52
+ /** @type {Map<string, {lost: boolean}>} */
53
+ this.submitting = new Map()
54
+ this.clientId = undefined
55
+ this.peer = undefined
56
+ this.fetchHandler = undefined
57
+ }
58
+
59
+ get ready() { return this.clientId !== undefined }
60
+
61
+ /** @returns {PendingEvent[]} */
62
+ list() { return [...this.pending.values()].sort((a, b) => a.receivedAt - b.receivedAt) }
63
+
64
+ /** @param {string} eventId */
65
+ get(eventId) { return this.pending.get(eventId) }
66
+
67
+ /**
68
+ * Keep one `$events` generation open until `signal` aborts; reconnect with backoff.
69
+ * @param {AbortSignal} signal
70
+ */
71
+ async run(signal) {
72
+ let delay = 500
73
+ const gateway = this.gateway()
74
+ const connection = this.connection()
75
+ if (typeof gateway?.wireStream?.open !== 'function' || typeof connection?.createPeer !== 'function'
76
+ || typeof connection?.createSharedFetchHandler !== 'function') {
77
+ // Pre-stable Gateway internals moved: fail loudly once instead of retrying forever.
78
+ this.log('this DSH version does not expose the Gateway event stream this plugin needs; approvals from the phone are disabled')
79
+ return
80
+ }
81
+ while (!signal.aborted) {
82
+ const generation = new AbortController()
83
+ const stop = () => generation.abort()
84
+ signal.addEventListener('abort', stop, { once: true })
85
+ try {
86
+ const connection = this.connection()
87
+ this.peer = connection.createPeer({ principal: { kind: 'owner' }, interactions: 'answer' })
88
+ this.fetchHandler = connection.createSharedFetchHandler('/api')
89
+ const uplink = (async function* () {})()
90
+ const stream = await this.gateway().wireStream.open('$events', { args: {} }, uplink, this.peer, generation.signal)
91
+ for await (const frame of stream) {
92
+ this.onFrame(frame)
93
+ delay = 500
94
+ }
95
+ if (!signal.aborted) this.log('event stream ended; reconnecting')
96
+ } catch (error) {
97
+ if (signal.aborted) break
98
+ this.log('event stream unavailable; retrying:', error?.message ?? String(error))
99
+ } finally {
100
+ signal.removeEventListener('abort', stop)
101
+ generation.abort()
102
+ this.resetGeneration()
103
+ }
104
+ if (signal.aborted) break
105
+ await sleep(delay, signal)
106
+ delay = Math.min(delay * 2, 15_000)
107
+ }
108
+ }
109
+
110
+ resetGeneration() {
111
+ const wasReady = this.ready
112
+ this.clientId = undefined
113
+ // Pending events are redelivered with the same eventId by the next generation.
114
+ this.pending.clear()
115
+ if (wasReady) this.hooks.onReadyChange?.(false)
116
+ }
117
+
118
+ /** @param {any} frame */
119
+ onFrame(frame) {
120
+ if (frame === null || typeof frame !== 'object') return
121
+ switch (frame.type) {
122
+ case 'ready':
123
+ this.clientId = frame.clientId
124
+ this.hooks.onReadyChange?.(true)
125
+ return
126
+ case 'waterfall': {
127
+ if (!HANDLED_EVENTS.has(frame.event) || typeof frame.eventId !== 'string' || typeof frame.agentId !== 'string') return
128
+ if (this.pending.has(frame.eventId) || this.settled.has(frame.eventId)) return
129
+ /** @type {PendingEvent} */
130
+ const pending = {
131
+ eventId: frame.eventId,
132
+ kind: frame.event === 'approval/request' ? 'approval' : 'question',
133
+ sessionId: frame.agentId,
134
+ request: frame.request !== null && typeof frame.request === 'object' ? frame.request : {},
135
+ receivedAt: this.now(),
136
+ }
137
+ this.pending.set(pending.eventId, pending)
138
+ this.hooks.onPending?.(pending)
139
+ return
140
+ }
141
+ case 'cancel': {
142
+ const eventId = frame.eventId
143
+ const inFlight = this.submitting.get(eventId)
144
+ if (inFlight !== undefined) inFlight.lost = true
145
+ const pending = this.pending.get(eventId)
146
+ this.pending.delete(eventId)
147
+ if (pending === undefined && inFlight === undefined) return
148
+ if (!this.settled.has(eventId) || inFlight !== undefined) this.remember(eventId, 'other')
149
+ this.hooks.onResolved?.(eventId, { by: 'other', pending })
150
+ return
151
+ }
152
+ default:
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Answer one pending event. First answer wins; everything after is a conflict.
158
+ * @param {string} eventId
159
+ * @param {unknown} value - already validated for the event kind
160
+ * @param {string} by - e.g. `device:<id>`
161
+ * @returns {Promise<{status: 200} | {status: 409, by: string} | {status: 404} | {status: 503}>}
162
+ */
163
+ async answer(eventId, value, by) {
164
+ const settled = this.settled.get(eventId)
165
+ if (settled !== undefined) return { status: 409, by: settled.by }
166
+ const pending = this.pending.get(eventId)
167
+ if (pending === undefined) return { status: 404 }
168
+ if (!this.ready || this.peer === undefined || this.fetchHandler === undefined) return { status: 503 }
169
+ // Claim synchronously so a concurrent second answer in this process gets 409.
170
+ this.pending.delete(eventId)
171
+ this.remember(eventId, by)
172
+ const race = { lost: false }
173
+ this.submitting.set(eventId, race)
174
+ try {
175
+ const response = await this.fetchHandler.fetch(new Request('http://in-process/api/$events/result', {
176
+ method: 'POST',
177
+ headers: { 'content-type': 'application/json' },
178
+ body: JSON.stringify({
179
+ type: 'client-request',
180
+ rpcId: randomUUID(),
181
+ method: '$events/result',
182
+ payload: { args: { clientId: this.clientId, eventId, outcome: { kind: 'result', value } } },
183
+ }),
184
+ }), this.peer)
185
+ const text = await response.text()
186
+ let ok = response.status === 200
187
+ try {
188
+ ok = ok && JSON.parse(text)?.result?.ok === true
189
+ } catch {
190
+ ok = false
191
+ }
192
+ if (!ok) {
193
+ // The Gateway refused the result (stale generation, malformed); give the event back.
194
+ this.settled.delete(eventId)
195
+ if (!race.lost) this.pending.set(eventId, pending)
196
+ this.log('gateway rejected answer', JSON.stringify({ eventId, status: response.status }))
197
+ return race.lost ? { status: 409, by: 'other' } : { status: 503 }
198
+ }
199
+ // A result for an event another client already settled is a silent no-op in the
200
+ // Gateway; the `cancel` frame for it is already queued, so give it a moment to land.
201
+ await sleep(RACE_SETTLE_MS)
202
+ if (race.lost) {
203
+ this.remember(eventId, 'other')
204
+ return { status: 409, by: 'other' }
205
+ }
206
+ this.hooks.onResolved?.(eventId, { by, pending })
207
+ return { status: 200 }
208
+ } finally {
209
+ this.submitting.delete(eventId)
210
+ }
211
+ }
212
+
213
+ /**
214
+ * @param {string} eventId
215
+ * @param {string} by
216
+ */
217
+ remember(eventId, by) {
218
+ const now = this.now()
219
+ this.settled.set(eventId, { by, at: now })
220
+ if (this.settled.size > 512) {
221
+ for (const [id, entry] of this.settled) if (now - entry.at > SETTLED_TTL_MS) this.settled.delete(id)
222
+ }
223
+ }
224
+ }
225
+
226
+ function sleep(ms, signal) {
227
+ return new Promise((resolve) => {
228
+ const timer = setTimeout(done, ms)
229
+ function done() {
230
+ signal?.removeEventListener('abort', done)
231
+ clearTimeout(timer)
232
+ resolve()
233
+ }
234
+ signal?.addEventListener('abort', done, { once: true })
235
+ })
236
+ }