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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { t as A2Error } from "./errors-BQuJpe82.js";
|
|
2
|
-
import { g as nullProtoRecord } from "./internal-
|
|
2
|
+
import { g as nullProtoRecord } from "./internal-DstsI6Re.js";
|
|
3
3
|
//#region src/wire.ts
|
|
4
4
|
/**
|
|
5
5
|
* The wire format shared by experimental-a2/http (server side) and experimental-a2/client
|
|
@@ -109,6 +109,13 @@ function errorToWire(error) {
|
|
|
109
109
|
if (error.details !== void 0) body.error.details = error.details;
|
|
110
110
|
return body;
|
|
111
111
|
}
|
|
112
|
+
/** Wrap an arbitrary thrown value for the wire: A2Errors pass through,
|
|
113
|
+
* anything else becomes STORE_UNAVAILABLE — from the client's
|
|
114
|
+
* perspective an unknown server failure is retryable-once, not a
|
|
115
|
+
* protocol contract. */
|
|
116
|
+
function asA2Error(error) {
|
|
117
|
+
return error instanceof A2Error ? error : new A2Error("STORE_UNAVAILABLE", "internal error", { cause: error });
|
|
118
|
+
}
|
|
112
119
|
/** Rebuild an A2Error from a wire body; null if the body isn't one. */
|
|
113
120
|
function errorFromWire(body) {
|
|
114
121
|
if (body === null || typeof body !== "object") return null;
|
|
@@ -120,33 +127,55 @@ function errorFromWire(body) {
|
|
|
120
127
|
}
|
|
121
128
|
const SOCKET_PING_FRAME = JSON.stringify({ kind: "ping" });
|
|
122
129
|
/** One stream item as a socket frame — `sseResponse`'s framing over
|
|
123
|
-
* the same codecs, with `kind` instead of an SSE event name.
|
|
124
|
-
|
|
130
|
+
* the same codecs, with `kind` instead of an SSE event name. On a
|
|
131
|
+
* multiplexed socket presence frames carry the `sessionId` tag; events
|
|
132
|
+
* already carry theirs in the wire event. */
|
|
133
|
+
function socketFrameFor(item, sessionId) {
|
|
134
|
+
const tag = sessionId === void 0 ? {} : { sessionId };
|
|
125
135
|
if ("snapshot" in item) return JSON.stringify({
|
|
126
136
|
kind: "presence-snapshot",
|
|
127
|
-
...presenceSnapshotToWire(item)
|
|
137
|
+
...presenceSnapshotToWire(item),
|
|
138
|
+
...tag
|
|
128
139
|
});
|
|
129
140
|
if ("participant" in item) return JSON.stringify({
|
|
130
141
|
kind: "presence",
|
|
131
|
-
...presencePatchToWire(item)
|
|
142
|
+
...presencePatchToWire(item),
|
|
143
|
+
...tag
|
|
132
144
|
});
|
|
133
145
|
return JSON.stringify({
|
|
134
146
|
kind: "event",
|
|
135
147
|
...eventToWire(item)
|
|
136
148
|
});
|
|
137
149
|
}
|
|
138
|
-
function socketAckFor(req, events) {
|
|
150
|
+
function socketAckFor(req, events, sessionId) {
|
|
139
151
|
return JSON.stringify({
|
|
140
152
|
kind: "ack",
|
|
141
153
|
req,
|
|
142
|
-
events: events.map(eventToWire)
|
|
154
|
+
events: events.map(eventToWire),
|
|
155
|
+
...sessionId === void 0 ? {} : { sessionId }
|
|
143
156
|
});
|
|
144
157
|
}
|
|
145
|
-
function socketErrorAckFor(req, error) {
|
|
158
|
+
function socketErrorAckFor(req, error, sessionId) {
|
|
146
159
|
return JSON.stringify({
|
|
147
160
|
kind: "ack",
|
|
148
161
|
req,
|
|
149
|
-
...errorToWire(error)
|
|
162
|
+
...errorToWire(error),
|
|
163
|
+
...sessionId === void 0 ? {} : { sessionId }
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
function socketSubscribedFor(sessionId) {
|
|
167
|
+
return JSON.stringify({
|
|
168
|
+
kind: "subscribed",
|
|
169
|
+
sessionId
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/** The subscription-over notice: with a `reason` the server rejected
|
|
173
|
+
* or lost the session's stream, without one it ended cleanly. */
|
|
174
|
+
function socketUnsubscribedFor(sessionId, reason) {
|
|
175
|
+
return JSON.stringify({
|
|
176
|
+
kind: "unsubscribed",
|
|
177
|
+
sessionId,
|
|
178
|
+
...reason === void 0 ? {} : { reason }
|
|
150
179
|
});
|
|
151
180
|
}
|
|
152
181
|
function parseSocketFrame(data) {
|
|
@@ -185,38 +214,71 @@ function parseSocketFrame(data) {
|
|
|
185
214
|
values,
|
|
186
215
|
seen,
|
|
187
216
|
at
|
|
188
|
-
}
|
|
217
|
+
},
|
|
218
|
+
...sessionTag(frame)
|
|
189
219
|
};
|
|
190
220
|
}
|
|
191
221
|
case "presence-snapshot":
|
|
192
222
|
if (!isWirePresenceSnapshot(parsed)) return null;
|
|
193
223
|
return {
|
|
194
224
|
kind: "presence-snapshot",
|
|
195
|
-
snapshot: { snapshot: parsed.snapshot }
|
|
225
|
+
snapshot: { snapshot: parsed.snapshot },
|
|
226
|
+
...sessionTag(frame)
|
|
227
|
+
};
|
|
228
|
+
case "subscribed": {
|
|
229
|
+
const sessionId = frame["sessionId"];
|
|
230
|
+
if (typeof sessionId !== "string") return null;
|
|
231
|
+
return {
|
|
232
|
+
kind: "subscribed",
|
|
233
|
+
sessionId
|
|
196
234
|
};
|
|
235
|
+
}
|
|
236
|
+
case "unsubscribed": {
|
|
237
|
+
const sessionId = frame["sessionId"];
|
|
238
|
+
if (typeof sessionId !== "string") return null;
|
|
239
|
+
const reason = frame["reason"];
|
|
240
|
+
return typeof reason === "string" ? {
|
|
241
|
+
kind: "unsubscribed",
|
|
242
|
+
sessionId,
|
|
243
|
+
reason
|
|
244
|
+
} : {
|
|
245
|
+
kind: "unsubscribed",
|
|
246
|
+
sessionId
|
|
247
|
+
};
|
|
248
|
+
}
|
|
197
249
|
case "ack": {
|
|
198
250
|
const req = frame["req"];
|
|
199
251
|
if (typeof req !== "number") return null;
|
|
252
|
+
const tag = sessionTag(frame);
|
|
200
253
|
const events = frame["events"];
|
|
201
254
|
if (Array.isArray(events) && events.every(isWireEvent)) return {
|
|
202
255
|
kind: "ack",
|
|
203
256
|
req,
|
|
204
|
-
events
|
|
257
|
+
events,
|
|
258
|
+
...tag
|
|
205
259
|
};
|
|
206
260
|
const error = errorFromWire(parsed);
|
|
207
261
|
if (error) return {
|
|
208
262
|
kind: "ack",
|
|
209
263
|
req,
|
|
210
|
-
error
|
|
264
|
+
error,
|
|
265
|
+
...tag
|
|
211
266
|
};
|
|
212
267
|
return {
|
|
213
268
|
kind: "ack",
|
|
214
269
|
req,
|
|
215
|
-
error: new A2Error("STORE_UNAVAILABLE", "unintelligible ack")
|
|
270
|
+
error: new A2Error("STORE_UNAVAILABLE", "unintelligible ack"),
|
|
271
|
+
...tag
|
|
216
272
|
};
|
|
217
273
|
}
|
|
218
274
|
default: return null;
|
|
219
275
|
}
|
|
220
276
|
}
|
|
277
|
+
const sessionTag = (frame) => {
|
|
278
|
+
const sessionId = frame["sessionId"];
|
|
279
|
+
return typeof sessionId === "string" ? { sessionId } : {};
|
|
280
|
+
};
|
|
221
281
|
//#endregion
|
|
222
|
-
export {
|
|
282
|
+
export { socketErrorAckFor as _, errorToWire as a, socketUnsubscribedFor as b, isWireEvent as c, parseSocketFrame as d, presencePatchFromWire as f, socketAckFor as g, presenceSnapshotToWire as h, errorStatus as i, isWirePresencePatch as l, presenceSnapshotFromWire as m, asA2Error as n, eventFromWire as o, presencePatchToWire as p, errorFromWire as r, eventToWire as s, SOCKET_PING_FRAME as t, isWirePresenceSnapshot as u, socketFrameFor as v, socketSubscribedFor as y };
|
|
283
|
+
|
|
284
|
+
//# sourceMappingURL=wire-BFQmSJ-9.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"wire-BFQmSJ-9.js","names":[],"sources":["../src/wire.ts"],"sourcesContent":["/**\n * The wire format shared by experimental-a2/http (server side) and experimental-a2/client\n * (browser side): events as JSON with ISO timestamps, and the A2Error\n * envelope. Internal module — both entry points re-export what's\n * public.\n */\n\nimport { A2Error, type A2ErrorCode } from './errors.ts'\nimport { nullProtoRecord } from './internal.ts'\nimport type { Event } from './store.ts'\nimport type {\n PresenceMap,\n PresencePatch,\n PresenceSnapshot,\n} from './contract.ts'\n\nexport type WireEvent = {\n id: string\n type: string\n payload: unknown\n index: number\n sessionId: string\n /** ISO 8601 — revived to a Date on the client so reducers see the\n * same shape on both sides of the wire. */\n createdAt: string\n}\n\nexport function eventToWire(event: Event): WireEvent {\n return {\n id: event.id,\n type: event.type,\n payload: event.payload,\n index: event.index,\n sessionId: event.sessionId,\n createdAt: event.createdAt.toISOString(),\n }\n}\n\nexport function eventFromWire(wire: WireEvent): Event {\n return {\n id: wire.id,\n type: wire.type,\n payload: wire.payload,\n index: wire.index,\n sessionId: wire.sessionId,\n createdAt: new Date(wire.createdAt),\n }\n}\n\nexport function isWireEvent(value: unknown): value is WireEvent {\n if (value === null || typeof value !== 'object') return false\n const v = value as Record<string, unknown>\n return (\n typeof v['id'] === 'string' &&\n typeof v['type'] === 'string' &&\n typeof v['index'] === 'number' &&\n typeof v['sessionId'] === 'string' &&\n typeof v['createdAt'] === 'string'\n )\n}\n\n// ── the presence wire pair ───────────────────────────────────────────\n\n/** The `event: presence` SSE frame body: one patch as JSON. */\nexport type WirePresencePatch = {\n participant: string\n values: Record<string, unknown>\n seen: number\n /** ISO 8601 — revived to a Date on the client. */\n at: string\n}\n\n/**\n * The `event: presence-snapshot` SSE frame body: the in-memory\n * `PresenceSnapshot` shape verbatim, with each per-field `at`\n * serialized to ISO 8601.\n */\nexport type WirePresenceSnapshot = {\n snapshot: {\n [participant: string]: {\n [field: string]: { value: unknown; seen: number; at: string }\n }\n }\n}\n\nexport function presencePatchToWire(patch: PresencePatch): WirePresencePatch {\n return {\n participant: patch.participant,\n values: patch.values,\n seen: patch.seen,\n at: patch.at.toISOString(),\n }\n}\n\nexport function presencePatchFromWire(wire: WirePresencePatch): PresencePatch {\n return {\n participant: wire.participant,\n values: wire.values,\n seen: wire.seen,\n at: new Date(wire.at),\n }\n}\n\n// Participant and field keys come off the wire, so every object keyed\n// by them is built null-prototype — see `nullProtoRecord`.\nexport function presenceSnapshotToWire(\n snapshot: PresenceSnapshot,\n): WirePresenceSnapshot {\n const wire: WirePresenceSnapshot['snapshot'] = nullProtoRecord()\n for (const [participant, fields] of Object.entries(snapshot.snapshot)) {\n const wireFields: WirePresenceSnapshot['snapshot'][string] =\n nullProtoRecord()\n for (const [field, entry] of Object.entries(fields)) {\n if (entry === undefined) continue\n wireFields[field] = {\n value: entry.value,\n seen: entry.seen,\n at: entry.at.toISOString(),\n }\n }\n wire[participant] = wireFields\n }\n return { snapshot: wire }\n}\n\nexport function presenceSnapshotFromWire(\n wire: WirePresenceSnapshot,\n): PresenceSnapshot {\n const map: PresenceMap = nullProtoRecord()\n for (const [participant, fields] of Object.entries(wire.snapshot)) {\n const revived: PresenceMap[string] = nullProtoRecord()\n for (const [field, entry] of Object.entries(fields)) {\n revived[field] = {\n value: entry.value,\n seen: entry.seen,\n at: new Date(entry.at),\n }\n }\n map[participant] = revived\n }\n return { snapshot: map }\n}\n\nexport function isWirePresencePatch(\n value: unknown,\n): value is WirePresencePatch {\n if (value === null || typeof value !== 'object') return false\n const v = value as Record<string, unknown>\n return (\n typeof v['participant'] === 'string' &&\n v['values'] !== null &&\n typeof v['values'] === 'object' &&\n !Array.isArray(v['values']) &&\n typeof v['seen'] === 'number' &&\n typeof v['at'] === 'string'\n )\n}\n\nexport function isWirePresenceSnapshot(\n value: unknown,\n): value is WirePresenceSnapshot {\n if (value === null || typeof value !== 'object') return false\n const v = value as Record<string, unknown>\n return (\n v['snapshot'] !== null &&\n typeof v['snapshot'] === 'object' &&\n !Array.isArray(v['snapshot'])\n )\n}\n\n// ── the A2Error envelope ─────────────────────────────────────────────\n\nexport type WireError = {\n error: { code: A2ErrorCode; message: string; details?: unknown }\n}\n\nconst ERROR_STATUS: Record<A2ErrorCode, number> = {\n INVALID_PAYLOAD: 400,\n UNKNOWN_EVENT_TYPE: 400,\n PARTIAL_DUPLICATE_BATCH: 400,\n SUPERSEDED_ATTEMPT: 409,\n CLAIM_EXPIRED: 409,\n STORE_UNAVAILABLE: 503,\n STORE_NOT_CONFIGURED: 500,\n UNKNOWN_PRESENCE_FIELD: 400,\n PRESENCE_NOT_SUPPORTED: 500,\n}\n\nexport function errorStatus(code: A2ErrorCode): number {\n return ERROR_STATUS[code]\n}\n\nexport function errorToWire(error: A2Error): WireError {\n const body: WireError = {\n error: { code: error.code, message: error.message },\n }\n if (error.details !== undefined) body.error.details = error.details\n return body\n}\n\n/** Wrap an arbitrary thrown value for the wire: A2Errors pass through,\n * anything else becomes STORE_UNAVAILABLE — from the client's\n * perspective an unknown server failure is retryable-once, not a\n * protocol contract. */\nexport function asA2Error(error: unknown): A2Error {\n return error instanceof A2Error\n ? error\n : new A2Error('STORE_UNAVAILABLE', 'internal error', { cause: error })\n}\n\n/** Rebuild an A2Error from a wire body; null if the body isn't one. */\nexport function errorFromWire(body: unknown): A2Error | null {\n if (body === null || typeof body !== 'object') return null\n const err = (body as { error?: unknown }).error\n if (err === null || typeof err !== 'object') return null\n const { code, message, details } = err as Record<string, unknown>\n if (typeof code !== 'string' || !Object.hasOwn(ERROR_STATUS, code))\n return null\n return new A2Error(code as A2ErrorCode, String(message ?? code), {\n details,\n })\n}\n\n// ── the ws frame layer ───────────────────────────────────────────────\n// The SSE lanes reframed for a socket (specs/a2-api.md §13): every\n// message is one JSON text frame, and a frame is its wire payload plus\n// a `kind` discriminant (plus `req` where a reply must correlate).\n// Unknown kinds are skipped by both sides — the same\n// forward-compatibility rule as named SSE frames.\n//\n// The multiplexed superset rides the same frames: `subscribe` /\n// `unsubscribe` up-frames open and close per-session lanes on one\n// socket, `sessionId` tags route everything else. Single-session\n// frames (no tags) keep parsing unchanged — a route opts into\n// multiplexing by choosing the multi-session server handler, never by\n// breaking the old protocol.\n\n/**\n * Server → client, parsed: a live-stream item, a push ack, a\n * subscription lifecycle notice, or the heartbeat. `parseSocketFrame`\n * yields these; frames of unknown kind (or ones failing their shape\n * guard) come back `null`. `sessionId` is present on frames from a\n * multiplexed socket and absent on a single-session one.\n */\nexport type SocketDownFrame =\n | { kind: 'event'; event: WireEvent }\n | { kind: 'presence'; patch: WirePresencePatch; sessionId?: string }\n | {\n kind: 'presence-snapshot'\n snapshot: WirePresenceSnapshot\n sessionId?: string\n }\n | { kind: 'ack'; req: number; events: WireEvent[]; sessionId?: string }\n | { kind: 'ack'; req: number; error: A2Error; sessionId?: string }\n | { kind: 'subscribed'; sessionId: string }\n | { kind: 'unsubscribed'; sessionId: string; reason?: string }\n | { kind: 'ping' }\n\n/**\n * Client → server: the push envelope's two planes. `sessionId` is\n * implied by the socket on a single-session connection and required by\n * the multiplexed handler. Plain JSON on the wire; the server parses\n * and validates them through the same seams as `parsePushBody`.\n */\nexport type SocketPushFrame = {\n kind: 'push'\n /** Client-local ack correlator — opaque to the server. */\n req: number\n events: Array<{ type: string; payload: unknown; id?: string }>\n sessionId?: string\n}\n\nexport type SocketPresenceFrame = {\n kind: 'presence'\n participant: string\n values: Record<string, unknown>\n seen?: number\n at?: number\n sessionId?: string\n}\n\n/** Open per-session lanes on a multiplexed socket. `index` is each\n * session's exclusive resume frontier — `stream({ startAfter })`. */\nexport type SocketSubscribeFrame = {\n kind: 'subscribe'\n sessions: Array<{ id: string; index: number }>\n}\n\nexport type SocketUnsubscribeFrame = {\n kind: 'unsubscribe'\n sessions: string[]\n}\n\nexport type SocketUpFrame =\n | SocketPushFrame\n | SocketPresenceFrame\n | SocketSubscribeFrame\n | SocketUnsubscribeFrame\n\nexport const SOCKET_PING_FRAME: string = JSON.stringify({ kind: 'ping' })\n\n/** One stream item as a socket frame — `sseResponse`'s framing over\n * the same codecs, with `kind` instead of an SSE event name. On a\n * multiplexed socket presence frames carry the `sessionId` tag; events\n * already carry theirs in the wire event. */\nexport function socketFrameFor(\n item: Event | PresencePatch | PresenceSnapshot,\n sessionId?: string,\n): string {\n const tag = sessionId === undefined ? {} : { sessionId }\n if ('snapshot' in item) {\n return JSON.stringify({\n kind: 'presence-snapshot',\n ...presenceSnapshotToWire(item),\n ...tag,\n })\n }\n if ('participant' in item) {\n return JSON.stringify({\n kind: 'presence',\n ...presencePatchToWire(item),\n ...tag,\n })\n }\n return JSON.stringify({ kind: 'event', ...eventToWire(item) })\n}\n\nexport function socketAckFor(\n req: number,\n events: Event[],\n sessionId?: string,\n): string {\n return JSON.stringify({\n kind: 'ack',\n req,\n events: events.map(eventToWire),\n ...(sessionId === undefined ? {} : { sessionId }),\n })\n}\n\nexport function socketErrorAckFor(\n req: number,\n error: A2Error,\n sessionId?: string,\n): string {\n return JSON.stringify({\n kind: 'ack',\n req,\n ...errorToWire(error),\n ...(sessionId === undefined ? {} : { sessionId }),\n })\n}\n\nexport function socketSubscribedFor(sessionId: string): string {\n return JSON.stringify({ kind: 'subscribed', sessionId })\n}\n\n/** The subscription-over notice: with a `reason` the server rejected\n * or lost the session's stream, without one it ended cleanly. */\nexport function socketUnsubscribedFor(\n sessionId: string,\n reason?: string,\n): string {\n return JSON.stringify({\n kind: 'unsubscribed',\n sessionId,\n ...(reason === undefined ? {} : { reason }),\n })\n}\n\nexport function parseSocketFrame(data: string): SocketDownFrame | null {\n let parsed: unknown\n try {\n parsed = JSON.parse(data)\n } catch {\n return null\n }\n if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {\n return null\n }\n const frame = parsed as Record<string, unknown>\n switch (frame['kind']) {\n case 'ping':\n return { kind: 'ping' }\n case 'event': {\n if (!isWireEvent(parsed)) return null\n const { id, type, payload, index, sessionId, createdAt } = parsed\n return {\n kind: 'event',\n event: { id, type, payload, index, sessionId, createdAt },\n }\n }\n case 'presence': {\n if (!isWirePresencePatch(parsed)) return null\n const { participant, values, seen, at } = parsed\n return {\n kind: 'presence',\n patch: { participant, values, seen, at },\n ...sessionTag(frame),\n }\n }\n case 'presence-snapshot': {\n if (!isWirePresenceSnapshot(parsed)) return null\n return {\n kind: 'presence-snapshot',\n snapshot: { snapshot: parsed.snapshot },\n ...sessionTag(frame),\n }\n }\n case 'subscribed': {\n const sessionId = frame['sessionId']\n if (typeof sessionId !== 'string') return null\n return { kind: 'subscribed', sessionId }\n }\n case 'unsubscribed': {\n const sessionId = frame['sessionId']\n if (typeof sessionId !== 'string') return null\n const reason = frame['reason']\n return typeof reason === 'string'\n ? { kind: 'unsubscribed', sessionId, reason }\n : { kind: 'unsubscribed', sessionId }\n }\n case 'ack': {\n const req = frame['req']\n if (typeof req !== 'number') return null\n const tag = sessionTag(frame)\n const events = frame['events']\n if (Array.isArray(events) && events.every(isWireEvent)) {\n return { kind: 'ack', req, events, ...tag }\n }\n const error = errorFromWire(parsed)\n if (error) return { kind: 'ack', req, error, ...tag }\n // A correlatable ack must never be dropped: its waiter would hang\n // forever behind a healthy socket (pings keep the watchdog fed).\n // An outcome this client cannot interpret — an error code from a\n // newer server, an event shape that fails a guard — degrades to\n // a lost ack: retryable, and the client-generated ids make the\n // retry idempotent even if the append actually committed.\n return {\n kind: 'ack',\n req,\n error: new A2Error('STORE_UNAVAILABLE', 'unintelligible ack'),\n ...tag,\n }\n }\n default:\n return null\n }\n}\n\nconst sessionTag = (frame: Record<string, unknown>): { sessionId?: string } => {\n const sessionId = frame['sessionId']\n return typeof sessionId === 'string' ? { sessionId } : {}\n}\n"],"mappings":"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"}
|
package/docs/guides/03-react.mdx
CHANGED
|
@@ -23,52 +23,57 @@ reducer, and the provider + hook it returns.
|
|
|
23
23
|
|
|
24
24
|
## The API route
|
|
25
25
|
|
|
26
|
-
One
|
|
27
|
-
|
|
26
|
+
One call exposes a session over HTTP: `GET` streams events (and serves
|
|
27
|
+
history slices), `POST` appends. `handle` parses each request into an
|
|
28
|
+
intent, runs your hooks, then acts.
|
|
28
29
|
|
|
29
30
|
```ts app/api/order-events/route.ts
|
|
30
|
-
import {
|
|
31
|
+
import { handle } from 'experimental-a2/http'
|
|
31
32
|
import { ordersServer } from '@/server/orders'
|
|
32
|
-
import { errorResponse, parsePushBody, sseResponse } from 'experimental-a2/http'
|
|
33
33
|
|
|
34
|
-
export
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
// here's where you'd do auth, or any other checks
|
|
43
|
-
|
|
44
|
-
return sseResponse(ordersServer.session(sessionId).stream({ startAfter }))
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export async function POST(req: Request) {
|
|
48
|
-
try {
|
|
49
|
-
const { sessionId, events } = await parsePushBody(req)
|
|
50
|
-
|
|
51
|
-
// here's where you'd do auth, or any other checks
|
|
52
|
-
|
|
53
|
-
const result = await ordersServer.session(sessionId).append(...events)
|
|
54
|
-
return Response.json(result)
|
|
55
|
-
} catch (err) {
|
|
56
|
-
return errorResponse(err)
|
|
57
|
-
}
|
|
58
|
-
}
|
|
34
|
+
export const { GET, POST } = handle(ordersServer, {
|
|
35
|
+
before({ request, intent }) {
|
|
36
|
+
// here's where you'd do auth, or any other checks. Every lane
|
|
37
|
+
// arrives parsed: intent.type is 'stream', 'history', 'push', or
|
|
38
|
+
// 'ws-upgrade'. Return a Response to refuse, e.g.:
|
|
39
|
+
// if (!canRead(request, intent)) return new Response(null, { status: 403 })
|
|
40
|
+
},
|
|
41
|
+
})
|
|
59
42
|
```
|
|
60
43
|
|
|
61
|
-
`GET` is the read path.
|
|
62
|
-
one session's events
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
44
|
+
`GET` is the read path. A plain `GET` is the live stream: a server-sent
|
|
45
|
+
events response of one session's events, resumed after the `index` query
|
|
46
|
+
parameter, so clients pick up exactly where they left off after a first
|
|
47
|
+
paint or a dropped connection. A `GET` carrying `gte`/`lte` query
|
|
48
|
+
parameters is a history slice instead: the bounded log range as JSON,
|
|
49
|
+
the cold read [`loadHistory`](#the-client-component) rides.
|
|
50
|
+
|
|
51
|
+
`POST` is the write path. The push envelope is validated (garbage
|
|
52
|
+
answers `INVALID_PAYLOAD` before your hooks run), then `append` does the
|
|
53
|
+
rest. The response is the appended events: an ack, not a stream. Thrown
|
|
54
|
+
[`A2Error`s](/reference/errors#over-the-wire) serialize onto the wire so
|
|
55
|
+
the client can branch on the same codes.
|
|
56
|
+
|
|
57
|
+
Parsing is protocol, hooks are policy. `before` sees every parsed
|
|
58
|
+
intent and short-circuits by returning a Response. `after` runs when the
|
|
59
|
+
library produced an HTTP response and can decorate or replace it. That
|
|
60
|
+
is where caching policy lives, if you want any: `outcome.covered` on a
|
|
61
|
+
history read means the closed range came back fully covered, an
|
|
62
|
+
immutable slice of an append-only log.
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// app/api/order-events/route.ts, now with response decoration:
|
|
66
|
+
import { handle } from 'experimental-a2/http'
|
|
67
|
+
import { ordersServer } from '@/server/orders'
|
|
66
68
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
export const { GET, POST } = handle(ordersServer, {
|
|
70
|
+
after({ outcome, response }) {
|
|
71
|
+
if (outcome.type === 'history' && outcome.covered) {
|
|
72
|
+
response.headers.set('cache-control', 'private, max-age=31536000')
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
```
|
|
72
77
|
|
|
73
78
|
## The session module
|
|
74
79
|
|
|
@@ -174,6 +179,21 @@ What the hook gives you:
|
|
|
174
179
|
seeded with. With only the server snapshot, it begins after `initialIndex`.
|
|
175
180
|
Use it for UI that wants the log itself: an activity feed, a debug panel.
|
|
176
181
|
Earlier events are not needed to hydrate `state`.
|
|
182
|
+
- **`loadHistory`**: backscroll. `loadHistory({ before?, limit? })`
|
|
183
|
+
fetches a bounded slice of the log from below the frontier (the same
|
|
184
|
+
route, `gte`/`lte` query parameters) and merges it into `events`:
|
|
185
|
+
deduped, ordered, shared across every handle of the session. By
|
|
186
|
+
default each call walks backward 50 events at a time from the oldest
|
|
187
|
+
one loaded. After a hydrate jump (returning to a session whose
|
|
188
|
+
frontier advanced while away), default paging still continues from
|
|
189
|
+
the oldest loaded event; pass an explicit `before` to fill the gap
|
|
190
|
+
between the old feed and the new frontier. It never touches `state` or the optimistic overlay;
|
|
191
|
+
backscrolled events are display data. Calls serialize per session, so
|
|
192
|
+
a double-tap never fetches the same range twice. The `ws` api has no
|
|
193
|
+
history lane; `loadHistory` throws a `TypeError` there.
|
|
194
|
+
- **`history`**: backscroll progress, `{ loading, complete,
|
|
195
|
+
oldestLoaded }`. `complete` means the feed reaches index 1 (or the
|
|
196
|
+
log is empty): nothing older is left, hide the "load older" button.
|
|
177
197
|
- **`index`**: the stream frontier, the last server-confirmed log
|
|
178
198
|
position. This is the `lastSeenIndex` that makes
|
|
179
199
|
[cancellation](/guides/cancellation) exact.
|
|
@@ -129,36 +129,14 @@ One HTTP route gives the browser a read and write path. `GET` streams events;
|
|
|
129
129
|
`POST` accepts optimistic pushes:
|
|
130
130
|
|
|
131
131
|
```ts app/api/agent-events/route.ts
|
|
132
|
-
import {
|
|
133
|
-
import { errorResponse, parsePushBody, sseResponse } from 'experimental-a2/http'
|
|
132
|
+
import { handle } from 'experimental-a2/http'
|
|
134
133
|
import { assistantServer } from '@/server/assistant'
|
|
135
134
|
|
|
136
|
-
export
|
|
137
|
-
|
|
138
|
-
const sessionId = searchParams.get('sessionId')
|
|
139
|
-
const startAfter = Number(searchParams.get('index')) || 0
|
|
140
|
-
|
|
141
|
-
if (!sessionId) {
|
|
142
|
-
return errorResponse(new A2Error('INVALID_PAYLOAD', 'missing sessionId'))
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// here's where you'd do auth, or any other checks
|
|
146
|
-
|
|
147
|
-
return sseResponse(assistantServer.session(sessionId).stream({ startAfter }))
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
export async function POST(req: Request): Promise<Response> {
|
|
151
|
-
try {
|
|
152
|
-
const { sessionId, events } = await parsePushBody(req)
|
|
153
|
-
|
|
135
|
+
export const { GET, POST } = handle(assistantServer, {
|
|
136
|
+
before({ request, intent }) {
|
|
154
137
|
// here's where you'd do auth, or any other checks
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
return Response.json(appended)
|
|
158
|
-
} catch (error) {
|
|
159
|
-
return errorResponse(error)
|
|
160
|
-
}
|
|
161
|
-
}
|
|
138
|
+
},
|
|
139
|
+
})
|
|
162
140
|
```
|
|
163
141
|
|
|
164
142
|
The route never calls the model directly. The browser appends user facts such
|
|
@@ -63,55 +63,34 @@ explicit leave required).
|
|
|
63
63
|
|
|
64
64
|
## The route
|
|
65
65
|
|
|
66
|
-
The same
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
The same `handle` route as [Live UI](/guides/react), with one option.
|
|
67
|
+
`presence: true` interleaves presence patches with events on the
|
|
68
|
+
stream, starting with a snapshot of the current map; the push body
|
|
69
|
+
grows an optional `presence` sibling to `events`, forwarded to
|
|
70
|
+
`setPresence`.
|
|
70
71
|
|
|
71
72
|
```ts app/api/canvas-events/route.ts
|
|
72
|
-
import {
|
|
73
|
-
import { errorResponse, parsePushBody, sseResponse } from 'experimental-a2/http'
|
|
73
|
+
import { handle } from 'experimental-a2/http'
|
|
74
74
|
import { canvasServer } from '@/server/canvas'
|
|
75
75
|
|
|
76
|
-
export
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
canvasServer.session(sessionId).stream({ startAfter, presence: true }),
|
|
88
|
-
)
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export async function POST(req: Request) {
|
|
92
|
-
try {
|
|
93
|
-
const { sessionId, events, presence } = await parsePushBody(req)
|
|
94
|
-
const session = canvasServer.session(sessionId)
|
|
95
|
-
|
|
96
|
-
// here's where you'd do auth, or any other checks; the participant
|
|
97
|
-
// id is caller-supplied, so authorize it like you authorize events.
|
|
98
|
-
// createServer's validatePush is the same seam: on the presence
|
|
99
|
-
// plane it receives { sessionId, events: [], presence }, the whole
|
|
100
|
-
// patch, participant included
|
|
101
|
-
|
|
102
|
-
if (presence) await session.setPresence(presence)
|
|
103
|
-
if (events.length === 0) return Response.json([])
|
|
104
|
-
return Response.json(await session.append(...events))
|
|
105
|
-
} catch (err) {
|
|
106
|
-
return errorResponse(err)
|
|
107
|
-
}
|
|
108
|
-
}
|
|
76
|
+
export const { GET, POST } = handle(canvasServer, {
|
|
77
|
+
presence: true,
|
|
78
|
+
before({ request, intent }) {
|
|
79
|
+
// here's where you'd do auth, or any other checks. On a push,
|
|
80
|
+
// intent.presence carries the whole patch; the participant id
|
|
81
|
+
// is caller-supplied, so authorize it like you authorize events.
|
|
82
|
+
// createServer's validatePush is the same seam and covers every
|
|
83
|
+
// transport (socket presence frames never become intents): on the
|
|
84
|
+
// presence plane it receives { sessionId, events: [], presence }.
|
|
85
|
+
},
|
|
86
|
+
})
|
|
109
87
|
```
|
|
110
88
|
|
|
111
89
|
`setPresence` validates each field against the contract, then
|
|
112
90
|
broadcasts. No append transaction, no dispatch, no scheduler arm, no log
|
|
113
91
|
row. A bad field throws `INVALID_PAYLOAD`; an unknown field throws
|
|
114
|
-
`UNKNOWN_PRESENCE_FIELD`; nothing is broadcast on either.
|
|
92
|
+
`UNKNOWN_PRESENCE_FIELD`; nothing is broadcast on either. A
|
|
93
|
+
presence-only push acks `[]`.
|
|
115
94
|
|
|
116
95
|
## The browser
|
|
117
96
|
|
|
@@ -24,58 +24,67 @@ api: { type: 'ws', url: '/api/order-events' }
|
|
|
24
24
|
platform duration limits differ per verb: the stream is a long-lived
|
|
25
25
|
read that wants a high `maxDuration`, the push is a short write that
|
|
26
26
|
doesn't.
|
|
27
|
-
- **`ws`** rides everything over one WebSocket:
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
- **`ws`** rides everything over one WebSocket: streams come down it,
|
|
28
|
+
pushes and presence go up it. One socket carries every session of
|
|
29
|
+
the client; a page showing ten sessions holds one connection, not
|
|
30
|
+
ten. Use it when latency or per-message cost matters; at a presence
|
|
31
|
+
cadence of fifteen sends a second, each send is a socket frame
|
|
32
|
+
instead of a route invocation.
|
|
31
33
|
|
|
32
34
|
A split socket is unrepresentable on purpose. The socket is one
|
|
33
35
|
connection in both directions; there is nothing left to split.
|
|
34
36
|
|
|
37
|
+
One wire is missing from `ws` by design: the history lane.
|
|
38
|
+
[`loadHistory`](/guides/react#the-client-component) is a bounded cold
|
|
39
|
+
read and rides plain HTTP; on a `ws` api it throws a `TypeError`.
|
|
40
|
+
|
|
35
41
|
## The WebSocket route
|
|
36
42
|
|
|
37
|
-
The same route
|
|
38
|
-
|
|
39
|
-
|
|
43
|
+
The same `handle` route serves both transports. Pass `options.upgrade`
|
|
44
|
+
and a GET carrying an upgrade header becomes the socket; plain GETs
|
|
45
|
+
stay SSE, POST keeps working next to it. A `ws` client never calls
|
|
46
|
+
POST, an `http` client never upgrades; the transports are additive.
|
|
40
47
|
|
|
41
48
|
```ts app/api/order-events/route.ts
|
|
42
49
|
import { experimental_upgradeWebSocket } from '@vercel/functions'
|
|
43
|
-
import {
|
|
44
|
-
import { errorResponse, sessionSocket, sseResponse } from 'experimental-a2/http'
|
|
50
|
+
import { handle } from 'experimental-a2/http'
|
|
45
51
|
import { ordersServer } from '@/server/orders'
|
|
46
52
|
|
|
47
|
-
export
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
// here's where you'd do auth, or any other checks
|
|
56
|
-
|
|
57
|
-
if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
|
58
|
-
return experimental_upgradeWebSocket(
|
|
59
|
-
(ws) => sessionSocket(ordersServer.session(sessionId), ws, { startAfter }),
|
|
53
|
+
export const { GET, POST } = handle(ordersServer, {
|
|
54
|
+
before({ request, intent }) {
|
|
55
|
+
// here's where you'd do auth, or any other checks: the upgrade
|
|
56
|
+
// itself, every subscribe, and every push arrive here as intents
|
|
57
|
+
},
|
|
58
|
+
upgrade: (attach) =>
|
|
59
|
+
experimental_upgradeWebSocket(attach, {
|
|
60
60
|
// ws defaults to 100 MiB per frame; POST bodies cap at about
|
|
61
61
|
// 4.5 MB on the platform. Keep the two ingress paths at parity.
|
|
62
|
-
|
|
63
|
-
)
|
|
64
|
-
|
|
65
|
-
return sseResponse(ordersServer.session(sessionId).stream({ startAfter }))
|
|
66
|
-
}
|
|
62
|
+
maxPayload: 4 * 1024 * 1024,
|
|
63
|
+
}),
|
|
64
|
+
})
|
|
67
65
|
```
|
|
68
66
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
67
|
+
The socket is multiplexed: `subscribe` frames open per-session lanes,
|
|
68
|
+
each resuming from its own frontier; every down frame carries the
|
|
69
|
+
`sessionId` it belongs to; pushes and presence route by it. One
|
|
70
|
+
heartbeat, one connection, all of the client's sessions.
|
|
71
|
+
|
|
72
|
+
Auth has two moments. `before` runs for the upgrade itself
|
|
73
|
+
(`intent.type === 'ws-upgrade'`), while the request is still a request;
|
|
74
|
+
return a Response to refuse and no socket ever opens. It then runs
|
|
75
|
+
again for every subscribe and push frame: each subscribe arrives as a
|
|
76
|
+
`stream` intent, each push as a `push` intent, with `request` always
|
|
77
|
+
the original upgrade Request. A Response cannot cross a socket, so a
|
|
78
|
+
denial answers in the wire's own vocabulary: a denied subscribe gets an
|
|
79
|
+
`unsubscribed` notice (every other session on the socket streams on), a
|
|
80
|
+
denied push a non-retryable error ack. Socket presence frames are
|
|
81
|
+
fire-and-forget and never become intents; the presence plane's policy
|
|
82
|
+
seam on every wire is `validatePush`.
|
|
83
|
+
|
|
84
|
+
Two more options ride along: `presence: true` interleaves presence with
|
|
85
|
+
events on every lane, the same opt-in as `stream()`, and `deadline`
|
|
86
|
+
(epoch milliseconds) closes the socket cleanly ahead of a known
|
|
87
|
+
platform deadline, so clients reconnect on your schedule.
|
|
79
88
|
|
|
80
89
|
## The client
|
|
81
90
|
|
|
@@ -105,9 +114,9 @@ are the same machinery above the wire seam.
|
|
|
105
114
|
A socket closes when the platform ends the function invocation, or
|
|
106
115
|
when the server closes it deliberately ahead of a known deadline. The
|
|
107
116
|
client treats every close the same way it treats a dropped SSE stream:
|
|
108
|
-
reconnect with backoff,
|
|
109
|
-
fresh presence
|
|
110
|
-
disconnect. A push whose socket died before the ack rejects as
|
|
117
|
+
reconnect with backoff, re-subscribe every session at its own frontier,
|
|
118
|
+
receive fresh presence snapshots, re-send its own presence fields set
|
|
119
|
+
since the disconnect. A push whose socket died before the ack rejects as
|
|
111
120
|
retryable: nothing was acknowledged, and if the append had already
|
|
112
121
|
committed, the client-generated event ids make the retry an idempotent
|
|
113
122
|
replay (you get the original events back). Push retries wait for the
|