experimental-a2 0.8.0 → 0.9.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/AGENTS.md +11 -0
- package/CHANGELOG.md +36 -0
- package/README.md +29 -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-BACubf4x.d.ts +136 -0
- package/dist/actor-shared-BACubf4x.d.ts.map +1 -0
- package/dist/actor-shared-DI7J5upy.js +127 -0
- package/dist/actor-shared-DI7J5upy.js.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 +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +4 -4
- 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-B2XNevQA.js → server-CBET-jSz.js} +6 -6
- package/dist/server-CBET-jSz.js.map +1 -0
- package/dist/{server-DjPhHnbI.d.ts → server-CKY3_lbw.d.ts} +3 -3
- package/dist/{server-DjPhHnbI.d.ts.map → server-CKY3_lbw.d.ts.map} +1 -1
- 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/07-examples.mdx +56 -0
- package/docs/guides/meta.ts +1 -0
- package/docs/index.mdx +16 -0
- package/docs/reference/02-errors.mdx +33 -0
- package/docs/reference/meta.ts +1 -0
- package/examples/README.md +15 -0
- package/examples/playground/AGENTS.md +11 -0
- package/examples/playground/DEPLOY.md +106 -0
- package/examples/playground/README.md +19 -0
- package/examples/playground/activity-feed.test.ts +10 -0
- package/examples/playground/app/agent/[agentId]/agent-client.tsx +376 -0
- package/examples/playground/app/agent/[agentId]/page.tsx +29 -0
- package/examples/playground/app/agent/events/route.ts +4 -0
- package/examples/playground/app/agent/model.ts +3 -0
- package/examples/playground/app/agent/new-agent-session.tsx +98 -0
- package/examples/playground/app/agent/page.tsx +25 -0
- package/examples/playground/app/agent/scheduler/route.ts +5 -0
- package/examples/playground/app/agent/server.ts +153 -0
- package/examples/playground/app/agent/session.ts +13 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +682 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-replay.test.ts +68 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-replay.ts +19 -0
- package/examples/playground/app/canvas/[canvasId]/page.tsx +22 -0
- package/examples/playground/app/canvas/[canvasId]/session.ts +19 -0
- package/examples/playground/app/canvas/events/route.ts +13 -0
- package/examples/playground/app/canvas/model.ts +94 -0
- package/examples/playground/app/canvas/open-canvas.tsx +40 -0
- package/examples/playground/app/canvas/page.tsx +20 -0
- package/examples/playground/app/canvas/server.ts +9 -0
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +118 -0
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +316 -0
- package/examples/playground/app/chat/[chatId]/chat-client.tsx +922 -0
- package/examples/playground/app/chat/[chatId]/chat-view.test.ts +152 -0
- package/examples/playground/app/chat/[chatId]/chat-view.ts +101 -0
- package/examples/playground/app/chat/[chatId]/composer.test.ts +44 -0
- package/examples/playground/app/chat/[chatId]/composer.ts +30 -0
- package/examples/playground/app/chat/[chatId]/page.tsx +30 -0
- package/examples/playground/app/chat/[chatId]/session.ts +7 -0
- package/examples/playground/app/chat/events/route.ts +7 -0
- package/examples/playground/app/chat/model.test.ts +155 -0
- package/examples/playground/app/chat/model.ts +310 -0
- package/examples/playground/app/chat/new-conversation.tsx +16 -0
- package/examples/playground/app/chat/page.tsx +25 -0
- package/examples/playground/app/chat/scheduler/route.ts +5 -0
- package/examples/playground/app/chat/server.ts +184 -0
- package/examples/playground/app/components/activity-feed.tsx +54 -0
- package/examples/playground/app/components/connection-pill.tsx +29 -0
- package/examples/playground/app/counter/counter-client.tsx +72 -0
- package/examples/playground/app/counter/events/route.ts +4 -0
- package/examples/playground/app/counter/model.test.ts +36 -0
- package/examples/playground/app/counter/model.ts +31 -0
- package/examples/playground/app/counter/page.tsx +24 -0
- package/examples/playground/app/counter/server.ts +9 -0
- package/examples/playground/app/counter/session.ts +13 -0
- package/examples/playground/app/documents/[documentId]/code-editor.tsx +80 -0
- package/examples/playground/app/documents/[documentId]/document-client.tsx +525 -0
- package/examples/playground/app/documents/[documentId]/page.tsx +23 -0
- package/examples/playground/app/documents/[documentId]/session.ts +7 -0
- package/examples/playground/app/documents/events/route.ts +4 -0
- package/examples/playground/app/documents/model.ts +55 -0
- package/examples/playground/app/documents/open-document.tsx +40 -0
- package/examples/playground/app/documents/page.tsx +22 -0
- package/examples/playground/app/documents/server.ts +9 -0
- package/examples/playground/app/globals.css +2078 -0
- package/examples/playground/app/layout.tsx +44 -0
- package/examples/playground/app/orders/[orderId]/order-client.tsx +140 -0
- package/examples/playground/app/orders/[orderId]/page.tsx +29 -0
- package/examples/playground/app/orders/[orderId]/session.ts +11 -0
- package/examples/playground/app/orders/create/route.ts +30 -0
- package/examples/playground/app/orders/events/route.ts +4 -0
- package/examples/playground/app/orders/model.ts +79 -0
- package/examples/playground/app/orders/new-order-form.tsx +98 -0
- package/examples/playground/app/orders/page.tsx +22 -0
- package/examples/playground/app/orders/scheduler/route.ts +5 -0
- package/examples/playground/app/orders/server.ts +50 -0
- package/examples/playground/app/page.tsx +111 -0
- package/examples/playground/app/recovery/[recoveryId]/page.tsx +31 -0
- package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +144 -0
- package/examples/playground/app/recovery/[recoveryId]/session.ts +7 -0
- package/examples/playground/app/recovery/events/route.ts +3 -0
- package/examples/playground/app/recovery/model.ts +55 -0
- package/examples/playground/app/recovery/new-recovery-session.tsx +20 -0
- package/examples/playground/app/recovery/page.tsx +22 -0
- package/examples/playground/app/recovery/scheduler/route.ts +7 -0
- package/examples/playground/app/recovery/server.ts +53 -0
- package/examples/playground/app/recovery/start/route.ts +41 -0
- 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/css.d.ts +4 -0
- package/examples/playground/lib/store.ts +15 -0
- package/examples/playground/next-env.d.ts +5 -0
- package/examples/playground/next.config.ts +10 -0
- package/examples/playground/package.json +46 -0
- package/examples/playground/tsconfig.json +37 -0
- package/examples/playground/vercel.json +40 -0
- package/package.json +11 -2
- 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 +9 -1
- package/src/errors.ts +15 -0
- package/src/index.ts +1 -1
- package/src/server.ts +13 -3
- package/dist/contract-jIfaR085.d.ts.map +0 -1
- package/dist/server-B2XNevQA.js.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 }
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Complete examples
|
|
3
|
+
description: Read or copy the complete Next.js playground that ships inside the experimental-a2 package.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Shipped with the package
|
|
7
|
+
|
|
8
|
+
Every `experimental-a2` installation includes a complete Next.js playground at
|
|
9
|
+
`node_modules/experimental-a2/examples/playground`.
|
|
10
|
+
|
|
11
|
+
The playground is not a collection of isolated snippets. Each feature owns its
|
|
12
|
+
contract, reducer, server, route handlers, client session, and scheduler when it
|
|
13
|
+
needs one. It uses only public package entry points.
|
|
14
|
+
|
|
15
|
+
Copy it into a new directory to run or modify it:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
cp -R node_modules/experimental-a2/examples/playground ./a2-playground
|
|
19
|
+
cd a2-playground
|
|
20
|
+
pnpm install
|
|
21
|
+
pnpm dev
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The copied `package.json` is standalone. Its A2 dependency is pinned to the
|
|
25
|
+
version that supplied the example.
|
|
26
|
+
|
|
27
|
+
The snapshot also includes an `AGENTS.md` file. Coding agents that support
|
|
28
|
+
repository instructions discover the example's structure as soon as they work
|
|
29
|
+
inside the copied directory.
|
|
30
|
+
|
|
31
|
+
## Find the right example
|
|
32
|
+
|
|
33
|
+
- `app/counter` is the smallest complete contract, reducer, server, and React
|
|
34
|
+
client.
|
|
35
|
+
- `app/orders` adds handlers and a feature-owned scheduler.
|
|
36
|
+
- `app/chat` shows request-scoped authorization and durable AI generation.
|
|
37
|
+
- `app/agent` shows tools, approvals, reminders, and sandboxed commands.
|
|
38
|
+
- `app/canvas` adds optimistic state, presence, and WebSocket upgrades.
|
|
39
|
+
- `app/documents` combines A2 events with a collaborative document protocol.
|
|
40
|
+
- `app/recovery` demonstrates queue recovery across function invocations.
|
|
41
|
+
|
|
42
|
+
## Guidance for coding agents
|
|
43
|
+
|
|
44
|
+
When A2 is installed locally, read these package directories in this order:
|
|
45
|
+
|
|
46
|
+
1. `node_modules/experimental-a2/docs` for the supported API and guarantees.
|
|
47
|
+
2. `node_modules/experimental-a2/examples/playground` for complete application
|
|
48
|
+
structure.
|
|
49
|
+
3. `node_modules/experimental-a2/src` only when implementation details are
|
|
50
|
+
relevant.
|
|
51
|
+
|
|
52
|
+
Do not infer application patterns from `dist`. The playground is the reference
|
|
53
|
+
for how the public pieces fit together in a real Next.js application.
|
|
54
|
+
|
|
55
|
+
The npm snapshot is generated from `apps/playground` in the A2 repository when
|
|
56
|
+
the package is published, so the runnable app remains the canonical source.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default { order: 3 }
|
package/docs/index.mdx
CHANGED
|
@@ -250,6 +250,16 @@ See [A2 and your database](/guides/application-data).
|
|
|
250
250
|
|
|
251
251
|
</details>
|
|
252
252
|
|
|
253
|
+
<details>
|
|
254
|
+
<summary>Does the package include complete examples?</summary>
|
|
255
|
+
|
|
256
|
+
Yes. Every installation includes a standalone Next.js playground at
|
|
257
|
+
`node_modules/experimental-a2/examples/playground`. It contains complete
|
|
258
|
+
contracts, reducers, servers, route handlers, clients, and schedulers using only
|
|
259
|
+
public package entry points. See [Complete examples](/guides/examples).
|
|
260
|
+
|
|
261
|
+
</details>
|
|
262
|
+
|
|
253
263
|
## Where to next
|
|
254
264
|
|
|
255
265
|
<CardGroup cols={2}>
|
|
@@ -271,4 +281,10 @@ See [A2 and your database](/guides/application-data).
|
|
|
271
281
|
<Card title="A2 and your database" href="/guides/application-data" icon="database">
|
|
272
282
|
Decide what belongs in a session and what belongs in ordinary tables.
|
|
273
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>
|
|
287
|
+
<Card title="Complete examples" href="/guides/examples" icon="code">
|
|
288
|
+
Read or copy the standalone Next.js playground included with the package.
|
|
289
|
+
</Card>
|
|
274
290
|
</CardGroup>
|
|
@@ -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 }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# A2 examples
|
|
2
|
+
|
|
3
|
+
The complete Next.js playground is included at `examples/playground` in the
|
|
4
|
+
published `experimental-a2` package. It uses only public package entry points
|
|
5
|
+
and can be copied into a new directory as a standalone application.
|
|
6
|
+
|
|
7
|
+
For coding agents, inspect these paths before inventing an integration:
|
|
8
|
+
|
|
9
|
+
- `docs/` defines the supported API.
|
|
10
|
+
- `examples/playground/` shows complete applications using that API.
|
|
11
|
+
- `src/` is the library implementation, not the recommended application
|
|
12
|
+
structure.
|
|
13
|
+
|
|
14
|
+
The playground snapshot is generated from the repository's `apps/playground`
|
|
15
|
+
directory when the package is packed.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# A2 playground
|
|
2
|
+
|
|
3
|
+
This is a standalone reference application for `experimental-a2`.
|
|
4
|
+
|
|
5
|
+
- Read `node_modules/experimental-a2/docs` for the supported API.
|
|
6
|
+
- Treat each directory under `app/` as a self-contained feature.
|
|
7
|
+
- Keep its contract and reducer in `model.ts`.
|
|
8
|
+
- Keep handlers and scheduler configuration in `server.ts`.
|
|
9
|
+
- Expose A2 through the feature's `events/route.ts` using `server.fetch`.
|
|
10
|
+
- Add a feature-owned `scheduler/route.ts` only when handlers or timers need it.
|
|
11
|
+
- Use only public `experimental-a2` package entry points.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Deploying the playground
|
|
2
|
+
|
|
3
|
+
The event log runs with zero setup in development (SQLite at `.a2/dev.db`,
|
|
4
|
+
no scheduler). The Agent demo also needs AI Gateway and Vercel Sandbox
|
|
5
|
+
credentials. Production needs the deliberate pieces from
|
|
6
|
+
`node_modules/experimental-a2/docs/guides/05-production.mdx`.
|
|
7
|
+
|
|
8
|
+
## 1. A store
|
|
9
|
+
|
|
10
|
+
Two options — set one of:
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
REDIS_URL=rediss://… # push-native streams (Upstash; recommended here)
|
|
14
|
+
DATABASE_URL=postgres://… # any Postgres (Neon, Supabase, RDS)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`lib/store.ts` prefers Redis when both are set — the chat, document,
|
|
18
|
+
and canvas demos are stream-latency-sensitive, and the Redis backend
|
|
19
|
+
delivers to parked subscribers in single-digit milliseconds (Postgres
|
|
20
|
+
streams poll adaptively, 25–250ms). The canvas demo's presence plane
|
|
21
|
+
(cursors, drag ghosts) also rides the backend's delivery tier: push on
|
|
22
|
+
Redis, poll cadence elsewhere. Upstash is durable by default; use its TCP
|
|
23
|
+
`rediss://` URL, not the REST one. Each feature creates its own server with
|
|
24
|
+
the shared store; storage remains namespaced per contract. Without either variable, a
|
|
25
|
+
production boot fails fast with `STORE_NOT_CONFIGURED` (by design).
|
|
26
|
+
Schemas/keys are created lazily on first use; no migration step.
|
|
27
|
+
|
|
28
|
+
To verify a provider before leaning on it, run the full conformance
|
|
29
|
+
kit against it (throwaway database; the suite isolates by key prefix
|
|
30
|
+
and sweeps after itself):
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
TEST_REDIS_URL=rediss://… pnpm --filter a2 exec vitest run test/store-redis.conformance.test.ts
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`pg` and `ioredis` are optional peer dependencies of `experimental-a2` and direct
|
|
37
|
+
dependencies of this app.
|
|
38
|
+
|
|
39
|
+
## 2. Vercel Queues scheduler
|
|
40
|
+
|
|
41
|
+
Queues need no configuration beyond the triggers in `vercel.json`. Orders,
|
|
42
|
+
Chat, Agent, and Recovery each own a scheduler route and queue topic alongside
|
|
43
|
+
their contract and server. Each topic is created on first send, and its trigger
|
|
44
|
+
makes the scheduler route private (no public URL). Schedulers turn on
|
|
45
|
+
automatically when `VERCEL` is set (every deployment).
|
|
46
|
+
|
|
47
|
+
To exercise the scheduler locally against a linked project:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
vercel link
|
|
51
|
+
vercel env pull # OIDC credentials for the @vercel/queue SDK
|
|
52
|
+
A2_QUEUES=1 pnpm dev
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## 3. AI Gateway
|
|
56
|
+
|
|
57
|
+
The Agent demo uses the AI SDK with `openai/gpt-5.6-luna`, while the Chat demo
|
|
58
|
+
uses `openai/gpt-5.4`. Both resolve through Vercel AI Gateway. Vercel
|
|
59
|
+
deployments use their OIDC token automatically. For local generation, link the
|
|
60
|
+
project and pull its environment or set an `AI_GATEWAY_API_KEY`:
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
vercel link
|
|
64
|
+
vercel env pull
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
New Chat rooms invoke the model only for `@agent` mentions. Participants can
|
|
68
|
+
enable ambient mode for the room to invoke it for every subsequent message.
|
|
69
|
+
|
|
70
|
+
The Counter, Orders, Canvas, and Documents demos do not call a model.
|
|
71
|
+
|
|
72
|
+
## 4. Vercel Sandbox
|
|
73
|
+
|
|
74
|
+
The Agent demo's Bash tool executes commands in a named, persistent Vercel
|
|
75
|
+
Sandbox. Every A2 agent session gets its own sandbox, so files remain available
|
|
76
|
+
to later Bash calls in that conversation. Vercel deployments authenticate with
|
|
77
|
+
OIDC automatically; the `vercel link` and `vercel env pull` commands above
|
|
78
|
+
supply the same credentials during local development.
|
|
79
|
+
|
|
80
|
+
## 5. WebSockets for the canvas
|
|
81
|
+
|
|
82
|
+
Deployed, the canvas demo upgrades its route to a WebSocket
|
|
83
|
+
(`experimental_upgradeWebSocket` from `@vercel/functions`, with `ws`
|
|
84
|
+
as the server-side socket — both direct dependencies of this app).
|
|
85
|
+
Vercel WebSockets need Fluid compute, which is the default on new
|
|
86
|
+
projects. Under `next dev` the upgrade never reaches the route (the
|
|
87
|
+
dev server drops the handshake), so the demo's session module picks
|
|
88
|
+
the SSE + POST pair in development — same route, same protocol
|
|
89
|
+
semantics, different wire.
|
|
90
|
+
|
|
91
|
+
## 6. Deploy
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
vercel deploy
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The Recovery lab tests a real function timeout. Its dedicated trigger and
|
|
98
|
+
queue consumer both export `maxDuration = 20`. The pipeline has four 8-second
|
|
99
|
+
handlers, so every handler fits in a fresh invocation but the 32-second chain
|
|
100
|
+
does not. A2 keeps dispatching events. Vercel stops the first invocation while
|
|
101
|
+
the third handler is running, leaving that event without a processed marker.
|
|
102
|
+
The deadline-capped claim expires, its watchdog retries the same event in a
|
|
103
|
+
fresh function, and already completed events do not run again. The dedicated
|
|
104
|
+
`a2-recovery` topic keeps this artificial budget away from the
|
|
105
|
+
playground's other recovery workloads. This example is disabled under
|
|
106
|
+
`next dev`, which supplies neither a Vercel deadline nor queue delivery.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# A2 playground
|
|
2
|
+
|
|
3
|
+
This is a standalone snapshot of the A2 playground included with the
|
|
4
|
+
`experimental-a2` npm package. It uses only public package entry points.
|
|
5
|
+
|
|
6
|
+
## Run it
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
pnpm install
|
|
10
|
+
pnpm dev
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Each directory under `app/` owns its contract, reducer, server, routes, and
|
|
14
|
+
scheduler when one is needed. Start with `app/counter` for the smallest
|
|
15
|
+
example, then read `app/orders`, `app/chat`, or `app/agent` for complete
|
|
16
|
+
handler-driven applications.
|
|
17
|
+
|
|
18
|
+
The canonical source lives in `apps/playground` in the A2 repository. This
|
|
19
|
+
copy is generated from it when the package is published.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { formatActivityTime } from './app/components/activity-feed'
|
|
3
|
+
|
|
4
|
+
describe('activity feed', () => {
|
|
5
|
+
it('formats event times identically on the server and client', () => {
|
|
6
|
+
expect(formatActivityTime(new Date('2026-08-18T01:16:09.123Z'))).toBe(
|
|
7
|
+
'01:16:09 UTC',
|
|
8
|
+
)
|
|
9
|
+
})
|
|
10
|
+
})
|