experimental-a2 0.6.0 → 0.8.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 +41 -0
- package/dist/ai-server.d.ts +4 -5
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +20 -17
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +334 -2
- package/dist/ai.d.ts.map +1 -0
- package/dist/ai.js +1 -1
- package/dist/client.d.ts +202 -2
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1025 -1
- package/dist/client.js.map +1 -0
- package/dist/errors-BQuJpe82.js.map +1 -1
- package/dist/index.d.ts +22 -3
- package/dist/index.d.ts.map +1 -0
- package/dist/{internal-DstsI6Re.js → internal-DRXJ56EI.js} +5 -28
- package/dist/internal-DRXJ56EI.js.map +1 -0
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/scheduler-qstash.d.ts +3 -3
- package/dist/scheduler-qstash.js +4 -5
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +4 -4
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/{server-Duw6MVlB.js → server-B2XNevQA.js} +830 -131
- package/dist/server-B2XNevQA.js.map +1 -0
- package/dist/{server-DpvjhdoE.d.ts → server-DjPhHnbI.d.ts} +71 -50
- package/dist/server-DjPhHnbI.d.ts.map +1 -0
- package/dist/server.d.ts +3 -3
- package/dist/server.js +1 -1
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/{store-DysUkTH3.d.ts → store-RJO35BMj.d.ts} +24 -62
- package/dist/store-RJO35BMj.d.ts.map +1 -0
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.d.ts.map +1 -1
- package/dist/store-memory.js +80 -78
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-dSeLxzfb.js → store-polling-6DW7F1DT.js} +2 -2
- package/dist/{store-polling-dSeLxzfb.js.map → store-polling-6DW7F1DT.js.map} +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +231 -182
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-redis-core-BFLwz0Wj.js → store-redis-core-DT01r4GZ.js} +213 -161
- package/dist/store-redis-core-DT01r4GZ.js.map +1 -0
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +3 -4
- package/dist/store-redis-http.js.map +1 -1
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +4 -5
- package/dist/store-redis.js.map +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.d.ts.map +1 -1
- package/dist/store-sqlite.js +104 -91
- package/dist/store-sqlite.js.map +1 -1
- package/dist/{wire-BFQmSJ-9.js → wire-B6te_wns.js} +4 -3
- package/dist/wire-B6te_wns.js.map +1 -0
- package/docs/concepts/02-handlers.mdx +4 -0
- package/docs/concepts/04-state.mdx +57 -9
- package/docs/guides/03-react.mdx +20 -28
- package/docs/guides/05-production.mdx +9 -11
- package/docs/guides/06-ai-agents.mdx +10 -15
- package/docs/guides/09-presence.mdx +14 -19
- package/docs/guides/10-transports.mdx +104 -86
- package/docs/reference/01-api.mdx +182 -293
- package/docs/reference/02-errors.mdx +5 -7
- package/package.json +1 -14
- package/src/ai-server.ts +36 -15
- package/src/client.ts +2 -2
- package/src/errors.ts +1 -0
- package/src/internal.ts +3 -62
- package/src/push-envelope.ts +24 -21
- package/src/scheduler-qstash.ts +3 -3
- package/src/scheduler-vercel.ts +2 -2
- package/src/server-fetch.ts +344 -0
- package/src/server.ts +315 -312
- package/src/session-socket.ts +36 -20
- package/src/sse.ts +2 -2
- package/src/store-memory.ts +138 -101
- package/src/store-postgres.ts +355 -238
- package/src/store-redis-core.ts +247 -237
- package/src/store-redis-http.ts +1 -2
- package/src/store-redis.ts +1 -2
- package/src/store-sqlite.ts +191 -153
- package/src/store.ts +24 -66
- package/src/wire.ts +2 -1
- package/dist/ai-D_PGS-JR.d.ts +0 -334
- package/dist/ai-D_PGS-JR.d.ts.map +0 -1
- package/dist/cli-B3VuxoDe.js +0 -599
- package/dist/cli-B3VuxoDe.js.map +0 -1
- package/dist/cli-bin.d.ts +0 -1
- package/dist/cli-bin.js +0 -7
- package/dist/cli-bin.js.map +0 -1
- package/dist/cli.d.ts +0 -20
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -2
- package/dist/client-CdMqi7mC.d.ts +0 -202
- package/dist/client-CdMqi7mC.d.ts.map +0 -1
- package/dist/client-Dj5d3SP_.js +0 -1026
- package/dist/client-Dj5d3SP_.js.map +0 -1
- package/dist/devtools-J_jZ2vQf.d.ts +0 -152
- package/dist/devtools-J_jZ2vQf.d.ts.map +0 -1
- package/dist/devtools-kJJaORn-.js +0 -340
- package/dist/devtools-kJJaORn-.js.map +0 -1
- package/dist/devtools-server.browser.d.ts +0 -1
- package/dist/devtools-server.browser.js +0 -6
- package/dist/devtools-server.browser.js.map +0 -1
- package/dist/devtools-server.d.ts +0 -23
- package/dist/devtools-server.d.ts.map +0 -1
- package/dist/devtools-server.js +0 -1270
- package/dist/devtools-server.js.map +0 -1
- package/dist/devtools.d.ts +0 -2
- package/dist/devtools.js +0 -2
- package/dist/errors-W6nwJ-fm.d.ts +0 -21
- package/dist/errors-W6nwJ-fm.d.ts.map +0 -1
- package/dist/http.d.ts +0 -151
- package/dist/http.d.ts.map +0 -1
- package/dist/http.js +0 -706
- package/dist/http.js.map +0 -1
- package/dist/inspection-DaxB5jM2.js +0 -13
- package/dist/inspection-DaxB5jM2.js.map +0 -1
- package/dist/internal-DstsI6Re.js.map +0 -1
- package/dist/platform-B4TnJtWu.js +0 -34
- package/dist/platform-B4TnJtWu.js.map +0 -1
- package/dist/server-DpvjhdoE.d.ts.map +0 -1
- package/dist/server-Duw6MVlB.js.map +0 -1
- package/dist/store-DysUkTH3.d.ts.map +0 -1
- package/dist/store-redis-core-BFLwz0Wj.js.map +0 -1
- package/dist/testing.browser.d.ts +0 -1
- package/dist/testing.browser.js +0 -6
- package/dist/testing.browser.js.map +0 -1
- package/dist/testing.d.ts +0 -32
- package/dist/testing.d.ts.map +0 -1
- package/dist/testing.js +0 -103
- package/dist/testing.js.map +0 -1
- package/dist/wire-BFQmSJ-9.js.map +0 -1
- package/docs/guides/07-devtools.mdx +0 -229
- package/src/cli-bin.ts +0 -5
- package/src/cli.ts +0 -1046
- package/src/devtools-app.ts +0 -989
- package/src/devtools-server.browser.ts +0 -5
- package/src/devtools-server.ts +0 -604
- package/src/devtools.ts +0 -716
- package/src/http.ts +0 -394
- package/src/inspection.ts +0 -39
- package/src/testing.browser.ts +0 -5
- package/src/testing.ts +0 -185
|
@@ -96,17 +96,57 @@ arguments or annotations are needed, and literal unions survive the fold.
|
|
|
96
96
|
fold includes every event through that index and none after it. The browser
|
|
97
97
|
resumes its live stream from that boundary. See [Live UI](/guides/react).
|
|
98
98
|
|
|
99
|
+
A root session reads the latest committed frontier by default. A handler's
|
|
100
|
+
session is causally scoped, so the same call stops at its triggering event:
|
|
101
|
+
|
|
102
|
+
```ts causal-server.ts
|
|
103
|
+
import { createServer } from 'experimental-a2/server'
|
|
104
|
+
import { orders } from './contracts'
|
|
105
|
+
import { ordersReducer } from './reducer'
|
|
106
|
+
|
|
107
|
+
export const causalOrdersServer = createServer({
|
|
108
|
+
contract: orders,
|
|
109
|
+
handlers: {
|
|
110
|
+
created: async ({ event, session }) => {
|
|
111
|
+
const atTrigger = await session.state(ordersReducer)
|
|
112
|
+
// atTrigger.index === event.index
|
|
113
|
+
|
|
114
|
+
const current = await session.state(ordersReducer, {
|
|
115
|
+
through: 'latest',
|
|
116
|
+
})
|
|
117
|
+
void current
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The trigger boundary is stable across concurrent appends and retries. Use the
|
|
124
|
+
explicit latest read for joins or other logic that intentionally observes later
|
|
125
|
+
events. A numeric `{ through: index }` reads any inclusive log boundary;
|
|
126
|
+
`through: 0` returns `initialState`.
|
|
127
|
+
|
|
128
|
+
A bounded handler read also keeps that exact folded checkpoint available while
|
|
129
|
+
the triggering event is unfinished. This is automatic. When concurrent work
|
|
130
|
+
advances the reducer's latest snapshot, A2 retains the older checkpoint until
|
|
131
|
+
every handler event using it completes or dead-letters. There is no TTL, history
|
|
132
|
+
limit, or retention setting to tune.
|
|
133
|
+
|
|
134
|
+
Root reads and handler reads with `{ through: 'latest' }` do not retain
|
|
135
|
+
historical checkpoints. A root numeric read can use an older checkpoint that an
|
|
136
|
+
active handler already retained, but querying an old boundary does not keep it
|
|
137
|
+
around or write a stale snapshot behind the current head. If no suitable
|
|
138
|
+
checkpoint exists, A2 folds the bounded log.
|
|
139
|
+
|
|
99
140
|
The snapshot and its remaining event tail come back in one consistent store
|
|
100
141
|
operation. A missing, invalid, or unreadable snapshot rebuilds from the full log.
|
|
101
142
|
`state()` is observational: it never runs handlers or waits for pending work.
|
|
102
|
-
Its index marks committed history, not handler completion.
|
|
103
|
-
includes the trigger and may include later events committed before the read.
|
|
143
|
+
Its index marks committed history, not handler completion.
|
|
104
144
|
|
|
105
|
-
|
|
145
|
+
A latest read and a following `ctx.session.append(name, ...events)` are separate
|
|
106
146
|
operations. Concurrent appends and retries can move the frontier between them.
|
|
107
|
-
For joins,
|
|
108
|
-
the output event a stable explicit `id`.
|
|
109
|
-
same append.
|
|
147
|
+
For joins, read with `{ through: 'latest' }`, use a monotone readiness check
|
|
148
|
+
(once ready, always ready), and give the output event a stable explicit `id`.
|
|
149
|
+
Repeated attempts then converge on the same append.
|
|
110
150
|
|
|
111
151
|
`stateSchema` declares the state's shape once. Without it, the state type
|
|
112
152
|
is inferred from `initialState`, fine while every fold arm returns the
|
|
@@ -130,6 +170,12 @@ Folding a long session on every read would get slow, so the store backend
|
|
|
130
170
|
caches folded state as a snapshot. You never interact with it, except for
|
|
131
171
|
one string.
|
|
132
172
|
|
|
173
|
+
Each session and reducer has one latest snapshot. Older snapshots exist only
|
|
174
|
+
while unfinished handler events need their exact state boundary. Handler
|
|
175
|
+
completion and dead-lettering release those references in the same store
|
|
176
|
+
operation and remove any historical snapshot with no remaining reader. Several
|
|
177
|
+
handlers can share one checkpoint; it stays until the last reference leaves.
|
|
178
|
+
|
|
133
179
|
Snapshots are keyed by the reducer's `name`, which makes the name do two
|
|
134
180
|
jobs. It's the identity: two different reducers over the same session
|
|
135
181
|
never fight over a cache entry, because they have different names. And
|
|
@@ -138,9 +184,11 @@ it's the invalidation knob. Changed the fold's logic? Change the name (a
|
|
|
138
184
|
ignored; the next read refolds from raw events and caches under the new
|
|
139
185
|
name. That's the entire cache invalidation story: one string.
|
|
140
186
|
|
|
141
|
-
Snapshot write-back runs in platform `waitUntil` after the state is ready.
|
|
142
|
-
|
|
143
|
-
|
|
187
|
+
Snapshot write-back runs in platform `waitUntil` after the state is ready. One
|
|
188
|
+
same-tick group of reads for a reducer and session reads the widest required log
|
|
189
|
+
range once, folds it once, and writes the needed prefixes together. A failed or
|
|
190
|
+
interrupted cache write changes no application behavior. The next read folds
|
|
191
|
+
the missing tail again.
|
|
144
192
|
|
|
145
193
|
Deleting every snapshot is always safe. The log rebuilds them.
|
|
146
194
|
|
package/docs/guides/03-react.mdx
CHANGED
|
@@ -23,22 +23,15 @@ reducer, and the provider + hook it returns.
|
|
|
23
23
|
|
|
24
24
|
## The API route
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
`server.fetch` exposes a session over HTTP. It is bound, so the route can
|
|
27
|
+
export it directly: `GET` streams events and serves history slices;
|
|
28
|
+
`POST` appends.
|
|
29
29
|
|
|
30
30
|
```ts app/api/order-events/route.ts
|
|
31
|
-
import { handle } from 'experimental-a2/http'
|
|
32
31
|
import { ordersServer } from '@/server/orders'
|
|
33
32
|
|
|
34
|
-
export const
|
|
35
|
-
|
|
36
|
-
// here's where you'd do auth, or any other checks. Every lane
|
|
37
|
-
// arrives parsed: intent.type is 'stream', 'history', 'push', or
|
|
38
|
-
// 'ws-upgrade'. Return a Response to refuse, e.g.:
|
|
39
|
-
// if (!canRead(request, intent)) return new Response(null, { status: 403 })
|
|
40
|
-
},
|
|
41
|
-
})
|
|
33
|
+
export const GET = ordersServer.fetch
|
|
34
|
+
export const POST = ordersServer.fetch
|
|
42
35
|
```
|
|
43
36
|
|
|
44
37
|
`GET` is the read path. A plain `GET` is the live stream: a server-sent
|
|
@@ -48,31 +41,30 @@ paint or a dropped connection. A `GET` carrying `gte`/`lte` query
|
|
|
48
41
|
parameters is a history slice instead: the bounded log range as JSON,
|
|
49
42
|
the cold read [`loadHistory`](#the-client-component) rides.
|
|
50
43
|
|
|
51
|
-
`POST` is the write path. The push envelope is validated
|
|
52
|
-
answers `INVALID_PAYLOAD` before your hooks run), then `append` does the
|
|
44
|
+
`POST` is the write path. The push envelope is validated, then `append` does the
|
|
53
45
|
rest. The response is the appended events: an ack, not a stream. Thrown
|
|
54
46
|
[`A2Error`s](/reference/errors#over-the-wire) serialize onto the wire so
|
|
55
47
|
the client can branch on the same codes.
|
|
56
48
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
immutable slice of an append-only log.
|
|
49
|
+
Wrap `server.fetch` when the route needs authentication, per-operation
|
|
50
|
+
authorization, or response decoration. `authorize` receives the parsed
|
|
51
|
+
A2 operation after envelope parsing and before schema validation or I/O.
|
|
52
|
+
Return `false` to answer `FORBIDDEN`. A thrown error is treated as an
|
|
53
|
+
infrastructure failure.
|
|
63
54
|
|
|
64
55
|
```ts
|
|
65
56
|
// app/api/order-events/route.ts, now with response decoration:
|
|
66
|
-
import { handle } from 'experimental-a2/http'
|
|
67
57
|
import { ordersServer } from '@/server/orders'
|
|
68
58
|
|
|
69
|
-
export
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
59
|
+
export async function GET(request: Request): Promise<Response> {
|
|
60
|
+
const response = await ordersServer.fetch(request)
|
|
61
|
+
if (response.headers.get('a2-history-covered') === 'true') {
|
|
62
|
+
response.headers.set('cache-control', 'private, max-age=31536000')
|
|
63
|
+
}
|
|
64
|
+
return response
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const POST = ordersServer.fetch
|
|
76
68
|
```
|
|
77
69
|
|
|
78
70
|
## The session module
|
|
@@ -89,21 +89,19 @@ export const ordersServer = createServer({
|
|
|
89
89
|
})
|
|
90
90
|
```
|
|
91
91
|
|
|
92
|
-
One scheduler instance is shared by every server on this route.
|
|
93
|
-
|
|
92
|
+
One scheduler instance is shared by every server on this route. Mount its
|
|
93
|
+
handler with every server it may deliver to:
|
|
94
94
|
|
|
95
95
|
```ts app/api/a2/scheduler/route.ts
|
|
96
|
-
import {
|
|
97
|
-
import { ordersServer, billingServer } from '@/server'
|
|
96
|
+
import { billingServer, ordersServer, scheduler } from '@/server'
|
|
98
97
|
|
|
99
|
-
export const POST =
|
|
98
|
+
export const POST = scheduler.handler(ordersServer, billingServer)
|
|
100
99
|
```
|
|
101
100
|
|
|
102
|
-
`
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
can accept a delivery.
|
|
101
|
+
`scheduler.handler(...)` checks the server list synchronously when this module
|
|
102
|
+
loads. It requires at least one drainable server and unique contract names. A
|
|
103
|
+
bad route fails during startup or a build that evaluates route modules, before
|
|
104
|
+
it can accept a delivery.
|
|
107
105
|
|
|
108
106
|
```json vercel.json
|
|
109
107
|
{
|
|
@@ -177,7 +175,7 @@ export const ordersServer = createServer({
|
|
|
177
175
|
})
|
|
178
176
|
```
|
|
179
177
|
|
|
180
|
-
Mount `
|
|
178
|
+
Mount `scheduler.handler(ordersServer, billingServer)` once with the same
|
|
181
179
|
`app/api/a2/scheduler/route.ts` module shown above. Local Node.js development
|
|
182
180
|
needs no QStash environment variables. With `@upstash/qstash` 2.11.0 or newer,
|
|
183
181
|
A2 uses `http://localhost:${PORT ?? 3000}/api/a2/scheduler` and enables the
|
|
@@ -129,14 +129,10 @@ One HTTP route gives the browser a read and write path. `GET` streams events;
|
|
|
129
129
|
`POST` accepts optimistic pushes:
|
|
130
130
|
|
|
131
131
|
```ts app/api/agent-events/route.ts
|
|
132
|
-
import { handle } from 'experimental-a2/http'
|
|
133
132
|
import { assistantServer } from '@/server/assistant'
|
|
134
133
|
|
|
135
|
-
export const
|
|
136
|
-
|
|
137
|
-
// here's where you'd do auth, or any other checks
|
|
138
|
-
},
|
|
139
|
-
})
|
|
134
|
+
export const GET = assistantServer.fetch
|
|
135
|
+
export const POST = assistantServer.fetch
|
|
140
136
|
```
|
|
141
137
|
|
|
142
138
|
The route never calls the model directly. The browser appends user facts such
|
|
@@ -631,7 +627,8 @@ authorization are ready. There is no fixed tool concurrency limit. A private
|
|
|
631
627
|
coordinator reducer tracks generation closure, cancellation, calls, approvals,
|
|
632
628
|
and terminal results for the active response. Its retained state is bounded;
|
|
633
629
|
completed responses do not accumulate in the coordinator. Each join reads a
|
|
634
|
-
durable reducer snapshot plus the log tail through
|
|
630
|
+
durable reducer snapshot plus the log tail through
|
|
631
|
+
`ctx.session.state(coordinator, { through: 'latest' })`. The snapshot
|
|
635
632
|
is only a cache. Recovery can rebuild the same coordinator state from the event
|
|
636
633
|
log after process death.
|
|
637
634
|
|
|
@@ -814,10 +811,7 @@ different server assembly:
|
|
|
814
811
|
```ts server/custom.ts
|
|
815
812
|
import { z } from 'zod'
|
|
816
813
|
import { agent } from 'experimental-a2/ai'
|
|
817
|
-
import {
|
|
818
|
-
createHandlers,
|
|
819
|
-
validateAgentPush,
|
|
820
|
-
} from 'experimental-a2/ai/server'
|
|
814
|
+
import { createHandlers } from 'experimental-a2/ai/server'
|
|
821
815
|
import { createServer } from 'experimental-a2/server'
|
|
822
816
|
|
|
823
817
|
const supportAgent = agent({
|
|
@@ -834,7 +828,6 @@ const aiHandlers = createHandlers({
|
|
|
834
828
|
|
|
835
829
|
export const customAssistantServer = createServer({
|
|
836
830
|
contract: supportAgent.contract,
|
|
837
|
-
validatePush: validateAgentPush,
|
|
838
831
|
handlers: {
|
|
839
832
|
...aiHandlers,
|
|
840
833
|
'ticket.linked': async ({ event }) => {
|
|
@@ -847,9 +840,11 @@ export const customAssistantServer = createServer({
|
|
|
847
840
|
|
|
848
841
|
Application events stay fully typed. The standard AI reducer ignores unknown
|
|
849
842
|
events, so another reducer can project application state without forking the AI
|
|
850
|
-
protocol. `
|
|
851
|
-
`
|
|
852
|
-
|
|
843
|
+
protocol. A custom `createServer()` assembly owns its browser ingress policy.
|
|
844
|
+
Use `server.fetch(request, { authorize })` to authorize operations, and reject
|
|
845
|
+
server-only AI event names there before they reach the custom server.
|
|
846
|
+
`createAgentServer()` applies its stricter built-in ingress allowlist
|
|
847
|
+
automatically.
|
|
853
848
|
|
|
854
849
|
### Replace generation, not durability
|
|
855
850
|
|
|
@@ -63,29 +63,25 @@ explicit leave required).
|
|
|
63
63
|
|
|
64
64
|
## The route
|
|
65
65
|
|
|
66
|
-
The same `
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
`setPresence`.
|
|
66
|
+
The same `server.fetch` route as [Live UI](/guides/react). Presence is
|
|
67
|
+
inferred from the contract. A contract with presence fields streams
|
|
68
|
+
presence patches with events, starting with a snapshot of the current
|
|
69
|
+
map. Its push body also accepts an optional `presence` sibling to
|
|
70
|
+
`events`, forwarded to `setPresence`.
|
|
71
71
|
|
|
72
72
|
```ts app/api/canvas-events/route.ts
|
|
73
|
-
import { handle } from 'experimental-a2/http'
|
|
74
73
|
import { canvasServer } from '@/server/canvas'
|
|
75
74
|
|
|
76
|
-
export const
|
|
77
|
-
|
|
78
|
-
before({ request, intent }) {
|
|
79
|
-
// here's where you'd do auth, or any other checks. On a push,
|
|
80
|
-
// intent.presence carries the whole patch; the participant id
|
|
81
|
-
// is caller-supplied, so authorize it like you authorize events.
|
|
82
|
-
// createServer's validatePush is the same seam and covers every
|
|
83
|
-
// transport (socket presence frames never become intents): on the
|
|
84
|
-
// presence plane it receives { sessionId, events: [], presence }.
|
|
85
|
-
},
|
|
86
|
-
})
|
|
75
|
+
export const GET = canvasServer.fetch
|
|
76
|
+
export const POST = canvasServer.fetch
|
|
87
77
|
```
|
|
88
78
|
|
|
79
|
+
Wrap `server.fetch` when the route needs authentication. Pass
|
|
80
|
+
`authorize` to check the typed A2 operation before field validation or
|
|
81
|
+
I/O. A push operation carries the complete presence patch, including
|
|
82
|
+
its caller-supplied participant id. The same hook runs for HTTP and
|
|
83
|
+
WebSocket presence.
|
|
84
|
+
|
|
89
85
|
`setPresence` validates each field against the contract, then
|
|
90
86
|
broadcasts. No append transaction, no dispatch, no scheduler arm, no log
|
|
91
87
|
row. A bad field throws `INVALID_PAYLOAD`; an unknown field throws
|
|
@@ -177,8 +173,7 @@ Both members are typed from the contract's `presence` map, through the
|
|
|
177
173
|
reducer, with no type arguments: field names, payloads, and `null`
|
|
178
174
|
clears all autocomplete. On a contract without `presence` the members
|
|
179
175
|
do not exist, so reaching for them is a compile error, not a silent
|
|
180
|
-
no-op.
|
|
181
|
-
echo while others are active, the GET route forgot `presence: true`.
|
|
176
|
+
no-op.
|
|
182
177
|
|
|
183
178
|
## Reconciling the planes: `seen`
|
|
184
179
|
|
|
@@ -7,8 +7,8 @@ description: "One client, three wires: a single HTTP route, split routes for per
|
|
|
7
7
|
|
|
8
8
|
Everything the client does is transport-agnostic: optimistic pushes
|
|
9
9
|
with ack and `confirmed`, frontier-resumed live reads, reconnection
|
|
10
|
-
with backoff,
|
|
11
|
-
|
|
10
|
+
with backoff, and presence throttling. `api` picks the wire underneath
|
|
11
|
+
it:
|
|
12
12
|
|
|
13
13
|
```ts
|
|
14
14
|
// in a 'use client' session module:
|
|
@@ -18,73 +18,111 @@ api: { type: 'ws', url: '/api/order-events' }
|
|
|
18
18
|
```
|
|
19
19
|
|
|
20
20
|
- **The string** is the default and the recommendation: one route
|
|
21
|
-
serving GET (SSE stream) and POST (push)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
doesn't.
|
|
27
|
-
- **`ws`** rides everything over one WebSocket: streams come down it,
|
|
28
|
-
pushes and presence go up it. One socket carries every session of
|
|
29
|
-
the client; a page showing ten sessions holds one connection, not
|
|
30
|
-
ten. Use it when latency or per-message cost matters; at a presence
|
|
31
|
-
cadence of fifteen sends a second, each send is a socket frame
|
|
32
|
-
instead of a route invocation.
|
|
21
|
+
serving GET (SSE stream and history) and POST (push).
|
|
22
|
+
- **`http` split** serves the two verbs from separate routes. Use it
|
|
23
|
+
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.
|
|
33
26
|
|
|
34
27
|
A split socket is unrepresentable on purpose. The socket is one
|
|
35
|
-
connection in both directions
|
|
28
|
+
connection in both directions.
|
|
36
29
|
|
|
37
30
|
One wire is missing from `ws` by design: the history lane.
|
|
38
31
|
[`loadHistory`](/guides/react#the-client-component) is a bounded cold
|
|
39
|
-
read and rides plain HTTP
|
|
32
|
+
read and rides plain HTTP. On a `ws` API it throws a `TypeError`.
|
|
33
|
+
|
|
34
|
+
## The HTTP route
|
|
35
|
+
|
|
36
|
+
`server.fetch` is bound, so an unprotected route is two exports:
|
|
37
|
+
|
|
38
|
+
```ts app/api/public-order-events/route.ts
|
|
39
|
+
import { ordersServer } from '@/server/orders'
|
|
40
|
+
|
|
41
|
+
export const GET = ordersServer.fetch
|
|
42
|
+
export const POST = ordersServer.fetch
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
A plain GET streams one session over SSE. A GET with `gte` and `lte`
|
|
46
|
+
returns a history slice. POST parses the push envelope and appends its
|
|
47
|
+
events. If the contract declares presence, the same route streams and
|
|
48
|
+
accepts presence automatically.
|
|
49
|
+
|
|
50
|
+
Wrap the call when the route needs authentication or authorization:
|
|
51
|
+
|
|
52
|
+
```ts app/api/protected-order-events/route.ts
|
|
53
|
+
import { ordersServer } from '@/server/orders'
|
|
54
|
+
|
|
55
|
+
async function fetchOrders(request: Request): Promise<Response> {
|
|
56
|
+
// your authentication:
|
|
57
|
+
const user = { id: 'user-1' }
|
|
58
|
+
if (!user) return new Response(null, { status: 401 })
|
|
59
|
+
|
|
60
|
+
return ordersServer.fetch(request, {
|
|
61
|
+
authorize(operation) {
|
|
62
|
+
// your per-session and per-operation authorization:
|
|
63
|
+
// return canAccess(user, operation)
|
|
64
|
+
return true
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const GET = fetchOrders
|
|
70
|
+
export const POST = fetchOrders
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`authorize` runs after A2 parses the operation and before schema
|
|
74
|
+
validation or durable I/O. It receives `stream`, `history`, and `push`
|
|
75
|
+
operations. Push operations include parsed event names and payloads,
|
|
76
|
+
plus the complete presence patch when present. Return `false` to deny.
|
|
77
|
+
A2 answers `FORBIDDEN` with status 403. If the hook throws, A2 treats
|
|
78
|
+
that as an infrastructure failure and answers `STORE_UNAVAILABLE`.
|
|
79
|
+
|
|
80
|
+
The hook is per `fetch` call. Authentication stays in the route, where
|
|
81
|
+
request headers, cookies, and framework context are available. The
|
|
82
|
+
captured user can then authorize strongly typed A2 operations without
|
|
83
|
+
repeating request parsing.
|
|
40
84
|
|
|
41
85
|
## The WebSocket route
|
|
42
86
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
stay SSE, POST keeps working next to it. A `ws` client never calls
|
|
46
|
-
POST, an `http` client never upgrades; the transports are additive.
|
|
87
|
+
Pass the platform's upgrade capability on GET. POST can stay a direct
|
|
88
|
+
export for HTTP clients:
|
|
47
89
|
|
|
48
|
-
```ts app/api/order-events/route.ts
|
|
90
|
+
```ts app/api/socket-order-events/route.ts
|
|
49
91
|
import { experimental_upgradeWebSocket } from '@vercel/functions'
|
|
50
|
-
import { handle } from 'experimental-a2/http'
|
|
51
92
|
import { ordersServer } from '@/server/orders'
|
|
52
93
|
|
|
53
|
-
export const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}),
|
|
64
|
-
})
|
|
94
|
+
export const POST = ordersServer.fetch
|
|
95
|
+
|
|
96
|
+
export function GET(request: Request): Promise<Response> {
|
|
97
|
+
return ordersServer.fetch(request, {
|
|
98
|
+
upgradeWebSocket: (attach) =>
|
|
99
|
+
experimental_upgradeWebSocket(attach, {
|
|
100
|
+
maxPayload: 4 * 1024 * 1024,
|
|
101
|
+
}),
|
|
102
|
+
})
|
|
103
|
+
}
|
|
65
104
|
```
|
|
66
105
|
|
|
67
|
-
The socket is multiplexed
|
|
68
|
-
each resuming from its own frontier
|
|
69
|
-
`sessionId
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
`
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
fire-and-forget and
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
platform deadline, so clients reconnect on your schedule.
|
|
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.
|
|
110
|
+
|
|
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`.
|
|
116
|
+
|
|
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.
|
|
122
|
+
|
|
123
|
+
A2 reads the platform's ambient invocation deadline and closes the
|
|
124
|
+
socket cleanly before it. `waitUntil` and deadline discovery remain
|
|
125
|
+
platform capabilities used internally, not options on `server.fetch`.
|
|
88
126
|
|
|
89
127
|
## The client
|
|
90
128
|
|
|
@@ -104,37 +142,17 @@ export const { SessionProvider, useSession } = createReact({
|
|
|
104
142
|
```
|
|
105
143
|
|
|
106
144
|
Nothing else changes. `useSession` is identical, `push` returns the
|
|
107
|
-
same ack and `confirmed`, presence coalesces on the same cadence.
|
|
108
|
-
connection status,
|
|
109
|
-
|
|
110
|
-
are the same machinery above the wire seam.
|
|
145
|
+
same ack and `confirmed`, and presence coalesces on the same cadence.
|
|
146
|
+
The connection status, stall watchdog, and reconnection behavior are
|
|
147
|
+
shared above the wire seam.
|
|
111
148
|
|
|
112
149
|
## Lifecycle
|
|
113
150
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
replay (you get the original events back). Push retries wait for the
|
|
123
|
-
restored socket rather than a fixed clock, capped by the reconnect
|
|
124
|
-
backoff, so a push caught in a routine blip resolves the way it would
|
|
125
|
-
have over HTTP, where pushes never depended on the stream.
|
|
126
|
-
|
|
127
|
-
On Vercel, WebSocket connections pin to one function instance and new
|
|
128
|
-
connections may land anywhere. A2 never relies on instance affinity:
|
|
129
|
-
the log is the shared state, so two clients on two instances converge
|
|
130
|
-
through it, exactly as they do over HTTP.
|
|
131
|
-
|
|
132
|
-
## Choosing
|
|
133
|
-
|
|
134
|
-
SSE plus POST is the right default: simplest route, ordinary HTTP
|
|
135
|
-
semantics, and one auth surface. Reach for `ws` when the send rate
|
|
136
|
-
makes per-request overhead visible (live cursors, drag ghosts) or when
|
|
137
|
-
round-trip latency is the product. Reach for the `http` split only when
|
|
138
|
-
per-route duration configuration forces it. The protocol underneath is
|
|
139
|
-
the same in every case: the log replays, presence repaints, and the
|
|
140
|
-
wire is an implementation detail.
|
|
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.
|
|
155
|
+
|
|
156
|
+
SSE and WebSocket are transport choices, not different consistency
|
|
157
|
+
models. Both resume from the durable event frontier and treat presence
|
|
158
|
+
as ephemeral repaintable state.
|