experimental-a2 0.4.0 → 0.5.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/CHANGELOG.md +51 -0
- package/dist/{ai-B4YhEnfw.d.ts → ai-CFNeCrRl.d.ts} +3 -2
- package/dist/ai-CFNeCrRl.d.ts.map +1 -0
- package/dist/ai-server.browser.js +2 -0
- package/dist/ai-server.browser.js.map +1 -0
- package/dist/ai-server.d.ts +4 -3
- package/dist/ai-server.d.ts.map +1 -0
- package/dist/ai-server.js +4 -2
- package/dist/ai-server.js.map +1 -0
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +3 -1
- package/dist/ai.js.map +1 -0
- package/dist/cli-B3VuxoDe.js +2 -0
- package/dist/cli-B3VuxoDe.js.map +1 -0
- package/dist/cli-bin.js +2 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli.d.ts +2 -1
- package/dist/cli.d.ts.map +1 -0
- package/dist/{client-Bt4tAKi9.js → client-BKlyLiOU.js} +295 -85
- package/dist/client-BKlyLiOU.js.map +1 -0
- package/dist/{client-BrfDXQ8A.d.ts → client-D7mvIXrF.d.ts} +40 -4
- package/dist/client-D7mvIXrF.d.ts.map +1 -0
- package/dist/client.d.ts +2 -2
- package/dist/client.js +1 -1
- package/dist/contract-48bUMgcL.js +2 -0
- package/dist/contract-48bUMgcL.js.map +1 -0
- package/dist/contract-jIfaR085.d.ts +2 -1
- package/dist/contract-jIfaR085.d.ts.map +1 -0
- package/dist/devtools-J_jZ2vQf.d.ts +2 -1
- package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
- package/dist/devtools-kJJaORn-.js +2 -0
- package/dist/devtools-kJJaORn-.js.map +1 -0
- package/dist/devtools-server.browser.js +2 -0
- package/dist/devtools-server.browser.js.map +1 -0
- package/dist/devtools-server.d.ts +2 -1
- package/dist/devtools-server.d.ts.map +1 -0
- package/dist/devtools-server.js +2 -0
- package/dist/devtools-server.js.map +1 -0
- package/dist/errors-BQuJpe82.js +2 -0
- package/dist/errors-BQuJpe82.js.map +1 -0
- package/dist/errors-W6nwJ-fm.d.ts +2 -1
- package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
- package/dist/http.d.ts +121 -72
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +503 -178
- package/dist/http.js.map +1 -0
- package/dist/idempotent-replay-DuqEkYA7.js +2 -0
- package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/inspection-DaxB5jM2.js +2 -0
- package/dist/inspection-DaxB5jM2.js.map +1 -0
- package/dist/{internal-aEotMzu_.js → internal-DstsI6Re.js} +3 -1
- package/dist/internal-DstsI6Re.js.map +1 -0
- package/dist/otel.d.ts +2 -1
- package/dist/otel.d.ts.map +1 -0
- package/dist/otel.js +2 -0
- package/dist/otel.js.map +1 -0
- package/dist/platform-B4TnJtWu.js +2 -0
- package/dist/platform-B4TnJtWu.js.map +1 -0
- package/dist/react.d.ts +12 -3
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +5 -1
- package/dist/react.js.map +1 -0
- package/dist/retryable-lazy-DZWmHpii.js +2 -0
- package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +4 -3
- package/dist/scheduler-qstash.d.ts.map +1 -0
- package/dist/scheduler-qstash.js +4 -2
- package/dist/scheduler-qstash.js.map +1 -0
- package/dist/scheduler-task-BpzhPnRS.js +2 -0
- package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
- package/dist/scheduler-vercel.d.ts +4 -3
- package/dist/scheduler-vercel.d.ts.map +1 -0
- package/dist/scheduler-vercel.js +4 -2
- package/dist/scheduler-vercel.js.map +1 -0
- package/dist/{server-CcNnFnoW.js → server-C72KOw51.js} +100 -48
- package/dist/server-C72KOw51.js.map +1 -0
- package/dist/{server-YtPq7hjw.d.ts → server-DUF9pjsx.d.ts} +5 -4
- package/dist/server-DUF9pjsx.d.ts.map +1 -0
- package/dist/server.browser.js +2 -0
- package/dist/server.browser.js.map +1 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/{store-C3sNAaBT.d.ts → store-DysUkTH3.d.ts} +10 -1
- package/dist/store-DysUkTH3.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js +2 -0
- package/dist/store-N8PXxDAS.js.map +1 -0
- package/dist/store-codec-DTG0Ftek.js +2 -0
- package/dist/store-codec-DTG0Ftek.js.map +1 -0
- package/dist/store-memory.d.ts +3 -2
- package/dist/store-memory.d.ts.map +1 -0
- package/dist/store-memory.js +19 -11
- package/dist/store-memory.js.map +1 -0
- package/dist/{store-polling-DgrrAE3d.js → store-polling-dSeLxzfb.js} +3 -1
- package/dist/store-polling-dSeLxzfb.js.map +1 -0
- package/dist/store-postgres.d.ts +3 -2
- package/dist/store-postgres.d.ts.map +1 -0
- package/dist/store-postgres.js +57 -1
- package/dist/store-postgres.js.map +1 -0
- package/dist/{store-redis-core-DWqx3F47.js → store-redis-core-BFLwz0Wj.js} +3 -1
- package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
- package/dist/store-redis-http.d.ts +3 -2
- package/dist/store-redis-http.d.ts.map +1 -0
- package/dist/store-redis-http.js +4 -2
- package/dist/store-redis-http.js.map +1 -0
- package/dist/store-redis.d.ts +3 -2
- package/dist/store-redis.d.ts.map +1 -0
- package/dist/store-redis.js +5 -3
- package/dist/store-redis.js.map +1 -0
- package/dist/store-sqlite.d.ts +3 -2
- package/dist/store-sqlite.d.ts.map +1 -0
- package/dist/store-sqlite.js +3 -1
- package/dist/store-sqlite.js.map +1 -0
- package/dist/telemetry-BjYHTfh2.d.ts +2 -1
- package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
- package/dist/testing.browser.js +2 -0
- package/dist/testing.browser.js.map +1 -0
- package/dist/testing.d.ts +2 -1
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +2 -0
- package/dist/testing.js.map +1 -0
- package/dist/validate-XKT4FSNn.js +2 -0
- package/dist/validate-XKT4FSNn.js.map +1 -0
- package/dist/{wire-DCUZBUlT.js → wire-BFQmSJ-9.js} +77 -15
- package/dist/wire-BFQmSJ-9.js.map +1 -0
- package/docs/guides/03-react.mdx +59 -39
- package/docs/guides/06-ai-agents.mdx +5 -27
- package/docs/guides/09-presence.mdx +19 -40
- package/docs/guides/10-transports.mdx +49 -40
- package/docs/reference/01-api.mdx +107 -26
- package/docs/reference/02-errors.mdx +4 -2
- package/package.json +2 -1
- package/src/ai-coordinator.ts +358 -0
- package/src/ai-projector.ts +524 -0
- package/src/ai-sdk-step.ts +261 -0
- package/src/ai-server.browser.ts +5 -0
- package/src/ai-server.ts +1719 -0
- package/src/ai.ts +2155 -0
- package/src/cache-indexeddb.ts +10 -0
- package/src/cli-bin.ts +5 -0
- package/src/cli.ts +1046 -0
- package/src/client.ts +1826 -0
- package/src/contract.ts +206 -0
- package/src/deterministic-id.ts +72 -0
- package/src/devtools-app.ts +989 -0
- package/src/devtools-server.browser.ts +5 -0
- package/src/devtools-server.ts +604 -0
- package/src/devtools.ts +716 -0
- package/src/errors.ts +50 -0
- package/src/http.ts +394 -0
- package/src/idempotent-replay.ts +53 -0
- package/src/index.ts +37 -0
- package/src/inspection.ts +39 -0
- package/src/internal.ts +426 -0
- package/src/otel.ts +59 -0
- package/src/platform.ts +60 -0
- package/src/push-envelope.ts +137 -0
- package/src/react.ts +284 -0
- package/src/reducer.ts +108 -0
- package/src/retryable-lazy.ts +27 -0
- package/src/scheduler-qstash.ts +915 -0
- package/src/scheduler-task.ts +106 -0
- package/src/scheduler-vercel.ts +437 -0
- package/src/server.browser.ts +12 -0
- package/src/server.ts +2700 -0
- package/src/session-socket.ts +548 -0
- package/src/sse.ts +141 -0
- package/src/standard-schema.ts +77 -0
- package/src/store-codec.ts +10 -0
- package/src/store-memory.ts +788 -0
- package/src/store-polling.ts +102 -0
- package/src/store-postgres.ts +1212 -0
- package/src/store-redis-core.ts +1494 -0
- package/src/store-redis-http.ts +116 -0
- package/src/store-redis.ts +458 -0
- package/src/store-sqlite.ts +1108 -0
- package/src/store.ts +385 -0
- package/src/telemetry.ts +47 -0
- package/src/testing.browser.ts +5 -0
- package/src/testing.ts +185 -0
- package/src/validate.ts +39 -0
- package/src/wire.ts +454 -0
package/src/wire.ts
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire format shared by experimental-a2/http (server side) and experimental-a2/client
|
|
3
|
+
* (browser side): events as JSON with ISO timestamps, and the A2Error
|
|
4
|
+
* envelope. Internal module — both entry points re-export what's
|
|
5
|
+
* public.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { A2Error, type A2ErrorCode } from './errors.ts'
|
|
9
|
+
import { nullProtoRecord } from './internal.ts'
|
|
10
|
+
import type { Event } from './store.ts'
|
|
11
|
+
import type {
|
|
12
|
+
PresenceMap,
|
|
13
|
+
PresencePatch,
|
|
14
|
+
PresenceSnapshot,
|
|
15
|
+
} from './contract.ts'
|
|
16
|
+
|
|
17
|
+
export type WireEvent = {
|
|
18
|
+
id: string
|
|
19
|
+
type: string
|
|
20
|
+
payload: unknown
|
|
21
|
+
index: number
|
|
22
|
+
sessionId: string
|
|
23
|
+
/** ISO 8601 — revived to a Date on the client so reducers see the
|
|
24
|
+
* same shape on both sides of the wire. */
|
|
25
|
+
createdAt: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function eventToWire(event: Event): WireEvent {
|
|
29
|
+
return {
|
|
30
|
+
id: event.id,
|
|
31
|
+
type: event.type,
|
|
32
|
+
payload: event.payload,
|
|
33
|
+
index: event.index,
|
|
34
|
+
sessionId: event.sessionId,
|
|
35
|
+
createdAt: event.createdAt.toISOString(),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function eventFromWire(wire: WireEvent): Event {
|
|
40
|
+
return {
|
|
41
|
+
id: wire.id,
|
|
42
|
+
type: wire.type,
|
|
43
|
+
payload: wire.payload,
|
|
44
|
+
index: wire.index,
|
|
45
|
+
sessionId: wire.sessionId,
|
|
46
|
+
createdAt: new Date(wire.createdAt),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isWireEvent(value: unknown): value is WireEvent {
|
|
51
|
+
if (value === null || typeof value !== 'object') return false
|
|
52
|
+
const v = value as Record<string, unknown>
|
|
53
|
+
return (
|
|
54
|
+
typeof v['id'] === 'string' &&
|
|
55
|
+
typeof v['type'] === 'string' &&
|
|
56
|
+
typeof v['index'] === 'number' &&
|
|
57
|
+
typeof v['sessionId'] === 'string' &&
|
|
58
|
+
typeof v['createdAt'] === 'string'
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── the presence wire pair ───────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/** The `event: presence` SSE frame body: one patch as JSON. */
|
|
65
|
+
export type WirePresencePatch = {
|
|
66
|
+
participant: string
|
|
67
|
+
values: Record<string, unknown>
|
|
68
|
+
seen: number
|
|
69
|
+
/** ISO 8601 — revived to a Date on the client. */
|
|
70
|
+
at: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The `event: presence-snapshot` SSE frame body: the in-memory
|
|
75
|
+
* `PresenceSnapshot` shape verbatim, with each per-field `at`
|
|
76
|
+
* serialized to ISO 8601.
|
|
77
|
+
*/
|
|
78
|
+
export type WirePresenceSnapshot = {
|
|
79
|
+
snapshot: {
|
|
80
|
+
[participant: string]: {
|
|
81
|
+
[field: string]: { value: unknown; seen: number; at: string }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function presencePatchToWire(patch: PresencePatch): WirePresencePatch {
|
|
87
|
+
return {
|
|
88
|
+
participant: patch.participant,
|
|
89
|
+
values: patch.values,
|
|
90
|
+
seen: patch.seen,
|
|
91
|
+
at: patch.at.toISOString(),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function presencePatchFromWire(wire: WirePresencePatch): PresencePatch {
|
|
96
|
+
return {
|
|
97
|
+
participant: wire.participant,
|
|
98
|
+
values: wire.values,
|
|
99
|
+
seen: wire.seen,
|
|
100
|
+
at: new Date(wire.at),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Participant and field keys come off the wire, so every object keyed
|
|
105
|
+
// by them is built null-prototype — see `nullProtoRecord`.
|
|
106
|
+
export function presenceSnapshotToWire(
|
|
107
|
+
snapshot: PresenceSnapshot,
|
|
108
|
+
): WirePresenceSnapshot {
|
|
109
|
+
const wire: WirePresenceSnapshot['snapshot'] = nullProtoRecord()
|
|
110
|
+
for (const [participant, fields] of Object.entries(snapshot.snapshot)) {
|
|
111
|
+
const wireFields: WirePresenceSnapshot['snapshot'][string] =
|
|
112
|
+
nullProtoRecord()
|
|
113
|
+
for (const [field, entry] of Object.entries(fields)) {
|
|
114
|
+
if (entry === undefined) continue
|
|
115
|
+
wireFields[field] = {
|
|
116
|
+
value: entry.value,
|
|
117
|
+
seen: entry.seen,
|
|
118
|
+
at: entry.at.toISOString(),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
wire[participant] = wireFields
|
|
122
|
+
}
|
|
123
|
+
return { snapshot: wire }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function presenceSnapshotFromWire(
|
|
127
|
+
wire: WirePresenceSnapshot,
|
|
128
|
+
): PresenceSnapshot {
|
|
129
|
+
const map: PresenceMap = nullProtoRecord()
|
|
130
|
+
for (const [participant, fields] of Object.entries(wire.snapshot)) {
|
|
131
|
+
const revived: PresenceMap[string] = nullProtoRecord()
|
|
132
|
+
for (const [field, entry] of Object.entries(fields)) {
|
|
133
|
+
revived[field] = {
|
|
134
|
+
value: entry.value,
|
|
135
|
+
seen: entry.seen,
|
|
136
|
+
at: new Date(entry.at),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
map[participant] = revived
|
|
140
|
+
}
|
|
141
|
+
return { snapshot: map }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function isWirePresencePatch(
|
|
145
|
+
value: unknown,
|
|
146
|
+
): value is WirePresencePatch {
|
|
147
|
+
if (value === null || typeof value !== 'object') return false
|
|
148
|
+
const v = value as Record<string, unknown>
|
|
149
|
+
return (
|
|
150
|
+
typeof v['participant'] === 'string' &&
|
|
151
|
+
v['values'] !== null &&
|
|
152
|
+
typeof v['values'] === 'object' &&
|
|
153
|
+
!Array.isArray(v['values']) &&
|
|
154
|
+
typeof v['seen'] === 'number' &&
|
|
155
|
+
typeof v['at'] === 'string'
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function isWirePresenceSnapshot(
|
|
160
|
+
value: unknown,
|
|
161
|
+
): value is WirePresenceSnapshot {
|
|
162
|
+
if (value === null || typeof value !== 'object') return false
|
|
163
|
+
const v = value as Record<string, unknown>
|
|
164
|
+
return (
|
|
165
|
+
v['snapshot'] !== null &&
|
|
166
|
+
typeof v['snapshot'] === 'object' &&
|
|
167
|
+
!Array.isArray(v['snapshot'])
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── the A2Error envelope ─────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
export type WireError = {
|
|
174
|
+
error: { code: A2ErrorCode; message: string; details?: unknown }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const ERROR_STATUS: Record<A2ErrorCode, number> = {
|
|
178
|
+
INVALID_PAYLOAD: 400,
|
|
179
|
+
UNKNOWN_EVENT_TYPE: 400,
|
|
180
|
+
PARTIAL_DUPLICATE_BATCH: 400,
|
|
181
|
+
SUPERSEDED_ATTEMPT: 409,
|
|
182
|
+
CLAIM_EXPIRED: 409,
|
|
183
|
+
STORE_UNAVAILABLE: 503,
|
|
184
|
+
STORE_NOT_CONFIGURED: 500,
|
|
185
|
+
UNKNOWN_PRESENCE_FIELD: 400,
|
|
186
|
+
PRESENCE_NOT_SUPPORTED: 500,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function errorStatus(code: A2ErrorCode): number {
|
|
190
|
+
return ERROR_STATUS[code]
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function errorToWire(error: A2Error): WireError {
|
|
194
|
+
const body: WireError = {
|
|
195
|
+
error: { code: error.code, message: error.message },
|
|
196
|
+
}
|
|
197
|
+
if (error.details !== undefined) body.error.details = error.details
|
|
198
|
+
return body
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Wrap an arbitrary thrown value for the wire: A2Errors pass through,
|
|
202
|
+
* anything else becomes STORE_UNAVAILABLE — from the client's
|
|
203
|
+
* perspective an unknown server failure is retryable-once, not a
|
|
204
|
+
* protocol contract. */
|
|
205
|
+
export function asA2Error(error: unknown): A2Error {
|
|
206
|
+
return error instanceof A2Error
|
|
207
|
+
? error
|
|
208
|
+
: new A2Error('STORE_UNAVAILABLE', 'internal error', { cause: error })
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Rebuild an A2Error from a wire body; null if the body isn't one. */
|
|
212
|
+
export function errorFromWire(body: unknown): A2Error | null {
|
|
213
|
+
if (body === null || typeof body !== 'object') return null
|
|
214
|
+
const err = (body as { error?: unknown }).error
|
|
215
|
+
if (err === null || typeof err !== 'object') return null
|
|
216
|
+
const { code, message, details } = err as Record<string, unknown>
|
|
217
|
+
if (typeof code !== 'string' || !Object.hasOwn(ERROR_STATUS, code))
|
|
218
|
+
return null
|
|
219
|
+
return new A2Error(code as A2ErrorCode, String(message ?? code), {
|
|
220
|
+
details,
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── the ws frame layer ───────────────────────────────────────────────
|
|
225
|
+
// The SSE lanes reframed for a socket (specs/a2-api.md §13): every
|
|
226
|
+
// message is one JSON text frame, and a frame is its wire payload plus
|
|
227
|
+
// a `kind` discriminant (plus `req` where a reply must correlate).
|
|
228
|
+
// Unknown kinds are skipped by both sides — the same
|
|
229
|
+
// forward-compatibility rule as named SSE frames.
|
|
230
|
+
//
|
|
231
|
+
// The multiplexed superset rides the same frames: `subscribe` /
|
|
232
|
+
// `unsubscribe` up-frames open and close per-session lanes on one
|
|
233
|
+
// socket, `sessionId` tags route everything else. Single-session
|
|
234
|
+
// frames (no tags) keep parsing unchanged — a route opts into
|
|
235
|
+
// multiplexing by choosing the multi-session server handler, never by
|
|
236
|
+
// breaking the old protocol.
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Server → client, parsed: a live-stream item, a push ack, a
|
|
240
|
+
* subscription lifecycle notice, or the heartbeat. `parseSocketFrame`
|
|
241
|
+
* yields these; frames of unknown kind (or ones failing their shape
|
|
242
|
+
* guard) come back `null`. `sessionId` is present on frames from a
|
|
243
|
+
* multiplexed socket and absent on a single-session one.
|
|
244
|
+
*/
|
|
245
|
+
export type SocketDownFrame =
|
|
246
|
+
| { kind: 'event'; event: WireEvent }
|
|
247
|
+
| { kind: 'presence'; patch: WirePresencePatch; sessionId?: string }
|
|
248
|
+
| {
|
|
249
|
+
kind: 'presence-snapshot'
|
|
250
|
+
snapshot: WirePresenceSnapshot
|
|
251
|
+
sessionId?: string
|
|
252
|
+
}
|
|
253
|
+
| { kind: 'ack'; req: number; events: WireEvent[]; sessionId?: string }
|
|
254
|
+
| { kind: 'ack'; req: number; error: A2Error; sessionId?: string }
|
|
255
|
+
| { kind: 'subscribed'; sessionId: string }
|
|
256
|
+
| { kind: 'unsubscribed'; sessionId: string; reason?: string }
|
|
257
|
+
| { kind: 'ping' }
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Client → server: the push envelope's two planes. `sessionId` is
|
|
261
|
+
* implied by the socket on a single-session connection and required by
|
|
262
|
+
* the multiplexed handler. Plain JSON on the wire; the server parses
|
|
263
|
+
* and validates them through the same seams as `parsePushBody`.
|
|
264
|
+
*/
|
|
265
|
+
export type SocketPushFrame = {
|
|
266
|
+
kind: 'push'
|
|
267
|
+
/** Client-local ack correlator — opaque to the server. */
|
|
268
|
+
req: number
|
|
269
|
+
events: Array<{ type: string; payload: unknown; id?: string }>
|
|
270
|
+
sessionId?: string
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export type SocketPresenceFrame = {
|
|
274
|
+
kind: 'presence'
|
|
275
|
+
participant: string
|
|
276
|
+
values: Record<string, unknown>
|
|
277
|
+
seen?: number
|
|
278
|
+
at?: number
|
|
279
|
+
sessionId?: string
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Open per-session lanes on a multiplexed socket. `index` is each
|
|
283
|
+
* session's exclusive resume frontier — `stream({ startAfter })`. */
|
|
284
|
+
export type SocketSubscribeFrame = {
|
|
285
|
+
kind: 'subscribe'
|
|
286
|
+
sessions: Array<{ id: string; index: number }>
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export type SocketUnsubscribeFrame = {
|
|
290
|
+
kind: 'unsubscribe'
|
|
291
|
+
sessions: string[]
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export type SocketUpFrame =
|
|
295
|
+
| SocketPushFrame
|
|
296
|
+
| SocketPresenceFrame
|
|
297
|
+
| SocketSubscribeFrame
|
|
298
|
+
| SocketUnsubscribeFrame
|
|
299
|
+
|
|
300
|
+
export const SOCKET_PING_FRAME: string = JSON.stringify({ kind: 'ping' })
|
|
301
|
+
|
|
302
|
+
/** One stream item as a socket frame — `sseResponse`'s framing over
|
|
303
|
+
* the same codecs, with `kind` instead of an SSE event name. On a
|
|
304
|
+
* multiplexed socket presence frames carry the `sessionId` tag; events
|
|
305
|
+
* already carry theirs in the wire event. */
|
|
306
|
+
export function socketFrameFor(
|
|
307
|
+
item: Event | PresencePatch | PresenceSnapshot,
|
|
308
|
+
sessionId?: string,
|
|
309
|
+
): string {
|
|
310
|
+
const tag = sessionId === undefined ? {} : { sessionId }
|
|
311
|
+
if ('snapshot' in item) {
|
|
312
|
+
return JSON.stringify({
|
|
313
|
+
kind: 'presence-snapshot',
|
|
314
|
+
...presenceSnapshotToWire(item),
|
|
315
|
+
...tag,
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
if ('participant' in item) {
|
|
319
|
+
return JSON.stringify({
|
|
320
|
+
kind: 'presence',
|
|
321
|
+
...presencePatchToWire(item),
|
|
322
|
+
...tag,
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
return JSON.stringify({ kind: 'event', ...eventToWire(item) })
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function socketAckFor(
|
|
329
|
+
req: number,
|
|
330
|
+
events: Event[],
|
|
331
|
+
sessionId?: string,
|
|
332
|
+
): string {
|
|
333
|
+
return JSON.stringify({
|
|
334
|
+
kind: 'ack',
|
|
335
|
+
req,
|
|
336
|
+
events: events.map(eventToWire),
|
|
337
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function socketErrorAckFor(
|
|
342
|
+
req: number,
|
|
343
|
+
error: A2Error,
|
|
344
|
+
sessionId?: string,
|
|
345
|
+
): string {
|
|
346
|
+
return JSON.stringify({
|
|
347
|
+
kind: 'ack',
|
|
348
|
+
req,
|
|
349
|
+
...errorToWire(error),
|
|
350
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function socketSubscribedFor(sessionId: string): string {
|
|
355
|
+
return JSON.stringify({ kind: 'subscribed', sessionId })
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** The subscription-over notice: with a `reason` the server rejected
|
|
359
|
+
* or lost the session's stream, without one it ended cleanly. */
|
|
360
|
+
export function socketUnsubscribedFor(
|
|
361
|
+
sessionId: string,
|
|
362
|
+
reason?: string,
|
|
363
|
+
): string {
|
|
364
|
+
return JSON.stringify({
|
|
365
|
+
kind: 'unsubscribed',
|
|
366
|
+
sessionId,
|
|
367
|
+
...(reason === undefined ? {} : { reason }),
|
|
368
|
+
})
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function parseSocketFrame(data: string): SocketDownFrame | null {
|
|
372
|
+
let parsed: unknown
|
|
373
|
+
try {
|
|
374
|
+
parsed = JSON.parse(data)
|
|
375
|
+
} catch {
|
|
376
|
+
return null
|
|
377
|
+
}
|
|
378
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
379
|
+
return null
|
|
380
|
+
}
|
|
381
|
+
const frame = parsed as Record<string, unknown>
|
|
382
|
+
switch (frame['kind']) {
|
|
383
|
+
case 'ping':
|
|
384
|
+
return { kind: 'ping' }
|
|
385
|
+
case 'event': {
|
|
386
|
+
if (!isWireEvent(parsed)) return null
|
|
387
|
+
const { id, type, payload, index, sessionId, createdAt } = parsed
|
|
388
|
+
return {
|
|
389
|
+
kind: 'event',
|
|
390
|
+
event: { id, type, payload, index, sessionId, createdAt },
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
case 'presence': {
|
|
394
|
+
if (!isWirePresencePatch(parsed)) return null
|
|
395
|
+
const { participant, values, seen, at } = parsed
|
|
396
|
+
return {
|
|
397
|
+
kind: 'presence',
|
|
398
|
+
patch: { participant, values, seen, at },
|
|
399
|
+
...sessionTag(frame),
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
case 'presence-snapshot': {
|
|
403
|
+
if (!isWirePresenceSnapshot(parsed)) return null
|
|
404
|
+
return {
|
|
405
|
+
kind: 'presence-snapshot',
|
|
406
|
+
snapshot: { snapshot: parsed.snapshot },
|
|
407
|
+
...sessionTag(frame),
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
case 'subscribed': {
|
|
411
|
+
const sessionId = frame['sessionId']
|
|
412
|
+
if (typeof sessionId !== 'string') return null
|
|
413
|
+
return { kind: 'subscribed', sessionId }
|
|
414
|
+
}
|
|
415
|
+
case 'unsubscribed': {
|
|
416
|
+
const sessionId = frame['sessionId']
|
|
417
|
+
if (typeof sessionId !== 'string') return null
|
|
418
|
+
const reason = frame['reason']
|
|
419
|
+
return typeof reason === 'string'
|
|
420
|
+
? { kind: 'unsubscribed', sessionId, reason }
|
|
421
|
+
: { kind: 'unsubscribed', sessionId }
|
|
422
|
+
}
|
|
423
|
+
case 'ack': {
|
|
424
|
+
const req = frame['req']
|
|
425
|
+
if (typeof req !== 'number') return null
|
|
426
|
+
const tag = sessionTag(frame)
|
|
427
|
+
const events = frame['events']
|
|
428
|
+
if (Array.isArray(events) && events.every(isWireEvent)) {
|
|
429
|
+
return { kind: 'ack', req, events, ...tag }
|
|
430
|
+
}
|
|
431
|
+
const error = errorFromWire(parsed)
|
|
432
|
+
if (error) return { kind: 'ack', req, error, ...tag }
|
|
433
|
+
// A correlatable ack must never be dropped: its waiter would hang
|
|
434
|
+
// forever behind a healthy socket (pings keep the watchdog fed).
|
|
435
|
+
// An outcome this client cannot interpret — an error code from a
|
|
436
|
+
// newer server, an event shape that fails a guard — degrades to
|
|
437
|
+
// a lost ack: retryable, and the client-generated ids make the
|
|
438
|
+
// retry idempotent even if the append actually committed.
|
|
439
|
+
return {
|
|
440
|
+
kind: 'ack',
|
|
441
|
+
req,
|
|
442
|
+
error: new A2Error('STORE_UNAVAILABLE', 'unintelligible ack'),
|
|
443
|
+
...tag,
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
default:
|
|
447
|
+
return null
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const sessionTag = (frame: Record<string, unknown>): { sessionId?: string } => {
|
|
452
|
+
const sessionId = frame['sessionId']
|
|
453
|
+
return typeof sessionId === 'string' ? { sessionId } : {}
|
|
454
|
+
}
|