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.
Files changed (148) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/ai-server.d.ts +4 -5
  3. package/dist/ai-server.d.ts.map +1 -1
  4. package/dist/ai-server.js +20 -17
  5. package/dist/ai-server.js.map +1 -1
  6. package/dist/ai.d.ts +334 -2
  7. package/dist/ai.d.ts.map +1 -0
  8. package/dist/ai.js +1 -1
  9. package/dist/client.d.ts +202 -2
  10. package/dist/client.d.ts.map +1 -0
  11. package/dist/client.js +1025 -1
  12. package/dist/client.js.map +1 -0
  13. package/dist/errors-BQuJpe82.js.map +1 -1
  14. package/dist/index.d.ts +22 -3
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/{internal-DstsI6Re.js → internal-DRXJ56EI.js} +5 -28
  17. package/dist/internal-DRXJ56EI.js.map +1 -0
  18. package/dist/react.d.ts +1 -1
  19. package/dist/react.js +1 -1
  20. package/dist/scheduler-qstash.d.ts +3 -3
  21. package/dist/scheduler-qstash.js +4 -5
  22. package/dist/scheduler-qstash.js.map +1 -1
  23. package/dist/scheduler-vercel.d.ts +2 -2
  24. package/dist/scheduler-vercel.js +4 -4
  25. package/dist/scheduler-vercel.js.map +1 -1
  26. package/dist/{server-Duw6MVlB.js → server-B2XNevQA.js} +830 -131
  27. package/dist/server-B2XNevQA.js.map +1 -0
  28. package/dist/{server-DpvjhdoE.d.ts → server-DjPhHnbI.d.ts} +71 -50
  29. package/dist/server-DjPhHnbI.d.ts.map +1 -0
  30. package/dist/server.d.ts +3 -3
  31. package/dist/server.js +1 -1
  32. package/dist/store-N8PXxDAS.js.map +1 -1
  33. package/dist/{store-DysUkTH3.d.ts → store-RJO35BMj.d.ts} +24 -62
  34. package/dist/store-RJO35BMj.d.ts.map +1 -0
  35. package/dist/store-memory.d.ts +1 -1
  36. package/dist/store-memory.d.ts.map +1 -1
  37. package/dist/store-memory.js +80 -78
  38. package/dist/store-memory.js.map +1 -1
  39. package/dist/{store-polling-dSeLxzfb.js → store-polling-6DW7F1DT.js} +2 -2
  40. package/dist/{store-polling-dSeLxzfb.js.map → store-polling-6DW7F1DT.js.map} +1 -1
  41. package/dist/store-postgres.d.ts +1 -1
  42. package/dist/store-postgres.d.ts.map +1 -1
  43. package/dist/store-postgres.js +231 -182
  44. package/dist/store-postgres.js.map +1 -1
  45. package/dist/{store-redis-core-BFLwz0Wj.js → store-redis-core-DT01r4GZ.js} +213 -161
  46. package/dist/store-redis-core-DT01r4GZ.js.map +1 -0
  47. package/dist/store-redis-http.d.ts +1 -1
  48. package/dist/store-redis-http.js +3 -4
  49. package/dist/store-redis-http.js.map +1 -1
  50. package/dist/store-redis.d.ts +1 -1
  51. package/dist/store-redis.js +4 -5
  52. package/dist/store-redis.js.map +1 -1
  53. package/dist/store-sqlite.d.ts +1 -1
  54. package/dist/store-sqlite.d.ts.map +1 -1
  55. package/dist/store-sqlite.js +104 -91
  56. package/dist/store-sqlite.js.map +1 -1
  57. package/dist/{wire-BFQmSJ-9.js → wire-B6te_wns.js} +4 -3
  58. package/dist/wire-B6te_wns.js.map +1 -0
  59. package/docs/concepts/02-handlers.mdx +4 -0
  60. package/docs/concepts/04-state.mdx +57 -9
  61. package/docs/guides/03-react.mdx +20 -28
  62. package/docs/guides/05-production.mdx +9 -11
  63. package/docs/guides/06-ai-agents.mdx +10 -15
  64. package/docs/guides/09-presence.mdx +14 -19
  65. package/docs/guides/10-transports.mdx +104 -86
  66. package/docs/reference/01-api.mdx +182 -293
  67. package/docs/reference/02-errors.mdx +5 -7
  68. package/package.json +1 -14
  69. package/src/ai-server.ts +36 -15
  70. package/src/client.ts +2 -2
  71. package/src/errors.ts +1 -0
  72. package/src/internal.ts +3 -62
  73. package/src/push-envelope.ts +24 -21
  74. package/src/scheduler-qstash.ts +3 -3
  75. package/src/scheduler-vercel.ts +2 -2
  76. package/src/server-fetch.ts +344 -0
  77. package/src/server.ts +315 -312
  78. package/src/session-socket.ts +36 -20
  79. package/src/sse.ts +2 -2
  80. package/src/store-memory.ts +138 -101
  81. package/src/store-postgres.ts +355 -238
  82. package/src/store-redis-core.ts +247 -237
  83. package/src/store-redis-http.ts +1 -2
  84. package/src/store-redis.ts +1 -2
  85. package/src/store-sqlite.ts +191 -153
  86. package/src/store.ts +24 -66
  87. package/src/wire.ts +2 -1
  88. package/dist/ai-D_PGS-JR.d.ts +0 -334
  89. package/dist/ai-D_PGS-JR.d.ts.map +0 -1
  90. package/dist/cli-B3VuxoDe.js +0 -599
  91. package/dist/cli-B3VuxoDe.js.map +0 -1
  92. package/dist/cli-bin.d.ts +0 -1
  93. package/dist/cli-bin.js +0 -7
  94. package/dist/cli-bin.js.map +0 -1
  95. package/dist/cli.d.ts +0 -20
  96. package/dist/cli.d.ts.map +0 -1
  97. package/dist/cli.js +0 -2
  98. package/dist/client-CdMqi7mC.d.ts +0 -202
  99. package/dist/client-CdMqi7mC.d.ts.map +0 -1
  100. package/dist/client-Dj5d3SP_.js +0 -1026
  101. package/dist/client-Dj5d3SP_.js.map +0 -1
  102. package/dist/devtools-J_jZ2vQf.d.ts +0 -152
  103. package/dist/devtools-J_jZ2vQf.d.ts.map +0 -1
  104. package/dist/devtools-kJJaORn-.js +0 -340
  105. package/dist/devtools-kJJaORn-.js.map +0 -1
  106. package/dist/devtools-server.browser.d.ts +0 -1
  107. package/dist/devtools-server.browser.js +0 -6
  108. package/dist/devtools-server.browser.js.map +0 -1
  109. package/dist/devtools-server.d.ts +0 -23
  110. package/dist/devtools-server.d.ts.map +0 -1
  111. package/dist/devtools-server.js +0 -1270
  112. package/dist/devtools-server.js.map +0 -1
  113. package/dist/devtools.d.ts +0 -2
  114. package/dist/devtools.js +0 -2
  115. package/dist/errors-W6nwJ-fm.d.ts +0 -21
  116. package/dist/errors-W6nwJ-fm.d.ts.map +0 -1
  117. package/dist/http.d.ts +0 -151
  118. package/dist/http.d.ts.map +0 -1
  119. package/dist/http.js +0 -706
  120. package/dist/http.js.map +0 -1
  121. package/dist/inspection-DaxB5jM2.js +0 -13
  122. package/dist/inspection-DaxB5jM2.js.map +0 -1
  123. package/dist/internal-DstsI6Re.js.map +0 -1
  124. package/dist/platform-B4TnJtWu.js +0 -34
  125. package/dist/platform-B4TnJtWu.js.map +0 -1
  126. package/dist/server-DpvjhdoE.d.ts.map +0 -1
  127. package/dist/server-Duw6MVlB.js.map +0 -1
  128. package/dist/store-DysUkTH3.d.ts.map +0 -1
  129. package/dist/store-redis-core-BFLwz0Wj.js.map +0 -1
  130. package/dist/testing.browser.d.ts +0 -1
  131. package/dist/testing.browser.js +0 -6
  132. package/dist/testing.browser.js.map +0 -1
  133. package/dist/testing.d.ts +0 -32
  134. package/dist/testing.d.ts.map +0 -1
  135. package/dist/testing.js +0 -103
  136. package/dist/testing.js.map +0 -1
  137. package/dist/wire-BFQmSJ-9.js.map +0 -1
  138. package/docs/guides/07-devtools.mdx +0 -229
  139. package/src/cli-bin.ts +0 -5
  140. package/src/cli.ts +0 -1046
  141. package/src/devtools-app.ts +0 -989
  142. package/src/devtools-server.browser.ts +0 -5
  143. package/src/devtools-server.ts +0 -604
  144. package/src/devtools.ts +0 -716
  145. package/src/http.ts +0 -394
  146. package/src/inspection.ts +0 -39
  147. package/src/testing.browser.ts +0 -5
  148. 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. Inside a handler it
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
- The read and a following `ctx.session.append(name, ...events)` are separate
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, use a monotone readiness check (once ready, always ready) and give
108
- the output event a stable explicit `id`. Repeated attempts then converge on the
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. It
142
- never delays the read, and a failed or interrupted cache write changes no
143
- application behavior. The next read folds the missing tail again.
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
 
