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.
Files changed (98) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/actor-client.d.ts +46 -0
  3. package/dist/actor-client.d.ts.map +1 -0
  4. package/dist/actor-client.js +54 -0
  5. package/dist/actor-client.js.map +1 -0
  6. package/dist/actor-react.d.ts +54 -0
  7. package/dist/actor-react.d.ts.map +1 -0
  8. package/dist/actor-react.js +79 -0
  9. package/dist/actor-react.js.map +1 -0
  10. package/dist/actor-shared-DI7J5upy.js +127 -0
  11. package/dist/actor-shared-DI7J5upy.js.map +1 -0
  12. package/dist/actor-shared-USo5MyuF.d.ts +136 -0
  13. package/dist/actor-shared-USo5MyuF.d.ts.map +1 -0
  14. package/dist/actor.browser.d.ts +1 -0
  15. package/dist/actor.browser.js +13 -0
  16. package/dist/actor.browser.js.map +1 -0
  17. package/dist/actor.d.ts +176 -0
  18. package/dist/actor.d.ts.map +1 -0
  19. package/dist/actor.js +437 -0
  20. package/dist/actor.js.map +1 -0
  21. package/dist/ai-server.d.ts +2 -2
  22. package/dist/ai-server.js +2 -2
  23. package/dist/ai.d.ts +2 -2
  24. package/dist/client.d.ts +14 -8
  25. package/dist/client.d.ts.map +1 -1
  26. package/dist/client.js +238 -58
  27. package/dist/client.js.map +1 -1
  28. package/dist/{errors-BQuJpe82.js → errors-DCk6ch5n.js} +16 -2
  29. package/dist/{errors-BQuJpe82.js.map → errors-DCk6ch5n.js.map} +1 -1
  30. package/dist/{idempotent-replay-DuqEkYA7.js → idempotent-replay-DVOlyYbx.js} +2 -2
  31. package/dist/{idempotent-replay-DuqEkYA7.js.map → idempotent-replay-DVOlyYbx.js.map} +1 -1
  32. package/dist/index.d.ts +16 -3
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +2 -2
  35. package/dist/react.d.ts +1 -1
  36. package/dist/{contract-jIfaR085.d.ts → reducer-DJKWm3cp.d.ts} +39 -39
  37. package/dist/reducer-DJKWm3cp.d.ts.map +1 -0
  38. package/dist/scheduler-qstash.d.ts +2 -2
  39. package/dist/scheduler-qstash.js +2 -2
  40. package/dist/scheduler-vercel.d.ts +2 -2
  41. package/dist/scheduler-vercel.js +1 -1
  42. package/dist/{server-DjPhHnbI.d.ts → server-DgCrSuhB.d.ts} +5 -3
  43. package/dist/server-DgCrSuhB.d.ts.map +1 -0
  44. package/dist/{server-B2XNevQA.js → server-DlLyvaSH.js} +140 -81
  45. package/dist/server-DlLyvaSH.js.map +1 -0
  46. package/dist/server.d.ts +3 -3
  47. package/dist/server.js +1 -1
  48. package/dist/{store-RJO35BMj.d.ts → store-DGHeBtIQ.d.ts} +2 -2
  49. package/dist/{store-RJO35BMj.d.ts.map → store-DGHeBtIQ.d.ts.map} +1 -1
  50. package/dist/store-memory.d.ts +1 -1
  51. package/dist/store-memory.js +2 -2
  52. package/dist/store-postgres.d.ts +1 -1
  53. package/dist/store-postgres.js +2 -2
  54. package/dist/{store-redis-core-DT01r4GZ.js → store-redis-core-z-ykbyMg.js} +3 -3
  55. package/dist/{store-redis-core-DT01r4GZ.js.map → store-redis-core-z-ykbyMg.js.map} +1 -1
  56. package/dist/store-redis-http.d.ts +1 -1
  57. package/dist/store-redis-http.js +2 -2
  58. package/dist/store-redis.d.ts +1 -1
  59. package/dist/store-redis.js +2 -2
  60. package/dist/store-sqlite.d.ts +1 -1
  61. package/dist/store-sqlite.js +2 -2
  62. package/dist/{wire-B6te_wns.js → wire--yji6mO3.js} +2 -2
  63. package/dist/{wire-B6te_wns.js.map → wire--yji6mO3.js.map} +1 -1
  64. package/docs/actors/01-introduction.mdx +189 -0
  65. package/docs/actors/02-concurrency.mdx +154 -0
  66. package/docs/actors/03-timers.mdx +120 -0
  67. package/docs/actors/04-routes.mdx +352 -0
  68. package/docs/actors/meta.ts +1 -0
  69. package/docs/concepts/meta.ts +1 -0
  70. package/docs/guides/10-transports.mdx +72 -22
  71. package/docs/guides/meta.ts +1 -0
  72. package/docs/index.mdx +3 -0
  73. package/docs/reference/01-api.mdx +30 -12
  74. package/docs/reference/02-errors.mdx +33 -0
  75. package/docs/reference/meta.ts +1 -0
  76. package/examples/playground/app/page.tsx +10 -1
  77. package/examples/playground/app/vault/[vaultId]/route.ts +19 -0
  78. package/examples/playground/app/vault/page.tsx +12 -0
  79. package/examples/playground/app/vault/server.ts +9 -0
  80. package/examples/playground/app/vault/vault-client.tsx +124 -0
  81. package/examples/playground/app/vault/vault.test.ts +147 -0
  82. package/examples/playground/app/vault/vault.ts +119 -0
  83. package/examples/playground/package.json +1 -1
  84. package/package.json +7 -1
  85. package/src/actor-client.ts +132 -0
  86. package/src/actor-react.ts +143 -0
  87. package/src/actor-shared.ts +356 -0
  88. package/src/actor.browser.ts +12 -0
  89. package/src/actor.ts +914 -0
  90. package/src/client.ts +341 -88
  91. package/src/errors.ts +15 -0
  92. package/src/index.ts +1 -1
  93. package/src/server-fetch.ts +51 -23
  94. package/src/server.ts +13 -3
  95. package/src/session-socket.ts +216 -81
  96. package/dist/contract-jIfaR085.d.ts.map +0 -1
  97. package/dist/server-B2XNevQA.js.map +0 -1
  98. 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
