experimental-a2 0.2.0 → 0.4.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 +169 -0
- package/dist/ai-B4YhEnfw.d.ts +333 -0
- package/dist/ai-server.d.ts +48 -9
- package/dist/ai-server.js +121 -49
- package/dist/ai.d.ts +2 -303
- package/dist/ai.js +231 -86
- package/dist/cli-B3VuxoDe.js +597 -0
- package/dist/cli-bin.d.ts +1 -0
- package/dist/cli-bin.js +5 -0
- package/dist/cli.d.ts +19 -0
- package/dist/cli.js +2 -0
- package/dist/client-BrfDXQ8A.d.ts +155 -0
- package/dist/client-Bt4tAKi9.js +798 -0
- package/dist/client.d.ts +2 -90
- package/dist/client.js +1 -409
- package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +10 -2
- package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +62 -8
- package/dist/devtools-J_jZ2vQf.d.ts +151 -0
- package/dist/devtools-kJJaORn-.js +338 -0
- package/dist/devtools-server.browser.js +1 -1
- package/dist/devtools-server.d.ts +2 -2
- package/dist/devtools-server.js +224 -43
- package/dist/devtools.d.ts +2 -0
- package/dist/devtools.js +2 -0
- package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +4 -4
- package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +1 -1
- package/dist/http.d.ts +71 -13
- package/dist/http.js +302 -41
- package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +2 -2
- package/dist/index.d.ts +5 -5
- package/dist/index.js +2 -2
- package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +2 -1
- package/dist/internal-aEotMzu_.js +209 -0
- package/dist/otel.d.ts +1 -1
- package/dist/platform-B4TnJtWu.js +32 -0
- package/dist/react.d.ts +37 -14
- package/dist/react.js +26 -15
- package/dist/scheduler-qstash.d.ts +78 -0
- package/dist/scheduler-qstash.js +499 -0
- package/dist/scheduler-task-BpzhPnRS.js +54 -0
- package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +17 -24
- package/dist/scheduler-vercel.js +226 -0
- package/dist/server-CcNnFnoW.js +1405 -0
- package/dist/server-YtPq7hjw.d.ts +260 -0
- package/dist/server.d.ts +4 -155
- package/dist/server.js +2 -2
- package/dist/{log-ldf5g8Cx.d.ts → store-C3sNAaBT.d.ts} +111 -35
- package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +1 -1
- package/dist/store-codec-DTG0Ftek.js +8 -0
- package/dist/store-memory.d.ts +11 -0
- package/dist/{log-memory.js → store-memory.js} +127 -24
- package/dist/{log-polling-6COoN60V.js → store-polling-DgrrAE3d.js} +7 -6
- package/dist/{log-postgres.d.ts → store-postgres.d.ts} +6 -6
- package/dist/{log-postgres.js → store-postgres.js} +158 -24
- package/dist/{log-redis.js → store-redis-core-DWqx3F47.js} +294 -156
- package/dist/store-redis-http.d.ts +21 -0
- package/dist/store-redis-http.js +70 -0
- package/dist/store-redis.d.ts +37 -0
- package/dist/store-redis.js +298 -0
- package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +6 -6
- package/dist/{log-sqlite.js → store-sqlite.js} +116 -22
- package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +1 -1
- package/dist/testing.browser.d.ts +1 -0
- package/dist/testing.browser.js +4 -0
- package/dist/testing.d.ts +31 -0
- package/dist/testing.js +101 -0
- package/dist/wire-DCUZBUlT.js +222 -0
- package/docs/01-quickstart.mdx +4 -5
- package/docs/concepts/01-contracts.mdx +21 -17
- package/docs/concepts/02-handlers.mdx +7 -7
- package/docs/concepts/03-durability.mdx +26 -29
- package/docs/concepts/04-state.mdx +18 -21
- package/docs/guides/01-timers.mdx +154 -54
- package/docs/guides/02-cancellation.mdx +30 -4
- package/docs/guides/03-react.mdx +20 -21
- package/docs/guides/04-local-first.mdx +1 -1
- package/docs/guides/05-production.mdx +321 -60
- package/docs/guides/06-ai-agents.mdx +249 -49
- package/docs/guides/07-devtools.mdx +137 -12
- package/docs/guides/08-application-data.mdx +1 -1
- package/docs/guides/09-presence.mdx +284 -0
- package/docs/guides/10-transports.mdx +131 -0
- package/docs/index.mdx +22 -46
- package/docs/reference/01-api.mdx +751 -102
- package/docs/reference/02-errors.mdx +10 -5
- package/package.json +23 -6
- package/dist/internal-D6wNxTck.js +0 -36
- package/dist/log-memory.d.ts +0 -11
- package/dist/log-redis.d.ts +0 -31
- package/dist/recovery-vercel.js +0 -119
- package/dist/server-DJgD2YWP.js +0 -877
- package/dist/wire-BVsgR8o9.js +0 -62
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Presence
|
|
3
|
+
description: "Ephemeral participant state on a durable session: cursors, typing, live progress. Presence repaints; the log replays."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Two planes, one session
|
|
7
|
+
|
|
8
|
+
Everything in the log is a fact: stored forever, folded by reducers,
|
|
9
|
+
replayed on demand. Presence is not a fact. "Her cursor is at (312, 88)"
|
|
10
|
+
has a useful lifetime of about two seconds, no replay value, and no
|
|
11
|
+
durability requirement; a lost update is repainted by the next one.
|
|
12
|
+
|
|
13
|
+
The test that separates the planes:
|
|
14
|
+
|
|
15
|
+
> Will anyone, human or reducer, care about this datum after the moment
|
|
16
|
+
> passes? Yes: event, in the log. No: presence.
|
|
17
|
+
|
|
18
|
+
Messages, document updates, cancellations: events. Cursors, selections,
|
|
19
|
+
typing, "who's online", a handler's live progress: presence.
|
|
20
|
+
|
|
21
|
+
A2 gives presence its own plane on the session you already have. Same
|
|
22
|
+
contract, same route, same auth, same stream. Each participant owns a
|
|
23
|
+
small ephemeral state; A2 replicates the latest value of each field to
|
|
24
|
+
everyone subscribed and stores none of it in the log. Reducers never see
|
|
25
|
+
it, `history()` never returns it, and the browser cache never persists
|
|
26
|
+
it. One invariant covers the whole design:
|
|
27
|
+
|
|
28
|
+
> **Presence repaints; the log replays.** Anything that decides (a fold,
|
|
29
|
+
> a retry, an abort match) reads the log. Presence only hurries pixels.
|
|
30
|
+
|
|
31
|
+
## Declare it in the contract
|
|
32
|
+
|
|
33
|
+
`presence` is a second vocabulary next to `events`: each key is a field
|
|
34
|
+
of one participant's ephemeral state, each value a schema for it.
|
|
35
|
+
|
|
36
|
+
```ts contracts.ts
|
|
37
|
+
import { z } from 'zod'
|
|
38
|
+
import * as a2 from 'experimental-a2'
|
|
39
|
+
|
|
40
|
+
export const canvas = a2.contract({
|
|
41
|
+
name: 'canvas',
|
|
42
|
+
events: {
|
|
43
|
+
'shape.created': z.object({
|
|
44
|
+
id: z.string(),
|
|
45
|
+
kind: z.enum(['rect', 'ellipse']),
|
|
46
|
+
x: z.number(),
|
|
47
|
+
y: z.number(),
|
|
48
|
+
}),
|
|
49
|
+
'shape.moved': z.object({ id: z.string(), x: z.number(), y: z.number() }),
|
|
50
|
+
},
|
|
51
|
+
presence: {
|
|
52
|
+
cursor: z.object({ x: z.number(), y: z.number() }),
|
|
53
|
+
dragging: z.object({ shapeId: z.string(), x: z.number(), y: z.number() }),
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The field is the unit of replication: last writer wins per participant
|
|
59
|
+
per field. Setting `cursor` does not touch `dragging`. Fields are absent
|
|
60
|
+
until a participant sets them, and clear when set to `null` or when
|
|
61
|
+
their backend TTL lapses (a closed tab disappears by silence, no
|
|
62
|
+
explicit leave required).
|
|
63
|
+
|
|
64
|
+
## The route
|
|
65
|
+
|
|
66
|
+
The same two handlers as [Live UI](/guides/react), with one option and
|
|
67
|
+
one branch. `stream({ presence: true })` interleaves presence patches
|
|
68
|
+
with events on the SSE response, starting with a snapshot of the current
|
|
69
|
+
map. The push body grows an optional `presence` sibling to `events`.
|
|
70
|
+
|
|
71
|
+
```ts app/api/canvas-events/route.ts
|
|
72
|
+
import { A2Error } from 'experimental-a2'
|
|
73
|
+
import { errorResponse, parsePushBody, sseResponse } from 'experimental-a2/http'
|
|
74
|
+
import { canvasServer } from '@/server/canvas'
|
|
75
|
+
|
|
76
|
+
export async function GET(req: Request) {
|
|
77
|
+
const { searchParams } = new URL(req.url)
|
|
78
|
+
const sessionId = searchParams.get('sessionId')
|
|
79
|
+
if (!sessionId) {
|
|
80
|
+
return errorResponse(new A2Error('INVALID_PAYLOAD', 'missing sessionId'))
|
|
81
|
+
}
|
|
82
|
+
const startAfter = Number(searchParams.get('index')) || 0
|
|
83
|
+
|
|
84
|
+
// here's where you'd do auth, or any other checks
|
|
85
|
+
|
|
86
|
+
return sseResponse(
|
|
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
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`setPresence` validates each field against the contract, then
|
|
112
|
+
broadcasts. No append transaction, no dispatch, no scheduler arm, no log
|
|
113
|
+
row. A bad field throws `INVALID_PAYLOAD`; an unknown field throws
|
|
114
|
+
`UNKNOWN_PRESENCE_FIELD`; nothing is broadcast on either.
|
|
115
|
+
|
|
116
|
+
## The browser
|
|
117
|
+
|
|
118
|
+
The provider takes a `participant` id (yours to mint: a user id, a tab
|
|
119
|
+
nonce, the playground's guest name). The hook grows two members. The
|
|
120
|
+
session module is the usual pair from
|
|
121
|
+
[Live UI](/guides/react#the-session-module), bound to a reducer that
|
|
122
|
+
carries the canvas contract:
|
|
123
|
+
|
|
124
|
+
```ts app/canvas/[canvasId]/session.ts
|
|
125
|
+
'use client'
|
|
126
|
+
import { createClient } from 'experimental-a2/client'
|
|
127
|
+
import { createReact } from 'experimental-a2/react'
|
|
128
|
+
import { canvas } from '@/contracts'
|
|
129
|
+
|
|
130
|
+
// a minimal scene fold; the real one lives with your reducers
|
|
131
|
+
const sceneReducer = canvas
|
|
132
|
+
.reducer({ name: 'scene', initialState: {} })
|
|
133
|
+
.fold((state) => state)
|
|
134
|
+
|
|
135
|
+
export const canvasClient = createClient({
|
|
136
|
+
reducer: sceneReducer,
|
|
137
|
+
api: '/api/canvas-events',
|
|
138
|
+
})
|
|
139
|
+
export const { SessionProvider, useSession } = createReact({
|
|
140
|
+
client: canvasClient,
|
|
141
|
+
})
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
```tsx app/canvas/[canvasId]/canvas-client.tsx
|
|
145
|
+
'use client'
|
|
146
|
+
import { useSession } from './session'
|
|
147
|
+
|
|
148
|
+
export function CanvasClient() {
|
|
149
|
+
const { presence, setPresence } = useSession()
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<div
|
|
153
|
+
onPointerMove={(e) => setPresence({ cursor: { x: e.clientX, y: e.clientY } })}
|
|
154
|
+
>
|
|
155
|
+
{Object.entries(presence).map(([participant, fields]) =>
|
|
156
|
+
// render-time expiry: a cursor silent for a few seconds is gone
|
|
157
|
+
fields.cursor && Date.now() - fields.cursor.at.getTime() < 5_000 ? (
|
|
158
|
+
<Cursor key={participant} {...fields.cursor.value} />
|
|
159
|
+
) : null,
|
|
160
|
+
)}
|
|
161
|
+
</div>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// your cursor sprite:
|
|
166
|
+
// function Cursor({ x, y }: { x: number; y: number }) { ... }
|
|
167
|
+
const Cursor = (props: { x: number; y: number }) => null
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
- **`setPresence(values)`** is fire-and-forget: validated locally,
|
|
171
|
+
merged into the local map immediately, coalesced on the wire to a
|
|
172
|
+
fixed cadence. The first set sends immediately; within the interval,
|
|
173
|
+
later values merge field by field (latest wins, `null` included) and
|
|
174
|
+
one trailing send carries the merged batch, so a 60Hz pointer becomes
|
|
175
|
+
about fifteen sends per second and losing one is repainted by the
|
|
176
|
+
next. Sends may overlap in flight; that is safe because merges are
|
|
177
|
+
last-writer-wins by `at` per field. No ack, no `confirmed`, no retry,
|
|
178
|
+
no optimistic rollback: there is nothing to roll back, because
|
|
179
|
+
nothing was ever true.
|
|
180
|
+
- **`presence`** is the replicated map,
|
|
181
|
+
`Record<participantId, { [field]: { value, seen, at } }>`, including
|
|
182
|
+
your own participant. `at` is the sender's stamp for when the value
|
|
183
|
+
was set, and the last-writer-wins comparator; the client stamps its
|
|
184
|
+
sends monotonically, so overlapping sends can never invert. How long
|
|
185
|
+
a cursor stays painted is view logic, decided at render time. Wall
|
|
186
|
+
clocks never enter folds, and presence is not folded state.
|
|
187
|
+
|
|
188
|
+
Set a field to `null` to clear it (pointer leaves the canvas, blur,
|
|
189
|
+
unmount). Everything else expires by TTL when a tab goes silent: 60
|
|
190
|
+
seconds by default, set `createServer({ presence: { ttlMs } })` to
|
|
191
|
+
change it. Expiry counts on the backend's clock, not the sender's
|
|
192
|
+
stamp.
|
|
193
|
+
|
|
194
|
+
Both members are typed from the contract's `presence` map, through the
|
|
195
|
+
reducer, with no type arguments: field names, payloads, and `null`
|
|
196
|
+
clears all autocomplete. On a contract without `presence` the members
|
|
197
|
+
do not exist, so reaching for them is a compile error, not a silent
|
|
198
|
+
no-op. One symptom worth knowing: if the map holds only your own
|
|
199
|
+
echo while others are active, the GET route forgot `presence: true`.
|
|
200
|
+
|
|
201
|
+
## Reconciling the planes: `seen`
|
|
202
|
+
|
|
203
|
+
Every presence value carries `seen`, the sender's stream frontier at the
|
|
204
|
+
moment it was set. The client stamps it automatically. This is what lets
|
|
205
|
+
the two planes agree on an order without wall clocks.
|
|
206
|
+
|
|
207
|
+
The classic race: a participant drags a shape (presence), drops it
|
|
208
|
+
(event), and a stale `dragging` patch arrives after the durable
|
|
209
|
+
`shape.moved`. Without a shared coordinate system the shape flickers
|
|
210
|
+
backwards. With `seen`, superseding is one render-time rule:
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
// in render code: a ghost is dead once the log has moved past it.
|
|
214
|
+
// show fields.dragging only if
|
|
215
|
+
// fields.dragging.seen >= state.shapes[fields.dragging.value.shapeId].movedAt
|
|
216
|
+
// where movedAt is the index your reducer recorded for the last shape.moved
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
The durable event at index 214 beats any presence stamped 213 or lower,
|
|
220
|
+
deterministically. A2 carries the stamp and exposes the frontiers; the
|
|
221
|
+
comparison is yours, in view code, next to the pixels it protects.
|
|
222
|
+
|
|
223
|
+
## Handlers broadcast too
|
|
224
|
+
|
|
225
|
+
`ctx.session.setPresence` is the same session operation. A handler
|
|
226
|
+
grinding through work can publish live progress without writing
|
|
227
|
+
non-facts to the log:
|
|
228
|
+
|
|
229
|
+
```ts server/canvas.ts
|
|
230
|
+
import { createServer } from 'experimental-a2/server'
|
|
231
|
+
import { canvas } from '@/contracts'
|
|
232
|
+
|
|
233
|
+
export const canvasServer = createServer({
|
|
234
|
+
contract: canvas,
|
|
235
|
+
handlers: {
|
|
236
|
+
'shape.created': async (ctx) => {
|
|
237
|
+
// long work; broadcast progress as it goes:
|
|
238
|
+
// await ctx.session.setPresence({
|
|
239
|
+
// participant: 'system:thumbnails',
|
|
240
|
+
// values: { progress: { step: 2, of: 5 } },
|
|
241
|
+
// })
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
})
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
The participant id is the handler's choice; a `system:` prefix keeps
|
|
248
|
+
robots and humans apart in the map. Append the durable outcome as an
|
|
249
|
+
event when the work completes; the progress was never a fact, the result
|
|
250
|
+
is.
|
|
251
|
+
|
|
252
|
+
## What presence never does
|
|
253
|
+
|
|
254
|
+
- Never appears in `history()`, `state()`, or a reducer's input type.
|
|
255
|
+
The separation is structural: presence fields are not events, so a
|
|
256
|
+
fold cannot reach them even by accident.
|
|
257
|
+
- Never arms the scheduler, takes a claim, or retries. A crashed presence
|
|
258
|
+
update is a non-event, literally.
|
|
259
|
+
- Never enters the IndexedDB cache. Cache the log, never the moment.
|
|
260
|
+
- Never blocks an append. The planes share a route, not a transaction.
|
|
261
|
+
|
|
262
|
+
## Backends
|
|
263
|
+
|
|
264
|
+
Presence needs a broadcast primitive, and backends differ. Declaring
|
|
265
|
+
`presence` in a contract requires a store with the presence capability;
|
|
266
|
+
`createServer` throws `PRESENCE_NOT_SUPPORTED` at construction
|
|
267
|
+
otherwise. All built-in stores have it, at two tiers:
|
|
268
|
+
|
|
269
|
+
| Backend | Delivery |
|
|
270
|
+
| --- | --- |
|
|
271
|
+
| `store-redis` | push: TTL'd hash plus pub/sub patch, single-digit ms to parked subscribers |
|
|
272
|
+
| `store-memory` | in-process, immediate |
|
|
273
|
+
| `store-postgres`, `store-sqlite`, `store-redis-http` | degraded: bounded TTL'd rows, read on the live feed's poll cadence |
|
|
274
|
+
|
|
275
|
+
Degraded means later, not lost while watched: presence-only traffic
|
|
276
|
+
surfaces on a fixed 250ms re-read tick plus event wakes; the 25ms
|
|
277
|
+
adaptive floor engages only while events flow. The push tier never
|
|
278
|
+
broadcasts TTL expiry: a participant that departs silently keeps their
|
|
279
|
+
last values in connected clients' maps until those clients reconnect,
|
|
280
|
+
which is why the render-time expiry above is the guard. Cursors want
|
|
281
|
+
the push tier; typing indicators and progress read fine on either. The presence rows are
|
|
282
|
+
bounded per session and participant, so this is not log growth in
|
|
283
|
+
disguise, but on metered backends every patch is still a network
|
|
284
|
+
operation. Presence makes ephemeral state correct, not free.
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Transports
|
|
3
|
+
description: "One client, three wires: a single HTTP route, split routes for per-route timeouts, or one WebSocket for both directions."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## One wire seam, same client
|
|
7
|
+
|
|
8
|
+
Everything the client does is transport-agnostic: optimistic pushes
|
|
9
|
+
with ack and `confirmed`, frontier-resumed live reads, reconnection
|
|
10
|
+
with backoff, the presence throttle and re-send. `api` picks the wire
|
|
11
|
+
underneath it:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// in a 'use client' session module:
|
|
15
|
+
api: '/api/order-events'
|
|
16
|
+
api: { type: 'http', push: '/api/order-push', stream: '/api/order-stream' }
|
|
17
|
+
api: { type: 'ws', url: '/api/order-events' }
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
- **The string** is the default and the recommendation: one route
|
|
21
|
+
serving GET (SSE stream) and POST (push), one file, one auth surface.
|
|
22
|
+
Everything else in these docs uses it.
|
|
23
|
+
- **`http` split** serves the two verbs from two routes. Use it when
|
|
24
|
+
platform duration limits differ per verb: the stream is a long-lived
|
|
25
|
+
read that wants a high `maxDuration`, the push is a short write that
|
|
26
|
+
doesn't.
|
|
27
|
+
- **`ws`** rides everything over one WebSocket: the stream comes down
|
|
28
|
+
it, pushes and presence go up it. Use it when latency or per-message
|
|
29
|
+
cost matters; at a presence cadence of fifteen sends a second, each
|
|
30
|
+
send is a socket frame instead of a route invocation.
|
|
31
|
+
|
|
32
|
+
A split socket is unrepresentable on purpose. The socket is one
|
|
33
|
+
connection in both directions; there is nothing left to split.
|
|
34
|
+
|
|
35
|
+
## The WebSocket route
|
|
36
|
+
|
|
37
|
+
The same route can serve both transports by branching on the upgrade
|
|
38
|
+
header. Auth runs before the upgrade, while the request is still a
|
|
39
|
+
request.
|
|
40
|
+
|
|
41
|
+
```ts app/api/order-events/route.ts
|
|
42
|
+
import { experimental_upgradeWebSocket } from '@vercel/functions'
|
|
43
|
+
import { A2Error } from 'experimental-a2'
|
|
44
|
+
import { errorResponse, sessionSocket, sseResponse } from 'experimental-a2/http'
|
|
45
|
+
import { ordersServer } from '@/server/orders'
|
|
46
|
+
|
|
47
|
+
export async function GET(req: Request) {
|
|
48
|
+
const { searchParams } = new URL(req.url)
|
|
49
|
+
const sessionId = searchParams.get('sessionId')
|
|
50
|
+
if (!sessionId) {
|
|
51
|
+
return errorResponse(new A2Error('INVALID_PAYLOAD', 'missing sessionId'))
|
|
52
|
+
}
|
|
53
|
+
const startAfter = Number(searchParams.get('index')) || 0
|
|
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 }),
|
|
60
|
+
// ws defaults to 100 MiB per frame; POST bodies cap at about
|
|
61
|
+
// 4.5 MB on the platform. Keep the two ingress paths at parity.
|
|
62
|
+
{ maxPayload: 4 * 1024 * 1024 },
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
return sseResponse(ordersServer.session(sessionId).stream({ startAfter }))
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`sessionSocket` speaks the whole protocol against any `ws`-shaped
|
|
70
|
+
socket: it pumps the session's stream down as JSON frames, accepts push
|
|
71
|
+
and presence frames up, validates them exactly as `parsePushBody` does
|
|
72
|
+
(same provenance brand, same `validatePush` calls, once per plane), and
|
|
73
|
+
answers each push with its ack. POST keeps working unchanged next to
|
|
74
|
+
it; a `ws` client never calls it, an `http` client never upgrades. The
|
|
75
|
+
transports are additive.
|
|
76
|
+
|
|
77
|
+
Contracts that declare presence pass `{ presence: true }` in the
|
|
78
|
+
options, the same opt-in as `stream()`.
|
|
79
|
+
|
|
80
|
+
## The client
|
|
81
|
+
|
|
82
|
+
```ts app/orders/[orderId]/session.ts
|
|
83
|
+
'use client'
|
|
84
|
+
import { createClient } from 'experimental-a2/client'
|
|
85
|
+
import { createReact } from 'experimental-a2/react'
|
|
86
|
+
import { ordersReducer } from '@/reducer'
|
|
87
|
+
|
|
88
|
+
export const ordersClient = createClient({
|
|
89
|
+
reducer: ordersReducer,
|
|
90
|
+
api: { type: 'ws', url: '/api/order-events' },
|
|
91
|
+
})
|
|
92
|
+
export const { SessionProvider, useSession } = createReact({
|
|
93
|
+
client: ordersClient,
|
|
94
|
+
})
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Nothing else changes. `useSession` is identical, `push` returns the
|
|
98
|
+
same ack and `confirmed`, presence coalesces on the same cadence. The
|
|
99
|
+
connection status, the stall watchdog (two missed heartbeats), and
|
|
100
|
+
reconnection with backoff all behave as they do over SSE, because they
|
|
101
|
+
are the same machinery above the wire seam.
|
|
102
|
+
|
|
103
|
+
## Lifecycle
|
|
104
|
+
|
|
105
|
+
A socket closes when the platform ends the function invocation, or
|
|
106
|
+
when the server closes it deliberately ahead of a known deadline. The
|
|
107
|
+
client treats every close the same way it treats a dropped SSE stream:
|
|
108
|
+
reconnect with backoff, resume from the current frontier, receive a
|
|
109
|
+
fresh presence snapshot, re-send its own presence fields set since the
|
|
110
|
+
disconnect. A push whose socket died before the ack rejects as
|
|
111
|
+
retryable: nothing was acknowledged, and if the append had already
|
|
112
|
+
committed, the client-generated event ids make the retry an idempotent
|
|
113
|
+
replay (you get the original events back). Push retries wait for the
|
|
114
|
+
restored socket rather than a fixed clock, capped by the reconnect
|
|
115
|
+
backoff, so a push caught in a routine blip resolves the way it would
|
|
116
|
+
have over HTTP, where pushes never depended on the stream.
|
|
117
|
+
|
|
118
|
+
On Vercel, WebSocket connections pin to one function instance and new
|
|
119
|
+
connections may land anywhere. A2 never relies on instance affinity:
|
|
120
|
+
the log is the shared state, so two clients on two instances converge
|
|
121
|
+
through it, exactly as they do over HTTP.
|
|
122
|
+
|
|
123
|
+
## Choosing
|
|
124
|
+
|
|
125
|
+
SSE plus POST is the right default: simplest route, ordinary HTTP
|
|
126
|
+
semantics, and one auth surface. Reach for `ws` when the send rate
|
|
127
|
+
makes per-request overhead visible (live cursors, drag ghosts) or when
|
|
128
|
+
round-trip latency is the product. Reach for the `http` split only when
|
|
129
|
+
per-route duration configuration forces it. The protocol underneath is
|
|
130
|
+
the same in every case: the log replays, presence repaints, and the
|
|
131
|
+
wire is an implementation detail.
|
package/docs/index.mdx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Introduction
|
|
3
|
-
description: A2 is durable sync and reactions for things with a lifecycle. Append events, run handlers, and keep one live view from server to browser.
|
|
3
|
+
description: A2 is durable sync and reactions for things with a lifecycle. Append or schedule events, run handlers, and keep one live view from server to browser.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
```bash
|
|
@@ -14,8 +14,8 @@ Handlers react to them, and usually return the next one. The browser follows
|
|
|
14
14
|
the same log and folds the same state as the server. That's the whole model;
|
|
15
15
|
the rest of this page is it happening.
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
Events record facts, reducers compute the current view, and handlers perform
|
|
18
|
+
the reactions.
|
|
19
19
|
|
|
20
20
|
## Define a contract
|
|
21
21
|
|
|
@@ -39,8 +39,7 @@ export const orders = a2.contract({
|
|
|
39
39
|
|
|
40
40
|
## React to events
|
|
41
41
|
|
|
42
|
-
Handlers are plain async functions
|
|
43
|
-
wrappers around side effects. Each one reacts to a fact and usually
|
|
42
|
+
Handlers are plain async functions. Each one reacts to a fact and usually
|
|
44
43
|
returns the next one.
|
|
45
44
|
|
|
46
45
|
```ts server/orders.ts
|
|
@@ -87,9 +86,11 @@ export async function POST(req: Request) {
|
|
|
87
86
|
}
|
|
88
87
|
```
|
|
89
88
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
pending work after a crash or timeout.
|
|
89
|
+
A2 commits the event before its handler can start. The handler may begin before
|
|
90
|
+
`append` returns; the return confirms durability, not handler completion.
|
|
91
|
+
A queue-backed scheduler wakes pending work after a crash or timeout. The same
|
|
92
|
+
scheduler lets a session append a typed event later with
|
|
93
|
+
`session.schedule(name, { delay: '30s' }, event)`.
|
|
93
94
|
|
|
94
95
|
## State is a fold
|
|
95
96
|
|
|
@@ -168,8 +169,8 @@ twenty lines; [Live UI](/guides/react) wires it end to end.
|
|
|
168
169
|
|
|
169
170
|
- **Handlers are plain async functions.** They run concurrently by default.
|
|
170
171
|
Use a lane for selective FIFO execution.
|
|
171
|
-
- **Handlers are optional per event type.** A
|
|
172
|
-
append and creates no reaction or
|
|
172
|
+
- **Handlers are optional per event type.** A history-only event settles during
|
|
173
|
+
append and creates no reaction or scheduler work.
|
|
173
174
|
- **History is real.** [`history()`](/concepts/state) returns what
|
|
174
175
|
actually happened, in order. Debugging is reading, not reconstructing.
|
|
175
176
|
- **Nothing inside a session is sacred except its log.** Snapshots, claims, queue
|
|
@@ -178,38 +179,12 @@ twenty lines; [Live UI](/guides/react) wires it end to end.
|
|
|
178
179
|
|
|
179
180
|
## FAQ
|
|
180
181
|
|
|
181
|
-
<details>
|
|
182
|
-
<summary>Why not a workflow engine?</summary>
|
|
183
|
-
|
|
184
|
-
Workflow engines replay your code from the top on every wake-up. So the
|
|
185
|
-
code has to be deterministic, so every side effect gets wrapped in a step
|
|
186
|
-
function, and `sleep()` becomes something magical instead of something
|
|
187
|
-
you'd never call in a serverless function.
|
|
188
|
-
|
|
189
|
-
A2's answer is older and simpler: write everything down. Every meaningful
|
|
190
|
-
thing that happens is an event in a log. Handlers are stateless functions
|
|
191
|
-
that react to one event at a time. State isn't stored. It's computed, by
|
|
192
|
-
folding over the log whenever you need it. There's no orchestrator to
|
|
193
|
-
operate.
|
|
194
|
-
|
|
195
|
-
</details>
|
|
196
|
-
|
|
197
|
-
<details>
|
|
198
|
-
<summary>Isn't this just event sourcing?</summary>
|
|
199
|
-
|
|
200
|
-
It's the useful core of it. A log of facts, state as a fold: the idea is
|
|
201
|
-
decades old, and it's a good one. A2 cuts the ceremony that made it a big
|
|
202
|
-
commitment. No command bus, no projection cluster, no upcasting
|
|
203
|
-
framework. A contract, a log, handlers, reducers.
|
|
204
|
-
|
|
205
|
-
</details>
|
|
206
|
-
|
|
207
182
|
<details>
|
|
208
183
|
<summary>Can I use A2 without handlers?</summary>
|
|
209
184
|
|
|
210
185
|
Yes. Omit `handlers` and use `append`, `history`, reducers, and live streams as
|
|
211
186
|
a durable event log. Events settle in their append transaction, with no drain,
|
|
212
|
-
claim, or queue message. A contract can also mix handled and
|
|
187
|
+
claim, or queue message. A contract can also mix handled and history-only event
|
|
213
188
|
types. A2 decides per event type.
|
|
214
189
|
|
|
215
190
|
See [Events without handlers](/concepts/handlers#events-without-handlers).
|
|
@@ -217,12 +192,12 @@ See [Events without handlers](/concepts/handlers#events-without-handlers).
|
|
|
217
192
|
</details>
|
|
218
193
|
|
|
219
194
|
<details>
|
|
220
|
-
<summary>How do I
|
|
195
|
+
<summary>How do I schedule an event for later?</summary>
|
|
221
196
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
event
|
|
197
|
+
Configure Vercel Queues or QStash, then call
|
|
198
|
+
`session.schedule(name, { delay: '30s' }, event)` or use `{ at: Date }`. The
|
|
199
|
+
same scheduler route handles recovery and the delayed append. Names and omitted
|
|
200
|
+
event ids stay stable across retries. See [Timers](/guides/timers).
|
|
226
201
|
|
|
227
202
|
</details>
|
|
228
203
|
|
|
@@ -248,10 +223,11 @@ recovery story behind them.
|
|
|
248
223
|
<details>
|
|
249
224
|
<summary>What does production need?</summary>
|
|
250
225
|
|
|
251
|
-
A durable
|
|
252
|
-
when the contract has handlers. A
|
|
253
|
-
|
|
254
|
-
state survives restarts. See
|
|
226
|
+
A durable store backend, such as Postgres or Redis. Add a queue-backed scheduler
|
|
227
|
+
when the contract has handlers or calls `session.schedule`. A history-only
|
|
228
|
+
contract with no timers needs no reaction infrastructure. Development needs
|
|
229
|
+
neither: SQLite appears under `.a2/` and state survives restarts. See
|
|
230
|
+
[Going to production](/guides/production).
|
|
255
231
|
|
|
256
232
|
</details>
|
|
257
233
|
|