experimental-a2 0.8.1 → 0.10.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 +42 -0
- package/dist/actor-client.d.ts +46 -0
- package/dist/actor-client.d.ts.map +1 -0
- package/dist/actor-client.js +54 -0
- package/dist/actor-client.js.map +1 -0
- package/dist/actor-react.d.ts +54 -0
- package/dist/actor-react.d.ts.map +1 -0
- package/dist/actor-react.js +79 -0
- package/dist/actor-react.js.map +1 -0
- package/dist/actor-shared-DI7J5upy.js +127 -0
- package/dist/actor-shared-DI7J5upy.js.map +1 -0
- package/dist/actor-shared-USo5MyuF.d.ts +136 -0
- package/dist/actor-shared-USo5MyuF.d.ts.map +1 -0
- package/dist/actor.browser.d.ts +1 -0
- package/dist/actor.browser.js +13 -0
- package/dist/actor.browser.js.map +1 -0
- package/dist/actor.d.ts +176 -0
- package/dist/actor.d.ts.map +1 -0
- package/dist/actor.js +437 -0
- package/dist/actor.js.map +1 -0
- package/dist/ai-server.d.ts +2 -2
- package/dist/ai-server.js +2 -2
- package/dist/ai.d.ts +2 -2
- package/dist/client.d.ts +14 -8
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +238 -58
- package/dist/client.js.map +1 -1
- package/dist/{errors-BQuJpe82.js → errors-DCk6ch5n.js} +16 -2
- package/dist/{errors-BQuJpe82.js.map → errors-DCk6ch5n.js.map} +1 -1
- package/dist/{idempotent-replay-DuqEkYA7.js → idempotent-replay-DVOlyYbx.js} +2 -2
- package/dist/{idempotent-replay-DuqEkYA7.js.map → idempotent-replay-DVOlyYbx.js.map} +1 -1
- package/dist/index.d.ts +16 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/react.d.ts +1 -1
- package/dist/{contract-jIfaR085.d.ts → reducer-DJKWm3cp.d.ts} +39 -39
- package/dist/reducer-DJKWm3cp.d.ts.map +1 -0
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +2 -2
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +1 -1
- package/dist/{server-DjPhHnbI.d.ts → server-DgCrSuhB.d.ts} +5 -3
- package/dist/server-DgCrSuhB.d.ts.map +1 -0
- package/dist/{server-B2XNevQA.js → server-DlLyvaSH.js} +140 -81
- package/dist/server-DlLyvaSH.js.map +1 -0
- package/dist/server.d.ts +3 -3
- package/dist/server.js +1 -1
- package/dist/{store-RJO35BMj.d.ts → store-DGHeBtIQ.d.ts} +2 -2
- package/dist/{store-RJO35BMj.d.ts.map → store-DGHeBtIQ.d.ts.map} +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.js +2 -2
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.js +2 -2
- package/dist/{store-redis-core-DT01r4GZ.js → store-redis-core-z-ykbyMg.js} +3 -3
- package/dist/{store-redis-core-DT01r4GZ.js.map → store-redis-core-z-ykbyMg.js.map} +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +2 -2
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +2 -2
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.js +2 -2
- package/dist/{wire-B6te_wns.js → wire--yji6mO3.js} +2 -2
- package/dist/{wire-B6te_wns.js.map → wire--yji6mO3.js.map} +1 -1
- package/docs/actors/01-introduction.mdx +189 -0
- package/docs/actors/02-concurrency.mdx +154 -0
- package/docs/actors/03-timers.mdx +120 -0
- package/docs/actors/04-routes.mdx +352 -0
- package/docs/actors/meta.ts +1 -0
- package/docs/concepts/meta.ts +1 -0
- package/docs/guides/10-transports.mdx +72 -22
- package/docs/guides/meta.ts +1 -0
- package/docs/index.mdx +3 -0
- package/docs/reference/01-api.mdx +30 -12
- package/docs/reference/02-errors.mdx +33 -0
- package/docs/reference/meta.ts +1 -0
- package/examples/playground/app/page.tsx +10 -1
- package/examples/playground/app/vault/[vaultId]/route.ts +19 -0
- package/examples/playground/app/vault/page.tsx +12 -0
- package/examples/playground/app/vault/server.ts +9 -0
- package/examples/playground/app/vault/vault-client.tsx +124 -0
- package/examples/playground/app/vault/vault.test.ts +147 -0
- package/examples/playground/app/vault/vault.ts +119 -0
- package/examples/playground/package.json +1 -1
- package/package.json +7 -1
- package/src/actor-client.ts +132 -0
- package/src/actor-react.ts +143 -0
- package/src/actor-shared.ts +356 -0
- package/src/actor.browser.ts +12 -0
- package/src/actor.ts +914 -0
- package/src/client.ts +341 -88
- package/src/errors.ts +15 -0
- package/src/index.ts +1 -1
- package/src/server-fetch.ts +51 -23
- package/src/server.ts +13 -3
- package/src/session-socket.ts +216 -81
- package/dist/contract-jIfaR085.d.ts.map +0 -1
- package/dist/server-B2XNevQA.js.map +0 -1
- package/dist/server-DjPhHnbI.d.ts.map +0 -1
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Routes, clients, and React
|
|
3
|
+
description: "One route serves an instance: GET streams live state, POST answers calls. Authorize per operation, project per mount, and mount it in React with one hook."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## The actor for this page
|
|
7
|
+
|
|
8
|
+
A vault with one internal field, so the projection section below has
|
|
9
|
+
something to hide:
|
|
10
|
+
|
|
11
|
+
```ts vault.ts
|
|
12
|
+
import { NonRetriableError } from 'experimental-a2'
|
|
13
|
+
import { actor } from 'experimental-a2/actor'
|
|
14
|
+
|
|
15
|
+
interface Vault {
|
|
16
|
+
state: {
|
|
17
|
+
balance: number
|
|
18
|
+
/** Internal bookkeeping; not for subscribers. */
|
|
19
|
+
audit: string[]
|
|
20
|
+
}
|
|
21
|
+
events: {
|
|
22
|
+
deposit: { amount: number }
|
|
23
|
+
withdraw: { amount: number }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type VaultState = Vault['state']
|
|
28
|
+
|
|
29
|
+
export const vault = actor<Vault>({
|
|
30
|
+
name: 'vault',
|
|
31
|
+
state: { balance: 0, audit: [] },
|
|
32
|
+
handlers: {
|
|
33
|
+
deposit: (ctx, input) => {
|
|
34
|
+
ctx.state.balance += input.amount
|
|
35
|
+
ctx.state.audit.push(`deposit ${input.amount}`)
|
|
36
|
+
},
|
|
37
|
+
withdraw: (ctx, input) => {
|
|
38
|
+
if (ctx.state.balance < input.amount) {
|
|
39
|
+
throw new NonRetriableError('insufficient funds')
|
|
40
|
+
}
|
|
41
|
+
ctx.state.balance -= input.amount
|
|
42
|
+
ctx.state.audit.push(`withdraw ${input.amount}`)
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## The route
|
|
49
|
+
|
|
50
|
+
`handle.fetch` serves one instance over HTTP. Your route authenticates
|
|
51
|
+
and picks the instance; the library serves the rest.
|
|
52
|
+
|
|
53
|
+
```ts app/vault/[vaultId]/route.ts
|
|
54
|
+
import { vault } from '@/vault'
|
|
55
|
+
|
|
56
|
+
async function respond(
|
|
57
|
+
request: Request,
|
|
58
|
+
context: { params: Promise<{ vaultId: string }> },
|
|
59
|
+
): Promise<Response> {
|
|
60
|
+
const { vaultId } = await context.params
|
|
61
|
+
// here's where you'd do auth, or any other checks
|
|
62
|
+
return vault.actor(vaultId).fetch(request)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const GET = respond
|
|
66
|
+
export const POST = respond
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`GET` is the live stream: a server-sent events response of the
|
|
70
|
+
instance's state commits, resumed after the `index` query parameter,
|
|
71
|
+
with heartbeats. The stream carries state and nothing else: subscribers
|
|
72
|
+
see what the actor is, never other callers' inputs, and a refusal
|
|
73
|
+
answers only its caller. The full mailbox stays server-side.
|
|
74
|
+
|
|
75
|
+
`POST` is the call lane. The body names a declared event:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{ "event": "deposit", "input": { "amount": 100 }, "messageId": "m-1" }
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The response is the answer: `{ state, index }` on success, `409` with
|
|
82
|
+
the refusal's message when the handler says no, `400` for an undeclared
|
|
83
|
+
event. `messageId` is optional; send one to make retrying the request
|
|
84
|
+
idempotent.
|
|
85
|
+
|
|
86
|
+
## Authorize
|
|
87
|
+
|
|
88
|
+
`authorize` is per-operation policy, checked before any write or
|
|
89
|
+
subscription. Authentication (who is asking) stays in your route;
|
|
90
|
+
`authorize` decides whether this operation is allowed.
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
// app/vault/[vaultId]/route.ts, now with per-operation policy:
|
|
94
|
+
import { vault } from '@/vault'
|
|
95
|
+
|
|
96
|
+
export async function POST(
|
|
97
|
+
request: Request,
|
|
98
|
+
context: { params: Promise<{ vaultId: string }> },
|
|
99
|
+
): Promise<Response> {
|
|
100
|
+
const { vaultId } = await context.params
|
|
101
|
+
return vault.actor(vaultId).fetch(request, {
|
|
102
|
+
authorize: (operation) =>
|
|
103
|
+
operation.type === 'call' && operation.event !== 'withdraw',
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The operation is a discriminated union: `{ type: 'stream', id,
|
|
109
|
+
startAfter }` for subscriptions, `{ type: 'call', id, event, input,
|
|
110
|
+
messageId? }` for calls. Returning `false` answers `403`.
|
|
111
|
+
|
|
112
|
+
## Views: what each mount shows
|
|
113
|
+
|
|
114
|
+
State can be mixed-audience: internal bookkeeping next to public
|
|
115
|
+
fields. The definition stays audience-blind; audiences are a route
|
|
116
|
+
concern, like auth. A `view` is a request-time projection applied to
|
|
117
|
+
everything that mount serves: every state frame on the stream and
|
|
118
|
+
every call answer (the two must agree, or the call lane would leak
|
|
119
|
+
what the stream hides).
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
// app/vault/[vaultId]/public/route.ts, a projected mount of the same actor:
|
|
123
|
+
import { vault } from '@/vault'
|
|
124
|
+
|
|
125
|
+
export async function GET(
|
|
126
|
+
request: Request,
|
|
127
|
+
context: { params: Promise<{ vaultId: string }> },
|
|
128
|
+
): Promise<Response> {
|
|
129
|
+
const { vaultId } = await context.params
|
|
130
|
+
return vault.actor(vaultId).fetch(request, {
|
|
131
|
+
view: (state) => ({ balance: state.balance }),
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Different routes project the same actor differently: an admin mount
|
|
137
|
+
with no view sees everything, the public mount above hides `audit`.
|
|
138
|
+
Per-viewer views need no extra machinery, because the route already
|
|
139
|
+
holds the identity; a closure does it
|
|
140
|
+
(`view: (state) => ({ ...visible, mine: state.perUser[user.id] })`).
|
|
141
|
+
View output holds to the same plain-JSON floor as state itself.
|
|
142
|
+
|
|
143
|
+
Views are for internal-but-not-secret fields. Secrets do not belong in
|
|
144
|
+
state at all: state commits are log rows, and the log is forever. Keep
|
|
145
|
+
secrets outside, pass references, resolve them inside handlers.
|
|
146
|
+
|
|
147
|
+
## Any client
|
|
148
|
+
|
|
149
|
+
`createActorClient` is the typed call surface over that route's `POST`
|
|
150
|
+
lane. It works anywhere: browsers, other frameworks, other servers.
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
// in the browser, or any client at all:
|
|
154
|
+
import {
|
|
155
|
+
ActorRefusedError,
|
|
156
|
+
createActorClient,
|
|
157
|
+
} from 'experimental-a2/actor/client'
|
|
158
|
+
import type { vault } from '@/vault'
|
|
159
|
+
|
|
160
|
+
const savings = createActorClient<typeof vault>({ api: '/vault/savings' })
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const { state } = await savings.call.deposit({ amount: 100 })
|
|
164
|
+
// state is the answer: the fold after this deposit
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (error instanceof ActorRefusedError) {
|
|
167
|
+
// the server's refusal, revived with its message
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The import of `vault` is type-only, so it is erased at build time: the
|
|
173
|
+
server module never enters a client bundle. On a view-projected mount,
|
|
174
|
+
pass the projected shape as the second type argument
|
|
175
|
+
(`createActorClient<typeof vault, PublicVault>`) so answers carry it.
|
|
176
|
+
|
|
177
|
+
## React
|
|
178
|
+
|
|
179
|
+
One hook. The server component hands down the first fold, the hook
|
|
180
|
+
keeps it live and exposes the same typed calls.
|
|
181
|
+
|
|
182
|
+
```tsx app/vault/[vaultId]/page.tsx
|
|
183
|
+
import { vault } from '@/vault'
|
|
184
|
+
import { VaultClient } from './vault-client'
|
|
185
|
+
|
|
186
|
+
export default async function VaultPage({
|
|
187
|
+
params,
|
|
188
|
+
}: {
|
|
189
|
+
params: Promise<{ vaultId: string }>
|
|
190
|
+
}) {
|
|
191
|
+
const { vaultId } = await params
|
|
192
|
+
const initial = await vault.actor(vaultId).state()
|
|
193
|
+
return <VaultClient vaultId={vaultId} initial={initial} />
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
```tsx app/vault/[vaultId]/vault-client.tsx
|
|
198
|
+
'use client'
|
|
199
|
+
import { useActor } from 'experimental-a2/actor/react'
|
|
200
|
+
import type { ActorSnapshot } from 'experimental-a2/actor/react'
|
|
201
|
+
import type { vault, VaultState } from '@/vault'
|
|
202
|
+
|
|
203
|
+
export function VaultClient({
|
|
204
|
+
vaultId,
|
|
205
|
+
initial,
|
|
206
|
+
}: {
|
|
207
|
+
vaultId: string
|
|
208
|
+
initial: ActorSnapshot<VaultState>
|
|
209
|
+
}) {
|
|
210
|
+
const { state, call, connection } = useActor<typeof vault>({
|
|
211
|
+
api: `/vault/${vaultId}`,
|
|
212
|
+
id: vaultId,
|
|
213
|
+
initial,
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<div>
|
|
218
|
+
<p>
|
|
219
|
+
Balance: {state.balance}
|
|
220
|
+
{connection.status === 'live' ? '' : ' (reconnecting)'}
|
|
221
|
+
</p>
|
|
222
|
+
<button onClick={() => void call.deposit({ amount: 25 })}>
|
|
223
|
+
Deposit $25
|
|
224
|
+
</button>
|
|
225
|
+
</div>
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
What the hook gives you:
|
|
231
|
+
|
|
232
|
+
- **`state`**: the live view. Every commit streams in and replaces it;
|
|
233
|
+
the fold is library code, so no reducers, schemas, or contracts
|
|
234
|
+
appear in browser code.
|
|
235
|
+
- **`call`**: the same typed surface as `createActorClient`, because it
|
|
236
|
+
is one; `useActor` is a thin React wrapper over the client primitive
|
|
237
|
+
and the stream.
|
|
238
|
+
- **`index`**: the stream frontier, and **`events`**: the state-commit
|
|
239
|
+
feed this browser has observed, for activity-feed UI.
|
|
240
|
+
- **`connection`**: the same discriminated union as
|
|
241
|
+
[`useSession`](/guides/react#the-client-component), heartbeat
|
|
242
|
+
watchdog included.
|
|
243
|
+
|
|
244
|
+
Live, not optimistic. Handlers are server code deciding against
|
|
245
|
+
serialized fresh state, so there is nothing correct for the browser to
|
|
246
|
+
apply early; a local guess would be wrong exactly when the actor
|
|
247
|
+
matters. If a control needs pending UI, `await call.deposit(...)` is a
|
|
248
|
+
promise like any other.
|
|
249
|
+
|
|
250
|
+
On a view-projected mount, pass the projected shape as the second type
|
|
251
|
+
argument (`useActor<typeof vault, PublicVault>`); `initial`, `state`,
|
|
252
|
+
and the call answers all carry it.
|
|
253
|
+
|
|
254
|
+
## Presence
|
|
255
|
+
|
|
256
|
+
Who is here, live: cursors, names, viewer counts. Presence is
|
|
257
|
+
ephemeral audience state. It replicates to the instance's subscribers
|
|
258
|
+
and expires; it never enters the log, and handlers cannot see it (an
|
|
259
|
+
actor decides from its log, never from who is watching).
|
|
260
|
+
|
|
261
|
+
Declare the vocabulary in the protocol as types, and arm the wire with
|
|
262
|
+
`presence: true`:
|
|
263
|
+
|
|
264
|
+
```ts board.ts
|
|
265
|
+
import { actor } from 'experimental-a2/actor'
|
|
266
|
+
|
|
267
|
+
interface Board {
|
|
268
|
+
state: { strokes: number }
|
|
269
|
+
events: { draw: { path: string } }
|
|
270
|
+
presence: { cursor: { x: number; y: number }; name: string }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export const board = actor<Board>({
|
|
274
|
+
name: 'board',
|
|
275
|
+
state: { strokes: 0 },
|
|
276
|
+
presence: true,
|
|
277
|
+
handlers: {
|
|
278
|
+
draw: (ctx) => {
|
|
279
|
+
ctx.state.strokes += 1
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
})
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
The route does not change: the same `GET` stream interleaves presence
|
|
286
|
+
updates between state commits, and the same `POST` lane accepts
|
|
287
|
+
presence announcements. In React, pass a `participant` identity and
|
|
288
|
+
use the map:
|
|
289
|
+
|
|
290
|
+
```tsx app/board/[boardId]/board-client.tsx
|
|
291
|
+
'use client'
|
|
292
|
+
import { useActor } from 'experimental-a2/actor/react'
|
|
293
|
+
import type { ActorSnapshot } from 'experimental-a2/actor/react'
|
|
294
|
+
import type { board } from '@/board'
|
|
295
|
+
|
|
296
|
+
export function BoardClient({
|
|
297
|
+
boardId,
|
|
298
|
+
initial,
|
|
299
|
+
user,
|
|
300
|
+
}: {
|
|
301
|
+
boardId: string
|
|
302
|
+
initial: ActorSnapshot<{ strokes: number }>
|
|
303
|
+
user: string
|
|
304
|
+
}) {
|
|
305
|
+
const { presence, setPresence } = useActor<typeof board>({
|
|
306
|
+
api: `/board/${boardId}`,
|
|
307
|
+
id: boardId,
|
|
308
|
+
initial,
|
|
309
|
+
participant: user,
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
return (
|
|
313
|
+
<div
|
|
314
|
+
onPointerMove={(event) =>
|
|
315
|
+
setPresence({ cursor: { x: event.clientX, y: event.clientY } })
|
|
316
|
+
}
|
|
317
|
+
>
|
|
318
|
+
{Object.keys(presence).length} here
|
|
319
|
+
</div>
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
`setPresence` is fire-and-forget: throttled on the way out, resent
|
|
325
|
+
after a reconnect, `null` clears a field. The map holds each
|
|
326
|
+
participant's latest fields with their stamps; how long a value stays
|
|
327
|
+
painted is render logic, decided against `at`.
|
|
328
|
+
|
|
329
|
+
Three guards stand where presence enters, and none of them is a
|
|
330
|
+
schema:
|
|
331
|
+
|
|
332
|
+
- **Types**: the protocol types `setPresence` and the map, so honest
|
|
333
|
+
clients cannot send the wrong shape.
|
|
334
|
+
- **The floor**: the library rejects values that are not plain JSON
|
|
335
|
+
trees, values over 8 KB, and object-plumbing field names.
|
|
336
|
+
- **`authorize`**: every announcement arrives as
|
|
337
|
+
`{ type: 'presence', id, participant, values }` before it is
|
|
338
|
+
accepted. Clamp, verify the participant matches the authenticated
|
|
339
|
+
user, or rate-limit here.
|
|
340
|
+
|
|
341
|
+
What no guard does is make peer values trustworthy. Presence is
|
|
342
|
+
authored by whoever holds a connection: render it like user input.
|
|
343
|
+
|
|
344
|
+
## The session wire underneath
|
|
345
|
+
|
|
346
|
+
`handle.fetch` serves the actor's two lanes and nothing else. When you
|
|
347
|
+
want the raw session protocol instead (the full event feed, pushes,
|
|
348
|
+
presence, the `ws` transport), the definition exposes the underlying
|
|
349
|
+
server: mount `def.server.fetch` and use the core
|
|
350
|
+
[client](/guides/react) against it. That wire shows the whole mailbox
|
|
351
|
+
to its subscribers, inputs included, so reserve it for trusted
|
|
352
|
+
audiences.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default { order: 4 }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default { order: 2 }
|
|
@@ -13,20 +13,33 @@ it:
|
|
|
13
13
|
```ts
|
|
14
14
|
// in a 'use client' session module:
|
|
15
15
|
api: '/api/order-events'
|
|
16
|
+
api: (sessionId) => `/api/orders/${encodeURIComponent(sessionId)}/events`
|
|
16
17
|
api: { type: 'http', push: '/api/order-push', stream: '/api/order-stream' }
|
|
17
18
|
api: { type: 'ws', url: '/api/order-events' }
|
|
18
19
|
```
|
|
19
20
|
|
|
20
|
-
- **
|
|
21
|
-
|
|
21
|
+
- **One route** is the default and the recommendation. Pass a fixed URL or a
|
|
22
|
+
session URL function. It serves GET (SSE stream and history) and POST (push).
|
|
22
23
|
- **`http` split** serves the two verbs from separate routes. Use it
|
|
23
24
|
when platform duration limits differ per verb.
|
|
24
|
-
- **`ws`** rides streams, pushes, and presence over
|
|
25
|
-
|
|
25
|
+
- **`ws`** rides streams, pushes, and presence over WebSocket. It opens
|
|
26
|
+
one socket per session. Set `multiplex: true` on the client and
|
|
27
|
+
`multiplexWebSocket: true` on the route to share one socket across the
|
|
28
|
+
client's sessions.
|
|
26
29
|
|
|
27
30
|
A split socket is unrepresentable on purpose. The socket is one
|
|
28
31
|
connection in both directions.
|
|
29
32
|
|
|
33
|
+
Every session-scoped URL may instead be a `(sessionId) => string` function.
|
|
34
|
+
A2 resolves it for each request or reconnect, then adds its standard query
|
|
35
|
+
parameters. This supports paths scoped to a session. Encode the ID when placing
|
|
36
|
+
it in a path segment. The function chooses a route, not authority: if a path or
|
|
37
|
+
connection token is scoped to one session, `authorize` must compare that scope
|
|
38
|
+
to `operation.sessionId`. Bind URL tokens to the same session and keep them
|
|
39
|
+
short-lived because URLs can appear in infrastructure logs. Multiplexed
|
|
40
|
+
WebSockets take one plain URL because the connection carries more than one
|
|
41
|
+
session.
|
|
42
|
+
|
|
30
43
|
One wire is missing from `ws` by design: the history lane.
|
|
31
44
|
[`loadHistory`](/guides/react#the-client-component) is a bounded cold
|
|
32
45
|
read and rides plain HTTP. On a `ws` API it throws a `TypeError`.
|
|
@@ -103,22 +116,58 @@ export function GET(request: Request): Promise<Response> {
|
|
|
103
116
|
}
|
|
104
117
|
```
|
|
105
118
|
|
|
106
|
-
The socket is
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
119
|
+
The socket is bound to the `sessionId` in its upgrade URL. The route
|
|
120
|
+
authorizes that session before upgrading, and frames cannot select a
|
|
121
|
+
different session afterward. Each connected session has its own socket.
|
|
122
|
+
|
|
123
|
+
Authenticate the physical upgrade before calling `server.fetch`. For browser
|
|
124
|
+
sockets authenticated by cookies, validate the request's `Origin` too. A
|
|
125
|
+
WebSocket handshake does not use a CORS preflight.
|
|
126
|
+
Then pass `authorize` to check the session stream and every push or
|
|
127
|
+
presence frame. There is no separate upgrade operation: request policy
|
|
128
|
+
belongs to the route, while A2 operation policy belongs to `authorize`.
|
|
129
|
+
|
|
130
|
+
To multiplex, opt in on both sides:
|
|
131
|
+
|
|
132
|
+
```ts app/api/multiplexed-order-events/route.ts
|
|
133
|
+
import { experimental_upgradeWebSocket } from '@vercel/functions'
|
|
134
|
+
import { ordersServer } from '@/server/orders'
|
|
135
|
+
|
|
136
|
+
export function GET(request: Request): Promise<Response> {
|
|
137
|
+
return ordersServer.fetch(request, {
|
|
138
|
+
multiplexWebSocket: true,
|
|
139
|
+
upgradeWebSocket: (attach) =>
|
|
140
|
+
experimental_upgradeWebSocket(attach, {
|
|
141
|
+
maxPayload: 4 * 1024 * 1024,
|
|
142
|
+
}),
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
```ts app/orders/multiplexed-session.ts
|
|
148
|
+
'use client'
|
|
149
|
+
import { createClient } from 'experimental-a2/client'
|
|
150
|
+
import { ordersReducer } from '@/reducer'
|
|
151
|
+
|
|
152
|
+
export const ordersClient = createClient({
|
|
153
|
+
reducer: ordersReducer,
|
|
154
|
+
api: {
|
|
155
|
+
type: 'ws',
|
|
156
|
+
url: '/api/multiplexed-order-events',
|
|
157
|
+
multiplex: true,
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
```
|
|
110
161
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
policy belongs to the route, while A2 operation policy belongs to
|
|
115
|
-
`authorize`.
|
|
162
|
+
Only enable multiplexing when the request's authentication context may
|
|
163
|
+
open more than one session and `authorize` checks each supplied session
|
|
164
|
+
ID. Subscribe frames open the lanes after the physical upgrade.
|
|
116
165
|
|
|
117
|
-
|
|
118
|
-
sessions connected.
|
|
119
|
-
`FORBIDDEN` ack. Denied
|
|
120
|
-
is fire-and-forget and
|
|
121
|
-
|
|
166
|
+
On a multiplexed socket, a denied subscribe receives an `unsubscribed`
|
|
167
|
+
notice and leaves other sessions connected. On either socket shape, a
|
|
168
|
+
denied event push receives a non-retryable `FORBIDDEN` ack. Denied
|
|
169
|
+
presence is silently dropped because presence is fire-and-forget and
|
|
170
|
+
repaints on the next update.
|
|
122
171
|
|
|
123
172
|
A2 reads the platform's ambient invocation deadline and closes the
|
|
124
173
|
socket cleanly before it. `waitUntil` and deadline discovery remain
|
|
@@ -148,10 +197,11 @@ shared above the wire seam.
|
|
|
148
197
|
|
|
149
198
|
## Lifecycle
|
|
150
199
|
|
|
151
|
-
When a socket closes, the client reconnects with backoff
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
client-generated ids, so
|
|
200
|
+
When a socket closes, the client reconnects with backoff at the current
|
|
201
|
+
frontier, receives a fresh presence snapshot, and re-sends its own latest
|
|
202
|
+
presence values. A multiplexed socket re-subscribes every active session
|
|
203
|
+
at its own frontier. Event pushes keep their client-generated ids, so
|
|
204
|
+
retry remains idempotent.
|
|
155
205
|
|
|
156
206
|
SSE and WebSocket are transport choices, not different consistency
|
|
157
207
|
models. Both resume from the durable event frontier and treat presence
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default { order: 3 }
|
package/docs/index.mdx
CHANGED
|
@@ -281,6 +281,9 @@ public package entry points. See [Complete examples](/guides/examples).
|
|
|
281
281
|
<Card title="A2 and your database" href="/guides/application-data" icon="database">
|
|
282
282
|
Decide what belongs in a session and what belongs in ordinary tables.
|
|
283
283
|
</Card>
|
|
284
|
+
<Card title="Actors" href="/actors/introduction" icon="boxes">
|
|
285
|
+
Durable objects on the log: typed state, one message at a time.
|
|
286
|
+
</Card>
|
|
284
287
|
<Card title="Complete examples" href="/guides/examples" icon="code">
|
|
285
288
|
Read or copy the standalone Next.js playground included with the package.
|
|
286
289
|
</Card>
|
|
@@ -199,6 +199,7 @@ server.fetch(
|
|
|
199
199
|
upgradeWebSocket?: (
|
|
200
200
|
attach: (socket: A2Socket) => void,
|
|
201
201
|
) => Response | Promise<Response>
|
|
202
|
+
multiplexWebSocket?: boolean
|
|
202
203
|
},
|
|
203
204
|
): Promise<Response>
|
|
204
205
|
|
|
@@ -307,13 +308,20 @@ export function GET(request: Request): Promise<Response> {
|
|
|
307
308
|
}
|
|
308
309
|
```
|
|
309
310
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
311
|
+
By default, the `sessionId` and `index` query parameters bind one session to the
|
|
312
|
+
socket. A2 authorizes the stream before upgrading. Frames on that socket cannot
|
|
313
|
+
select another session.
|
|
314
|
+
|
|
315
|
+
Set `multiplexWebSocket: true` here and `multiplex: true` in the client's `ws`
|
|
316
|
+
API to share one socket.
|
|
317
|
+
Authenticate the physical WebSocket GET before calling `server.fetch`. Validate
|
|
318
|
+
`Origin` too when browser cookies authenticate it; WebSocket handshakes do not
|
|
319
|
+
use a CORS preflight. Use `authorize` for each session subscribe and each event
|
|
320
|
+
or presence push. Only enable multiplexing when that request context may access
|
|
321
|
+
every session accepted by `authorize`. A denied subscribe receives
|
|
322
|
+
`unsubscribed` and does not affect other sessions. A denied event push receives
|
|
323
|
+
a `FORBIDDEN` ack. Denied presence is silently dropped because it is
|
|
324
|
+
fire-and-forget.
|
|
317
325
|
|
|
318
326
|
SSE and WebSocket lifetimes use the platform's ambient invocation deadline.
|
|
319
327
|
A2 also uses the platform's ambient `waitUntil` capability for background work.
|
|
@@ -674,8 +682,8 @@ session.stream(options: {
|
|
|
674
682
|
|
|
675
683
|
A live feed of the session's events. `startAfter` is a non-negative safe integer;
|
|
676
684
|
`startAfter: 20` begins with event 21.
|
|
677
|
-
Server-side only; `server.fetch` exposes it over SSE
|
|
678
|
-
|
|
685
|
+
Server-side only; `server.fetch` exposes it over SSE and WebSocket. Subscribing
|
|
686
|
+
never dispatches handlers.
|
|
679
687
|
|
|
680
688
|
With `presence: true`, the feed yields one `PresenceSnapshot` first:
|
|
681
689
|
`{ snapshot }`, the current pruned map with each field's own `value`,
|
|
@@ -1201,11 +1209,21 @@ createClient(options: {
|
|
|
1201
1209
|
}): A2Client
|
|
1202
1210
|
|
|
1203
1211
|
type ClientApi =
|
|
1204
|
-
|
|
|
1205
|
-
| { type: 'http'; push:
|
|
1206
|
-
| { type: 'ws'; url:
|
|
1212
|
+
| SessionUrl // one route: GET SSE stream + POST push
|
|
1213
|
+
| { type: 'http'; push: SessionUrl; stream: SessionUrl }
|
|
1214
|
+
| { type: 'ws'; url: SessionUrl; multiplex?: false }
|
|
1215
|
+
| { type: 'ws'; url: string; multiplex: true }
|
|
1216
|
+
|
|
1217
|
+
type SessionUrl = string | ((sessionId: string) => string)
|
|
1207
1218
|
```
|
|
1208
1219
|
|
|
1220
|
+
A `SessionUrl` function runs for each HTTP request or WebSocket connection.
|
|
1221
|
+
Use it for session-specific paths. A2 still adds its `sessionId` and read-bound
|
|
1222
|
+
query parameters. The function does not authorize that ID. Bind any path scope
|
|
1223
|
+
or URL token to `operation.sessionId` in `authorize`; keep URL tokens short-lived
|
|
1224
|
+
because URLs can appear in infrastructure logs. A multiplexed WebSocket URL is
|
|
1225
|
+
a string because its connection carries more than one session.
|
|
1226
|
+
|
|
1209
1227
|
The framework-agnostic session client `experimental-a2/react` is built on: the SSE
|
|
1210
1228
|
subscription with frontier resume and reconnection, the optimistic push
|
|
1211
1229
|
queue with ack/rollback, and the local fold. `client.session(id, {
|
|
@@ -58,6 +58,39 @@ contains the same id twice, and an id that was already used in a
|
|
|
58
58
|
*different* session (event ids are globally unique). In every case,
|
|
59
59
|
nothing is written.
|
|
60
60
|
|
|
61
|
+
## NonRetriableError
|
|
62
|
+
|
|
63
|
+
The second exported error class. Throw it (or a subclass, matched by
|
|
64
|
+
`instanceof`) from a handler when the failure is deterministic: the
|
|
65
|
+
event dead-letters immediately instead of consuming the ten-failure
|
|
66
|
+
retry budget, because a guard that refuses this attempt will refuse
|
|
67
|
+
every retry identically. Any other thrown error keeps the ordinary
|
|
68
|
+
retry contract.
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { z } from 'zod'
|
|
72
|
+
import * as a2 from 'experimental-a2'
|
|
73
|
+
import { NonRetriableError } from 'experimental-a2'
|
|
74
|
+
import { createServer } from 'experimental-a2/server'
|
|
75
|
+
|
|
76
|
+
const orders = a2.contract({
|
|
77
|
+
name: 'orders',
|
|
78
|
+
events: { created: z.object({ chargeable: z.boolean() }) },
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
export const ordersServer = createServer({
|
|
82
|
+
contract: orders,
|
|
83
|
+
handlers: {
|
|
84
|
+
created: async ({ event }) => {
|
|
85
|
+
if (!event.payload.chargeable) {
|
|
86
|
+
throw new NonRetriableError('this order cannot be charged')
|
|
87
|
+
}
|
|
88
|
+
// transient failures below retry as usual
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
```
|
|
93
|
+
|
|
61
94
|
## What append never throws for
|
|
62
95
|
|
|
63
96
|
- **A failing handler.** Appends always land; handler failures are retried
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default { order: 5 }
|
|
@@ -6,7 +6,7 @@ export default function HomePage(): ReactNode {
|
|
|
6
6
|
<article>
|
|
7
7
|
<h1>A durable event log, live in your browser</h1>
|
|
8
8
|
<p className="lede">
|
|
9
|
-
|
|
9
|
+
Eight small demos, one model: append an event, let a handler react, fold
|
|
10
10
|
the log into a view — on the server for the first paint, in the browser
|
|
11
11
|
for every moment after.
|
|
12
12
|
</p>
|
|
@@ -64,6 +64,15 @@ export default function HomePage(): ReactNode {
|
|
|
64
64
|
under a fresh claim.
|
|
65
65
|
</p>
|
|
66
66
|
</Link>
|
|
67
|
+
<Link className="card" href="/vault">
|
|
68
|
+
<h2>Vault (actor)</h2>
|
|
69
|
+
<p>
|
|
70
|
+
A durable actor: send messages to one vault's mailbox, watch it
|
|
71
|
+
process them one at a time against its state — serialized
|
|
72
|
+
read-modify-write, refusals as answers, and the whole transcript in
|
|
73
|
+
the log. Built on <code>experimental-a2/actor</code>.
|
|
74
|
+
</p>
|
|
75
|
+
</Link>
|
|
67
76
|
<Link className="card" href="/counter">
|
|
68
77
|
<h2>Counter</h2>
|
|
69
78
|
<p>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vault's ingress — the actor mirror of `server.fetch`: your route
|
|
3
|
+
* authenticates and picks the instance, `handle.fetch` serves the rest
|
|
4
|
+
* (GET → the state-plane SSE, POST → the call lane).
|
|
5
|
+
*/
|
|
6
|
+
import { vault } from '../server'
|
|
7
|
+
|
|
8
|
+
async function respond(
|
|
9
|
+
request: Request,
|
|
10
|
+
context: { params: Promise<{ vaultId: string }> },
|
|
11
|
+
): Promise<Response> {
|
|
12
|
+
const { vaultId } = await context.params
|
|
13
|
+
// here's where you'd authenticate; per-operation policy goes in
|
|
14
|
+
// fetch's { authorize } option
|
|
15
|
+
return vault.actor(vaultId).fetch(request)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const GET = respond
|
|
19
|
+
export const POST = respond
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { vault } from './server'
|
|
3
|
+
import { VaultClient } from './vault-client'
|
|
4
|
+
|
|
5
|
+
export const dynamic = 'force-dynamic'
|
|
6
|
+
|
|
7
|
+
const VAULT_ID = 'shared'
|
|
8
|
+
|
|
9
|
+
export default async function VaultPage(): Promise<ReactNode> {
|
|
10
|
+
const initial = await vault.actor(VAULT_ID).state()
|
|
11
|
+
return <VaultClient vaultId={VAULT_ID} initial={initial} />
|
|
12
|
+
}
|