- - **The string** is the default and the recommendation: one route
21
- serving GET (SSE stream and history) and POST (push).
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 one multiplexed
25
- WebSocket. One socket carries every session of the client.
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 multiplexed. Subscribe frames open per-session lanes,
107
- each resuming from its own frontier. Every down frame carries its
108
- `sessionId`; pushes and presence route by it. One heartbeat and one
109
- connection serve all of the client's sessions.
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
- Authenticate the physical upgrade before calling `server.fetch`.
112
- Then pass `authorize` to check every session subscribe and every push
113
- or presence frame. There is no separate upgrade operation: request
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
- A denied subscribe receives an `unsubscribed` notice and leaves other
118
- sessions connected. A denied event push receives a non-retryable
119
- `FORBIDDEN` ack. Denied presence is silently dropped because presence
120
- is fire-and-forget and repaints on the next update. Failed presence
121
- authorization is dropped the same way and leaves the socket connected.
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, re-subscribes
152
- every session at its own frontier, receives fresh presence snapshots,
153
- and re-sends its own latest presence values. Event pushes keep their
154
- client-generated ids, so retry remains idempotent.
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
- Authenticate the physical WebSocket GET before calling `server.fetch`. Then
311
- use `authorize` for each session subscribe and each event or presence push.
312
- There is no WebSocket-upgrade operation in `A2Operation`. A denied subscribe
313
- receives `unsubscribed` and does not affect other sessions. A denied event push
314
- receives a `FORBIDDEN` ack. Denied presence is silently dropped because it is
315
- fire-and-forget. Failed presence authorization is dropped the same way and does
316
- not disconnect unrelated sessions on the multiplexed socket.
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, and over the multiplexed
678
- socket when `upgrade` is set. Subscribing never dispatches handlers.
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
- | string // one route: GET SSE stream + POST push
1205
- | { type: 'http'; push: string; stream: string } // split routes
1206
- | { type: 'ws'; url: string } // one socket, both directions
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
- Seven small demos, one model: append an event, let a handler react, fold
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
+ }