@@ -23,22 +23,15 @@ reducer, and the provider + hook it returns.
23
23
 
24
24
  ## The API route
25
25
 
26
- One call exposes a session over HTTP: `GET` streams events (and serves
27
- history slices), `POST` appends. `handle` parses each request into an
28
- intent, runs your hooks, then acts.
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 { GET, POST } = handle(ordersServer, {
35
- before({ request, intent }) {
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 (garbage
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
- Parsing is protocol, hooks are policy. `before` sees every parsed
58
- intent and short-circuits by returning a Response. `after` runs when the
59
- library produced an HTTP response and can decorate or replace it. That
60
- is where caching policy lives, if you want any: `outcome.covered` on a
61
- history read means the closed range came back fully covered, an
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 const { GET, POST } = handle(ordersServer, {
70
- after({ outcome, response }) {
71
- if (outcome.type === 'history' && outcome.covered) {
72
- response.headers.set('cache-control', 'private, max-age=31536000')
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. The route
93
- derives that scheduler from the servers:
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 { schedulerHandler } from 'experimental-a2/http'
97
- import { ordersServer, billingServer } from '@/server'
96
+ import { billingServer, ordersServer, scheduler } from '@/server'
98
97
 
99
- export const POST = schedulerHandler(ordersServer, billingServer)
98
+ export const POST = scheduler.handler(ordersServer, billingServer)
100
99
  ```
101
100
 
102
- `schedulerHandler(...)` checks the wiring synchronously when this module loads.
103
- Every argument must be an A2 server with a scheduler, every contract name must
104
- be unique, and every server must hold the exact same scheduler instance. A bad
105
- route fails during startup or a build that evaluates route modules, before it
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 `schedulerHandler(ordersServer, billingServer)` once with the same
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 { GET, POST } = handle(assistantServer, {
136
- before({ request, intent }) {
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 `ctx.session.state()`. The snapshot
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. `validateAgentPush` preserves the same browser boundary as
851
- `createAgentServer()`: user facts may enter through a parsed push, while model
852
- scheduling, tool lifecycle, and seeded non-user messages stay server-authored.
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 `handle` route as [Live UI](/guides/react), with one option.
67
- `presence: true` interleaves presence patches with events on the
68
- stream, starting with a snapshot of the current map; the push body
69
- grows an optional `presence` sibling to `events`, forwarded to
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 { GET, POST } = handle(canvasServer, {
77
- presence: true,
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. One symptom worth knowing: if the map holds only your own
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, the presence throttle and re-send. `api` picks the wire
11
- underneath it:
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), one file, one auth surface.
22
- Everything else in these docs uses it.
23
- - **`http` split** serves the two verbs from two routes. Use it when
24
- platform duration limits differ per verb: the stream is a long-lived
25
- read that wants a high `maxDuration`, the push is a short write that
26
- doesn't.
27
- - **`ws`** rides everything over one WebSocket: 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; there is nothing left to split.
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; on a `ws` api it throws a `TypeError`.
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
- The same `handle` route serves both transports. Pass `options.upgrade`
44
- and a GET carrying an upgrade header becomes the socket; plain GETs
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 { GET, POST } = handle(ordersServer, {
54
- before({ request, intent }) {
55
- // here's where you'd do auth, or any other checks: the upgrade
56
- // itself, every subscribe, and every push arrive here as intents
57
- },
58
- upgrade: (attach) =>
59
- experimental_upgradeWebSocket(attach, {
60
- // ws defaults to 100 MiB per frame; POST bodies cap at about
61
- // 4.5 MB on the platform. Keep the two ingress paths at parity.
62
- maxPayload: 4 * 1024 * 1024,
63
- }),
64
- })
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: `subscribe` frames open per-session lanes,
68
- each resuming from its own frontier; every down frame carries the
69
- `sessionId` it belongs to; pushes and presence route by it. One
70
- heartbeat, one connection, all of the client's sessions.
71
-
72
- Auth has two moments. `before` runs for the upgrade itself
73
- (`intent.type === 'ws-upgrade'`), while the request is still a request;
74
- return a Response to refuse and no socket ever opens. It then runs
75
- again for every subscribe and push frame: each subscribe arrives as a
76
- `stream` intent, each push as a `push` intent, with `request` always
77
- the original upgrade Request. A Response cannot cross a socket, so a
78
- denial answers in the wire's own vocabulary: a denied subscribe gets an
79
- `unsubscribed` notice (every other session on the socket streams on), a
80
- denied push a non-retryable error ack. Socket presence frames are
81
- fire-and-forget and never become intents; the presence plane's policy
82
- seam on every wire is `validatePush`.
83
-
84
- Two more options ride along: `presence: true` interleaves presence with
85
- events on every lane, the same opt-in as `stream()`, and `deadline`
86
- (epoch milliseconds) closes the socket cleanly ahead of a known
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. The
108
- connection status, the stall watchdog (two missed heartbeats), and
109
- reconnection with backoff all behave as they do over SSE, because they
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
- A socket closes when the platform ends the function invocation, or
115
- when the server closes it deliberately ahead of a known deadline. The
116
- client treats every close the same way it treats a dropped SSE stream:
117
- reconnect with backoff, re-subscribe every session at its own frontier,
118
- receive fresh presence snapshots, re-send its own presence fields set
119
- since the disconnect. A push whose socket died before the ack rejects as
120
- retryable: nothing was acknowledged, and if the append had already
121
- committed, the client-generated event ids make the retry an idempotent
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.