experimental-a2 0.4.0 → 0.5.1
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 +66 -0
- package/dist/{ai-B4YhEnfw.d.ts → ai-D_PGS-JR.d.ts} +3 -2
- package/dist/ai-D_PGS-JR.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 +2 -2
- 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 +3 -2
- 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-YtPq7hjw.d.ts → server-DpvjhdoE.d.ts} +6 -5
- package/dist/server-DpvjhdoE.d.ts.map +1 -0
- package/dist/{server-CcNnFnoW.js → server-Duw6MVlB.js} +112 -54
- package/dist/server-Duw6MVlB.js.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 → telemetry-CpeclqB2.d.ts} +4 -3
- package/dist/telemetry-CpeclqB2.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 +54 -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/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
|
|
@@ -123,8 +123,8 @@ Server-only by construction: `experimental-a2/server` is the only entry point th
|
|
|
123
123
|
can reach a store backend, and its exports map resolves to a loud error
|
|
124
124
|
under the browser condition.
|
|
125
125
|
|
|
126
|
-
`validatePush(context)` runs only for input that
|
|
127
|
-
once per plane. The events plane invokes it with `{ sessionId, events }` before
|
|
126
|
+
`validatePush(context)` runs only for input that arrived over the wire
|
|
127
|
+
through `handle`'s push lane, once per plane. The events plane invokes it with `{ sessionId, events }` before
|
|
128
128
|
contract schema validation and before the store append, so throwing rejects the
|
|
129
129
|
complete push without writing anything. The presence plane invokes it with
|
|
130
130
|
`{ sessionId, events: [], presence }`, the whole pushed patch with its
|
|
@@ -132,7 +132,7 @@ participant, before field validation and the broadcast, so authorizing the
|
|
|
132
132
|
participant id (and applying any size or cardinality policy) happens at the
|
|
133
133
|
same seam. The patch arrives frozen: authorize, don't rewrite (a mutation
|
|
134
134
|
attempt throws and fails the push). Direct trusted server appends, handler appends, and server-side
|
|
135
|
-
`setPresence` bypass it. `
|
|
135
|
+
`setPresence` bypass it. The envelope parser inside `handle` creates
|
|
136
136
|
the runtime provenance brand after reading the envelope; a caller-supplied
|
|
137
137
|
field with the same name is ignored, and the brand is not stored in the log.
|
|
138
138
|
|
|
@@ -428,8 +428,8 @@ The only way to move a session forward. Payloads are validated against
|
|
|
428
428
|
the contract's schemas before anything is written. A multi-event append
|
|
429
429
|
is atomic: all-or-nothing, consecutive positions, one transaction. Pass
|
|
430
430
|
`id` to make an append idempotent across retries; re-sending an
|
|
431
|
-
identical batch returns the original rows. (Events
|
|
432
|
-
`
|
|
431
|
+
identical batch returns the original rows. (Events that arrived through
|
|
432
|
+
`handle`'s push lane are accepted directly.) See
|
|
433
433
|
[Durability](/concepts/durability).
|
|
434
434
|
|
|
435
435
|
After the write, A2 dispatches only event types with registered handlers.
|
|
@@ -537,14 +537,14 @@ session.stream(options: {
|
|
|
537
537
|
|
|
538
538
|
A live feed of the session's events. `startAfter` is a non-negative safe integer;
|
|
539
539
|
`startAfter: 20` begins with event 21.
|
|
540
|
-
Server-side only;
|
|
541
|
-
dispatches handlers.
|
|
540
|
+
Server-side only; `handle` exposes it over SSE, and over the multiplexed
|
|
541
|
+
socket when `upgrade` is set. Subscribing never dispatches handlers.
|
|
542
542
|
|
|
543
543
|
With `presence: true`, the feed yields one `PresenceSnapshot` first:
|
|
544
544
|
`{ snapshot }`, the current pruned map with each field's own `value`,
|
|
545
545
|
`seen`, and `at` stamp. Live presence patches then interleave with
|
|
546
|
-
events.
|
|
547
|
-
don't know them skip them. The return type widens only under the
|
|
546
|
+
events. The stream response sends both as named SSE frames, so clients
|
|
547
|
+
that don't know them skip them. The return type widens only under the
|
|
548
548
|
literal `presence: true`; without it, existing consumers keep
|
|
549
549
|
`AsyncIterable<Event>`. The option itself exists only on sessions of
|
|
550
550
|
contracts that declare `presence`; elsewhere it is a type error, not a
|
|
@@ -580,9 +580,9 @@ by backend TTL when a participant goes silent (default 60 seconds; set
|
|
|
580
580
|
the storage clock, not the sender stamp, so a hostile stamp can only
|
|
581
581
|
vandalize its own field and still expires on schedule.
|
|
582
582
|
|
|
583
|
-
The patch is structurally the `presence`
|
|
584
|
-
|
|
585
|
-
API mirrors the wire.
|
|
583
|
+
The patch is structurally the `presence` sibling of the push envelope;
|
|
584
|
+
`handle`'s push lane forwards it whole to `session.setPresence(presence)`.
|
|
585
|
+
The API mirrors the wire.
|
|
586
586
|
|
|
587
587
|
The handler-scoped form `ctx.session.setPresence(...)` is the same
|
|
588
588
|
operation; `seen` defaults to the triggering event's `index`.
|
|
@@ -666,16 +666,32 @@ not affect state hydration or stream resumption.
|
|
|
666
666
|
### `useSession()`
|
|
667
667
|
|
|
668
668
|
```ts
|
|
669
|
-
const { state, push, events, index,
|
|
670
|
-
useSession()
|
|
669
|
+
const { state, push, events, index, loadHistory, history, connection,
|
|
670
|
+
presence, setPresence } = useSession()
|
|
671
671
|
```
|
|
672
672
|
|
|
673
673
|
`state` starts from the initial snapshot and folds live events through the
|
|
674
|
-
shared reducer. `events` is the raw
|
|
675
|
-
|
|
674
|
+
shared reducer. `events` is the raw event feed: observed, explicitly
|
|
675
|
+
seeded, or backscrolled. Unless `initialEvents` seeds earlier entries or
|
|
676
|
+
`loadHistory` fetches them, it begins after `initialIndex`. `index` is
|
|
676
677
|
the stream frontier, the `lastSeenIndex` for
|
|
677
678
|
[cancellation](/guides/cancellation).
|
|
678
679
|
|
|
680
|
+
`loadHistory({ before?, limit? })` backscrolls: it fetches a bounded
|
|
681
|
+
slice of the log from below the frontier (the same route, `gte`/`lte`
|
|
682
|
+
query parameters) and merges it into `events`, deduped, ordered, and
|
|
683
|
+
shared across every handle of the session. It resolves with the events
|
|
684
|
+
in the requested range. Defaults walk backward 50 at a time from the
|
|
685
|
+
oldest loaded event; `before` is an exclusive upper bound. After a
|
|
686
|
+
hydrate jump (returning to a session whose frontier advanced while
|
|
687
|
+
away), default paging still continues from the oldest loaded event;
|
|
688
|
+
pass an explicit `before` to fill the gap between the old feed and the
|
|
689
|
+
new frontier. It never touches `state` or the optimistic overlay. Calls
|
|
690
|
+
serialize per session and already-loaded ranges are not refetched.
|
|
691
|
+
`history` is the progress: `{ loading, complete, oldestLoaded }`, where
|
|
692
|
+
`complete` means the feed reaches index 1 (or the log is empty). The `ws` api has no history lane; there `loadHistory`
|
|
693
|
+
throws a `TypeError`.
|
|
694
|
+
|
|
679
695
|
`presence` is the replicated ephemeral map,
|
|
680
696
|
`Record<participantId, { [field]: { value, seen, at } }>`, including
|
|
681
697
|
this client. `setPresence(values)` is fire-and-forget: validated
|
|
@@ -1032,10 +1048,10 @@ subscription with frontier resume and reconnection, the optimistic push
|
|
|
1032
1048
|
queue with ack/rollback, and the local fold. `client.session(id, {
|
|
1033
1049
|
initialState?, initialIndex?, initialEvents?, participant? })` returns a
|
|
1034
1050
|
handle with `getSnapshot()`/`subscribe()` (the `useSyncExternalStore`
|
|
1035
|
-
contract), `push()`, `connect()`, and `close()`.
|
|
1036
|
-
`state`, `events`, `index`, and `connection`
|
|
1037
|
-
`useSession` exposes), and `push` returns the same
|
|
1038
|
-
result. On contracts that declare `presence` the handle also carries
|
|
1051
|
+
contract), `push()`, `loadHistory()`, `connect()`, and `close()`.
|
|
1052
|
+
Snapshots carry `state`, `events`, `index`, `history`, and `connection`
|
|
1053
|
+
(the same fields `useSession` exposes), and `push` returns the same
|
|
1054
|
+
ack-then-`confirmed` result. On contracts that declare `presence` the handle also carries
|
|
1039
1055
|
`setPresence()` and snapshots carry the `presence` map, exactly like
|
|
1040
1056
|
the hook; `participant` is the identity `setPresence` sends under. Use
|
|
1041
1057
|
it directly from any other framework, or none.
|
|
@@ -1051,13 +1067,77 @@ across route transitions, while IndexedDB preserves the replica across reloads.
|
|
|
1051
1067
|
|
|
1052
1068
|
## `experimental-a2/http`
|
|
1053
1069
|
|
|
1070
|
+
### `handle(server, options?)`
|
|
1071
|
+
|
|
1072
|
+
```ts
|
|
1073
|
+
handle(server: A2Server, options?: {
|
|
1074
|
+
before?(args: { request: Request; intent: A2Intent }):
|
|
1075
|
+
Response | undefined | void | Promise<Response | undefined | void>
|
|
1076
|
+
after?(args: { request: Request; intent: A2Intent; outcome: A2Outcome; response: Response }):
|
|
1077
|
+
Response | undefined | void | Promise<Response | undefined | void>
|
|
1078
|
+
upgrade?: UpgradeFn // e.g. (attach) => experimental_upgradeWebSocket(attach)
|
|
1079
|
+
presence?: boolean // interleave presence on every stream lane
|
|
1080
|
+
deadline?: number // epoch ms: close sockets cleanly before it
|
|
1081
|
+
}): { GET(req: Request): Promise<Response>; POST(req: Request): Promise<Response> }
|
|
1082
|
+
|
|
1083
|
+
type A2Intent =
|
|
1084
|
+
| { type: 'ws-upgrade' }
|
|
1085
|
+
| { type: 'stream'; sessionId: string; startAfter: number; transport: 'sse' | 'ws' }
|
|
1086
|
+
| { type: 'history'; sessionId: string; gte: number; lte: number }
|
|
1087
|
+
| { type: 'push'; sessionId: string; events: PushedEvent[]; presence?: PushedPresence; transport: 'http' | 'ws' }
|
|
1088
|
+
|
|
1089
|
+
type A2Outcome =
|
|
1090
|
+
| { type: 'stream' }
|
|
1091
|
+
| { type: 'history'; covered: boolean; events: Event[] }
|
|
1092
|
+
| { type: 'push'; appended: Event[] }
|
|
1093
|
+
```
|
|
1094
|
+
|
|
1095
|
+
The session route pair as one call: `export const { GET, POST } =
|
|
1096
|
+
handle(server)` in a route module (any framework speaking
|
|
1097
|
+
`(req: Request) => Promise<Response>`). A plain `GET` is the live SSE
|
|
1098
|
+
stream, resumed after the `index` query parameter, with a `: connected`
|
|
1099
|
+
prelude, a `: ping` heartbeat every 15s, and a clean close one second
|
|
1100
|
+
before an ambient Vercel invocation deadline when available; presence
|
|
1101
|
+
patches ride as named frames when `presence: true`. A `GET` with
|
|
1102
|
+
`gte`/`lte` query parameters is a history slice: the closed log range
|
|
1103
|
+
as JSON wire events, the read `loadHistory` rides. `POST` is the push
|
|
1104
|
+
envelope `{ sessionId, events, presence? }`, answered with the appended
|
|
1105
|
+
events. A `GET` carrying an upgrade header becomes the multiplexed
|
|
1106
|
+
WebSocket when `options.upgrade` is present, and answers `426` when it
|
|
1107
|
+
is not.
|
|
1108
|
+
|
|
1109
|
+
Parsing is protocol, hooks are policy. A request that fails to parse
|
|
1110
|
+
(missing `sessionId`, malformed bounds, a bad push envelope) answers
|
|
1111
|
+
`INVALID_PAYLOAD` on the wire before any hook runs. `before` sees every
|
|
1112
|
+
parsed intent, HTTP requests and socket frames alike; over the socket,
|
|
1113
|
+
each subscribe arrives as a `stream` intent and each push as a `push`
|
|
1114
|
+
intent, with `request` always the original upgrade Request. Returning a
|
|
1115
|
+
Response short-circuits: over HTTP it is the response, verbatim; over
|
|
1116
|
+
the socket it is translated into the wire's own vocabulary (a denied
|
|
1117
|
+
subscribe answers `unsubscribed`, a denied push a non-retryable error
|
|
1118
|
+
ack), because a Response cannot cross a socket.
|
|
1119
|
+
|
|
1120
|
+
`after` runs only where the library produced an HTTP response: never
|
|
1121
|
+
after a short-circuit, never for `ws-upgrade` or socket frames. It may
|
|
1122
|
+
mutate `response.headers` in place or return a replacement Response.
|
|
1123
|
+
`outcome.covered` on a history read means the closed range came back
|
|
1124
|
+
fully covered (`events.length === lte - gte + 1`): an immutable slice
|
|
1125
|
+
of the append-only log, safe to cache under whatever policy your
|
|
1126
|
+
`after` applies. The history response carries no cache headers of its
|
|
1127
|
+
own.
|
|
1128
|
+
|
|
1129
|
+
The socket is one connection for all of a client's sessions:
|
|
1130
|
+
`subscribe`/`unsubscribe` frames open and close per-session lanes at
|
|
1131
|
+
their own resume frontiers, `sessionId` tags route pushes, presence,
|
|
1132
|
+
and acks, and a lane ending or failing answers `unsubscribed` without
|
|
1133
|
+
taking the socket down. See [Transports](/guides/transports).
|
|
1134
|
+
|
|
1135
|
+
### The rest of the entry
|
|
1136
|
+
|
|
1054
1137
|
| Helper | What it does |
|
|
1055
1138
|
| ----------------------- | ------------------------------------------------------------------------------ |
|
|
1056
1139
|
| `schedulerHandler(...servers)` | returns the delivery route after synchronously verifying one shared scheduler |
|
|
1057
|
-
| `
|
|
1058
|
-
| `sseResponse(iterable)` | pipes a `session.stream()` iterable into an SSE `Response`, with a `: connected` prelude, a `: ping` heartbeat every 15s, and a clean close one second before an ambient Vercel invocation deadline when available; presence patches ride as named `presence` frames |
|
|
1059
|
-
| `sessionSocket(session, socket, options?)` | speaks the A2 wire over any `ws`-shaped socket: the stream pumps down as JSON frames, pushes and presence come up with the same validation and `validatePush` seam as the POST route; `options` carries `startAfter`, `presence`, and an optional `deadline` for clean pre-deadline closes. See [Transports](/guides/transports) |
|
|
1060
|
-
| `errorResponse(err)` | serializes an `A2Error` to `{ error: { code, message, details } }` + status |
|
|
1140
|
+
| `errorResponse(err)` | serializes an `A2Error` to `{ error: { code, message, details } }` + status; the natural return value of a refusing `before` hook |
|
|
1061
1141
|
| `deserializeError(body)` | rebuilds an `A2Error` from a wire body, or `null` if the body isn't one |
|
|
1062
1142
|
|
|
1063
1143
|
`schedulerHandler(...servers)` is the application-facing scheduler route. It
|
|
@@ -1071,8 +1151,9 @@ Different scheduler instances use different routes. Match each QStash route to
|
|
|
1071
1151
|
that instance's resolved `url`; additional QStash routes pass an explicit
|
|
1072
1152
|
`url`. Match each Vercel Queues route and trigger to that instance's `topic`.
|
|
1073
1153
|
|
|
1074
|
-
|
|
1075
|
-
push
|
|
1154
|
+
`errorResponse` and `deserializeError` are the `A2Error` wire format
|
|
1155
|
+
that `push` and the push lane share. See
|
|
1156
|
+
[Errors](/reference/errors#over-the-wire).
|
|
1076
1157
|
|
|
1077
1158
|
## `experimental-a2/cache-indexeddb`
|
|
1078
1159
|
|
|
@@ -78,5 +78,7 @@ with a mapped status: `INVALID_PAYLOAD`, `UNKNOWN_EVENT_TYPE`, and
|
|
|
78
78
|
|
|
79
79
|
The client's `push` deserializes the body back into an `A2Error`, so client
|
|
80
80
|
and server code branch on identical codes. `push` auto-retries only
|
|
81
|
-
`STORE_UNAVAILABLE`. The serializer pair
|
|
82
|
-
`
|
|
81
|
+
`STORE_UNAVAILABLE`. The serializer pair, `errorResponse` and
|
|
82
|
+
`deserializeError`, ships in `experimental-a2/http` alongside `handle`;
|
|
83
|
+
a `before` hook that wants the wire's own error shapes returns
|
|
84
|
+
`errorResponse(new A2Error(...))`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "experimental-a2",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Durable sync and reactions for things with a lifecycle: one event log, derived state, and live client per session.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"files": [
|
|
24
24
|
"dist",
|
|
25
25
|
"docs",
|
|
26
|
+
"src",
|
|
26
27
|
"CHANGELOG.md"
|
|
27
28
|
],
|
|
28
29
|
"exports": {
|