experimental-a2 0.5.1 → 0.7.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 +67 -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 +8 -7
  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 +41 -7
  19. package/dist/react.d.ts.map +1 -1
  20. package/dist/react.js +74 -35
  21. package/dist/react.js.map +1 -1
  22. package/dist/scheduler-qstash.d.ts +3 -3
  23. package/dist/scheduler-qstash.js +4 -5
  24. package/dist/scheduler-qstash.js.map +1 -1
  25. package/dist/scheduler-vercel.d.ts +2 -2
  26. package/dist/scheduler-vercel.js +4 -4
  27. package/dist/scheduler-vercel.js.map +1 -1
  28. package/dist/{server-Duw6MVlB.js → server-286j79Mt.js} +708 -79
  29. package/dist/server-286j79Mt.js.map +1 -0
  30. package/dist/{server-DpvjhdoE.d.ts → server-DgXmORIq.d.ts} +67 -49
  31. package/dist/server-DgXmORIq.d.ts.map +1 -0
  32. package/dist/server.d.ts +3 -3
  33. package/dist/server.js +1 -1
  34. package/dist/store-N8PXxDAS.js.map +1 -1
  35. package/dist/{store-DysUkTH3.d.ts → store-flRz1OWh.d.ts} +2 -57
  36. package/dist/store-flRz1OWh.d.ts.map +1 -0
  37. package/dist/store-memory.d.ts +1 -1
  38. package/dist/store-memory.d.ts.map +1 -1
  39. package/dist/store-memory.js +1 -59
  40. package/dist/store-memory.js.map +1 -1
  41. package/dist/{store-polling-dSeLxzfb.js → store-polling-6DW7F1DT.js} +2 -2
  42. package/dist/{store-polling-dSeLxzfb.js.map → store-polling-6DW7F1DT.js.map} +1 -1
  43. package/dist/store-postgres.d.ts +1 -1
  44. package/dist/store-postgres.js +1 -81
  45. package/dist/store-postgres.js.map +1 -1
  46. package/dist/{store-redis-core-BFLwz0Wj.js → store-redis-core-DEYO8Ryv.js} +48 -134
  47. package/dist/store-redis-core-DEYO8Ryv.js.map +1 -0
  48. package/dist/store-redis-http.d.ts +1 -1
  49. package/dist/store-redis-http.js +2 -3
  50. package/dist/store-redis-http.js.map +1 -1
  51. package/dist/store-redis.d.ts +1 -1
  52. package/dist/store-redis.js +3 -4
  53. package/dist/store-redis.js.map +1 -1
  54. package/dist/store-sqlite.d.ts +1 -1
  55. package/dist/store-sqlite.d.ts.map +1 -1
  56. package/dist/store-sqlite.js +1 -72
  57. package/dist/store-sqlite.js.map +1 -1
  58. package/dist/{wire-BFQmSJ-9.js → wire-B6te_wns.js} +4 -3
  59. package/dist/wire-B6te_wns.js.map +1 -0
  60. package/docs/guides/03-react.mdx +118 -29
  61. package/docs/guides/05-production.mdx +9 -11
  62. package/docs/guides/06-ai-agents.mdx +8 -14
  63. package/docs/guides/09-presence.mdx +19 -21
  64. package/docs/guides/10-transports.mdx +104 -86
  65. package/docs/reference/01-api.mdx +186 -279
  66. package/docs/reference/02-errors.mdx +5 -7
  67. package/package.json +1 -14
  68. package/src/ai-server.ts +9 -5
  69. package/src/client.ts +71 -35
  70. package/src/errors.ts +1 -0
  71. package/src/internal.ts +3 -62
  72. package/src/push-envelope.ts +24 -21
  73. package/src/react.ts +118 -44
  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 +73 -225
  78. package/src/session-socket.ts +36 -20
  79. package/src/sse.ts +2 -2
  80. package/src/store-memory.ts +0 -81
  81. package/src/store-postgres.ts +0 -100
  82. package/src/store-redis-core.ts +47 -211
  83. package/src/store-redis-http.ts +0 -1
  84. package/src/store-redis.ts +0 -1
  85. package/src/store-sqlite.ts +0 -119
  86. package/src/store.ts +0 -60
  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-BKlyLiOU.js +0 -1008
  99. package/dist/client-BKlyLiOU.js.map +0 -1
  100. package/dist/client-D7mvIXrF.d.ts +0 -191
  101. package/dist/client-D7mvIXrF.d.ts.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
@@ -72,7 +72,6 @@ createServer(options: {
72
72
  store?: A2Store // default: sqlite in dev, memory in tests, required in prod
73
73
  scheduler?: A2Scheduler
74
74
  telemetry?: A2Telemetry // optional instrumentation; see experimental-a2/otel
75
- validatePush?: (context: PushValidationContext) => void | PromiseLike<void>
76
75
  presence?: { ttlMs?: number } // presence expiry policy; default 60s
77
76
  handlers?: {
78
77
  [type]:
@@ -123,19 +122,6 @@ Server-only by construction: `experimental-a2/server` is the only entry point th
123
122
  can reach a store backend, and its exports map resolves to a loud error
124
123
  under the browser condition.
125
124
 
126
- `validatePush(context)` runs only for input that arrived over the wire
127
- through `handle`'s push lane, once per plane. The events plane invokes it with `{ sessionId, events }` before
128
- contract schema validation and before the store append, so throwing rejects the
129
- complete push without writing anything. The presence plane invokes it with
130
- `{ sessionId, events: [], presence }`, the whole pushed patch with its
131
- participant, before field validation and the broadcast, so authorizing the
132
- participant id (and applying any size or cardinality policy) happens at the
133
- same seam. The patch arrives frozen: authorize, don't rewrite (a mutation
134
- attempt throws and fails the push). Direct trusted server appends, handler appends, and server-side
135
- `setPresence` bypass it. The envelope parser inside `handle` creates
136
- the runtime provenance brand after reading the envelope; a caller-supplied
137
- field with the same name is ignored, and the brand is not stored in the log.
138
-
139
125
  `presence.ttlMs` sets how long a presence value survives without a
140
126
  refreshing set (default 60 seconds, as a positive integer of
141
127
  milliseconds). Expiry counts on the storage's own clock, never on the
@@ -203,6 +189,136 @@ retries may move the frontier between them. A generic join should use a
203
189
  monotone readiness predicate and a stable explicit output event `id`, so every
204
190
  eligible attempt converges on the same append.
205
191
 
192
+ ### `server.fetch(request, options?)`
193
+
194
+ ```ts
195
+ server.fetch(
196
+ request: Request,
197
+ options?: {
198
+ authorize?: (operation: A2Operation) => boolean | Promise<boolean>
199
+ upgradeWebSocket?: (
200
+ attach: (socket: A2Socket) => void,
201
+ ) => Response | Promise<Response>
202
+ },
203
+ ): Promise<Response>
204
+
205
+ type A2Operation =
206
+ | {
207
+ type: 'stream'
208
+ sessionId: string
209
+ startAfter: number
210
+ transport: 'http' | 'websocket'
211
+ }
212
+ | {
213
+ type: 'history'
214
+ sessionId: string
215
+ gte: number
216
+ lte: number
217
+ transport: 'http'
218
+ }
219
+ | {
220
+ type: 'push'
221
+ sessionId: string
222
+ events: readonly {
223
+ type: ContractEventType | string
224
+ payload: unknown
225
+ id?: string
226
+ }[]
227
+ presence?: {
228
+ participant: string
229
+ values: Readonly<Record<string, unknown>>
230
+ seen?: number
231
+ at?: number
232
+ }
233
+ transport: 'http' | 'websocket'
234
+ }
235
+ ```
236
+
237
+ The bound Fetch API route for this server. An unprotected route can export it
238
+ directly:
239
+
240
+ ```ts app/api/public-order-events/route.ts
241
+ import { ordersServer } from '@/server/orders'
242
+
243
+ export const GET = ordersServer.fetch
244
+ export const POST = ordersServer.fetch
245
+ ```
246
+
247
+ A plain GET streams one session over SSE, resumed after the `index` query
248
+ parameter. A GET with `gte` and `lte` returns the closed history range as JSON
249
+ and sets `a2-history-covered` to `true` when every requested index was returned.
250
+ POST accepts `{ sessionId, events, presence? }` and returns the appended events.
251
+ Presence is inferred from the contract. A contract that declares presence
252
+ streams it and accepts presence pushes automatically.
253
+
254
+ `authorize` runs after the request or socket frame has been parsed, but before
255
+ contract schema validation, presence validation, or I/O. Event names and
256
+ presence field names are typed from the contract. Their unvalidated values stay
257
+ `unknown`. Return `false` to deny the operation with `FORBIDDEN`. A thrown error
258
+ becomes `STORE_UNAVAILABLE`, because authorization infrastructure failed rather
259
+ than denied access. Operation objects, push events, event arrays, and presence
260
+ patches are frozen. Authorize them, do not rewrite them.
261
+
262
+ Authentication stays outside A2, where the route can read headers, cookies, and
263
+ framework context. Capture the authenticated principal in `authorize`:
264
+
265
+ ```ts app/api/protected-order-events/route.ts
266
+ import { ordersServer } from '@/server/orders'
267
+
268
+ async function fetchOrders(request: Request): Promise<Response> {
269
+ // your authentication:
270
+ const user = { id: 'user-1' }
271
+ if (!user) return new Response(null, { status: 401 })
272
+
273
+ return ordersServer.fetch(request, {
274
+ authorize(operation) {
275
+ // your per-session and per-operation authorization:
276
+ // return canAccess(user, operation)
277
+ return true
278
+ },
279
+ })
280
+ }
281
+
282
+ export const GET = fetchOrders
283
+ export const POST = fetchOrders
284
+ ```
285
+
286
+ Direct `session.append`, handler appends, scheduled appends, and server-side
287
+ `setPresence` bypass `authorize`. It is an ingress policy for this fetch call,
288
+ not a global server policy.
289
+
290
+ A GET with `Upgrade: websocket` uses `upgradeWebSocket`. Without that option it
291
+ returns 426. The upgrade function receives an `attach` callback for the
292
+ platform socket:
293
+
294
+ ```ts app/api/socket-order-events/route.ts
295
+ import { experimental_upgradeWebSocket } from '@vercel/functions'
296
+ import { ordersServer } from '@/server/orders'
297
+
298
+ export const POST = ordersServer.fetch
299
+
300
+ export function GET(request: Request): Promise<Response> {
301
+ return ordersServer.fetch(request, {
302
+ upgradeWebSocket: (attach) =>
303
+ experimental_upgradeWebSocket(attach, {
304
+ maxPayload: 4 * 1024 * 1024,
305
+ }),
306
+ })
307
+ }
308
+ ```
309
+
310
+ Authenticate the physical WebSocket GET before calling `server.fetch`. Then
311
+ use `authorize` for each session subscribe and each event or presence push.
312
+ There is no WebSocket-upgrade operation in `A2Operation`. A denied subscribe
313
+ receives `unsubscribed` and does not affect other sessions. A denied event push
314
+ receives a `FORBIDDEN` ack. Denied presence is silently dropped because it is
315
+ fire-and-forget. Failed presence authorization is dropped the same way and does
316
+ not disconnect unrelated sessions on the multiplexed socket.
317
+
318
+ SSE and WebSocket lifetimes use the platform's ambient invocation deadline.
319
+ A2 also uses the platform's ambient `waitUntil` capability for background work.
320
+ Neither capability is reordered or passed through `server.fetch` options.
321
+
206
322
  ### `server.session(id)`
207
323
 
208
324
  ```ts
@@ -372,9 +488,9 @@ Custom scheduler adapters implement two methods:
372
488
  | `schedule(task: SchedulerTask): Promise<void>` | put one versioned task on durable delivery infrastructure |
373
489
  | `handler(...servers)` | return the route that receives drain and append tasks |
374
490
 
375
- `handler` is the low-level adapter-author seam. Application routes call
376
- `schedulerHandler(...servers)` from `experimental-a2/http`, which derives the
377
- adapter from the servers and verifies their wiring before it delegates here.
491
+ Application routes call `scheduler.handler(...servers)` on the configured
492
+ adapter. The handler requires at least one drainable server and rejects
493
+ duplicate contract names synchronously.
378
494
 
379
495
  `SchedulerTask` is `SchedulerDrainTask | SchedulerAppendTask`. These task types,
380
496
  plus `ScheduledEvent`, are exported from `experimental-a2/server`.
@@ -429,7 +545,7 @@ the contract's schemas before anything is written. A multi-event append
429
545
  is atomic: all-or-nothing, consecutive positions, one transaction. Pass
430
546
  `id` to make an append idempotent across retries; re-sending an
431
547
  identical batch returns the original rows. (Events that arrived through
432
- `handle`'s push lane are accepted directly.) See
548
+ `server.fetch`'s push lane are accepted directly.) See
433
549
  [Durability](/concepts/durability).
434
550
 
435
551
  After the write, A2 dispatches only event types with registered handlers.
@@ -537,7 +653,7 @@ session.stream(options: {
537
653
 
538
654
  A live feed of the session's events. `startAfter` is a non-negative safe integer;
539
655
  `startAfter: 20` begins with event 21.
540
- Server-side only; `handle` exposes it over SSE, and over the multiplexed
656
+ Server-side only; `server.fetch` exposes it over SSE, and over the multiplexed
541
657
  socket when `upgrade` is set. Subscribing never dispatches handlers.
542
658
 
543
659
  With `presence: true`, the feed yields one `PresenceSnapshot` first:
@@ -581,7 +697,7 @@ the storage clock, not the sender stamp, so a hostile stamp can only
581
697
  vandalize its own field and still expires on schedule.
582
698
 
583
699
  The patch is structurally the `presence` sibling of the push envelope;
584
- `handle`'s push lane forwards it whole to `session.setPresence(presence)`.
700
+ `server.fetch`'s push lane forwards it whole to `session.setPresence(presence)`.
585
701
  The API mirrors the wire.
586
702
 
587
703
  The handler-scoped form `ctx.session.setPresence(...)` is the same
@@ -650,7 +766,7 @@ transition and the destination should adopt the same optimistic session.
650
766
  | `initialState` | server-rendered state |
651
767
  | `initialIndex` | the fold's frontier, where the stream resumes |
652
768
  | `initialEvents` | optional earlier raw events for a history UI |
653
- | `participant` | this client's presence identity; required to call `setPresence` |
769
+ | `participant` | overrides the client's `participant`; one of the two is required to call `setPresence` |
654
770
 
655
771
  Opens the stream on mount, closes it on unmount, reconnects with
656
772
  backoff from the current frontier. `participant` binds at the session's
@@ -706,9 +822,8 @@ invert). No ack, no `confirmed`, no retry.
706
822
  Both members exist only when the contract declares `presence`; their
707
823
  value and field types come from its schemas, through the reducer, with
708
824
  no type arguments (the reducer is the client's typed handle on the
709
- contract; it still never folds presence). `setPresence` requires the
710
- provider's `participant`. If the map holds only your own echo with
711
- participants active, the GET route forgot `presence: true`. See
825
+ contract; it still never folds presence). `setPresence` requires a
826
+ `participant`: the client's default, or the provider's override. See
712
827
  [Presence](/guides/presence).
713
828
 
714
829
  `push` appends optimistically: validated locally, rolled back on
@@ -754,6 +869,37 @@ connection as dead (aborts it and reconnects), so `live` means bytes
754
869
  are actually flowing, not "the socket hasn't errored yet". See
755
870
  [Live UI](/guides/react).
756
871
 
872
+ ### `useSession(client, sessionId, options?)`
873
+
874
+ ```ts
875
+ useSession(client: A2Client, sessionId: string, options?: {
876
+ participant?: string // overrides the client's participant
877
+ hydrate?: { state: S; index: number }
878
+ }): UseSessionResult
879
+ ```
880
+
881
+ The standalone, provider-less hook. It returns the same result shape
882
+ as the bound hook, for apps whose components reach sessions ad hoc (a
883
+ sidebar of channel sessions, a user session read from a menu). The
884
+ handle is identity-mapped: every hook and provider mounting the same
885
+ session shares one runtime. The stream is refcounted: the first mount
886
+ connects, the last unmount closes (StrictMode-safe).
887
+
888
+ `hydrate` is the hydration input, one atomic option: the
889
+ `{ state, index }` pair `session.state(reducer)` returns, fetched by
890
+ your app through its own route and handed over whenever it lands.
891
+ While it is `undefined`, the hook holds the stream (connecting without
892
+ a fold would replay the whole log). There is no pending flag on the
893
+ result: whether the fold has been handed over is your own input, so
894
+ your data layer's loading state is the pending state.
895
+ When it arrives, the session hydrates under the usual
896
+ never-move-backward rule and the stream connects at that frontier.
897
+ For a deliberate full replay, hand the fold's true starting point:
898
+ `{ state: reducer.initialState, index: 0 }`. State at index 0 is the
899
+ reducer's seed by definition, so the explicit replay needs no special
900
+ vocabulary. See
901
+ [Client-first sessions](/guides/react#client-first-sessions).
902
+
757
903
  ## `experimental-a2/ai`
758
904
 
759
905
  ### `agent(options)`
@@ -1013,17 +1159,10 @@ it into `createServer({ handlers })` beside application handlers when you need
1013
1159
  a custom assembly. The table handles input facts, generation requests, model
1014
1160
  step completion, tool calls, approval responses, and terminal tool results.
1015
1161
  Application handlers spread later can deliberately replace a built-in
1016
- handler. Custom assemblies pass `validateAgentPush` as
1017
- `createServer({ validatePush })` to preserve the browser boundary.
1018
-
1019
- ### `validateAgentPush(context)`
1020
-
1021
- `validateAgentPush({ sessionId, events }): void`
1022
-
1023
- Accepts the browser interaction allowlist: user messages, approval and input
1024
- responses, interruptions, and explicit retries. It rejects server-authored
1025
- scheduling and lifecycle events, seeded non-user messages, and input requests.
1026
- `createAgentServer()` installs it automatically.
1162
+ handler. A custom assembly owns its browser ingress policy. Use
1163
+ `server.fetch(request, { authorize })` to reject server-authored AI event names
1164
+ before they reach the server. `createAgentServer()` installs the built-in
1165
+ browser allowlist automatically.
1027
1166
 
1028
1167
  See [Durable AI agents](/guides/ai-agents) for the protocol and complete
1029
1168
  examples.
@@ -1035,6 +1174,7 @@ createClient(options: {
1035
1174
  reducer: Reducer
1036
1175
  api: ClientApi
1037
1176
  gcTime?: number // idle session lifetime; 5 minutes by default
1177
+ participant?: string // default presence identity for every session
1038
1178
  }): A2Client
1039
1179
 
1040
1180
  type ClientApi =
@@ -1049,11 +1189,19 @@ queue with ack/rollback, and the local fold. `client.session(id, {
1049
1189
  initialState?, initialIndex?, initialEvents?, participant? })` returns a
1050
1190
  handle with `getSnapshot()`/`subscribe()` (the `useSyncExternalStore`
1051
1191
  contract), `push()`, `loadHistory()`, `connect()`, and `close()`.
1192
+ `connect()` takes a lease on the live stream and returns its release:
1193
+ leases refcount per handle (the first connects, releasing the last
1194
+ closes, releasing twice is a no-op), so independent consumers of one
1195
+ identity-mapped handle never fight over the stream. `close()` is the
1196
+ hard stop: it drops every outstanding lease and closes now; a later
1197
+ `connect()` starts fresh.
1052
1198
  Snapshots carry `state`, `events`, `index`, `history`, and `connection`
1053
1199
  (the same fields `useSession` exposes), and `push` returns the same
1054
1200
  ack-then-`confirmed` result. On contracts that declare `presence` the handle also carries
1055
1201
  `setPresence()` and snapshots carry the `presence` map, exactly like
1056
- the hook; `participant` is the identity `setPresence` sends under. Use
1202
+ the hook; `participant` is the identity `setPresence` sends under:
1203
+ stated once on `createClient` as the default for every handle, or per
1204
+ session as the override. Use
1057
1205
  it directly from any other framework, or none.
1058
1206
 
1059
1207
  Within one `A2Client`, repeated `session(id)` calls return the same live
@@ -1065,96 +1213,6 @@ frontier. Idle handles are evicted after `gcTime`. This memory layer is
1065
1213
  separate from `experimental-a2/cache-indexeddb`: memory preserves identity
1066
1214
  across route transitions, while IndexedDB preserves the replica across reloads.
1067
1215
 
1068
- ## `experimental-a2/http`
1069
-
1070
- ### `handle(server, options?)`
1071
-
1072
- ```ts
1073
- handle(server: A2Server, options?: {
1074
- before?(args: { request: Request; intent: A2Intent }):
1075
- Response | undefined | void | Promise<Response | undefined | void>
1076
- after?(args: { request: Request; intent: A2Intent; outcome: A2Outcome; response: Response }):
1077
- Response | undefined | void | Promise<Response | undefined | void>
1078
- upgrade?: UpgradeFn // e.g. (attach) => experimental_upgradeWebSocket(attach)
1079
- presence?: boolean // interleave presence on every stream lane
1080
- deadline?: number // epoch ms: close sockets cleanly before it
1081
- }): { GET(req: Request): Promise<Response>; POST(req: Request): Promise<Response> }
1082
-
1083
- type A2Intent =
1084
- | { type: 'ws-upgrade' }
1085
- | { type: 'stream'; sessionId: string; startAfter: number; transport: 'sse' | 'ws' }
1086
- | { type: 'history'; sessionId: string; gte: number; lte: number }
1087
- | { type: 'push'; sessionId: string; events: PushedEvent[]; presence?: PushedPresence; transport: 'http' | 'ws' }
1088
-
1089
- type A2Outcome =
1090
- | { type: 'stream' }
1091
- | { type: 'history'; covered: boolean; events: Event[] }
1092
- | { type: 'push'; appended: Event[] }
1093
- ```
1094
-
1095
- The session route pair as one call: `export const { GET, POST } =
1096
- handle(server)` in a route module (any framework speaking
1097
- `(req: Request) => Promise<Response>`). A plain `GET` is the live SSE
1098
- stream, resumed after the `index` query parameter, with a `: connected`
1099
- prelude, a `: ping` heartbeat every 15s, and a clean close one second
1100
- before an ambient Vercel invocation deadline when available; presence
1101
- patches ride as named frames when `presence: true`. A `GET` with
1102
- `gte`/`lte` query parameters is a history slice: the closed log range
1103
- as JSON wire events, the read `loadHistory` rides. `POST` is the push
1104
- envelope `{ sessionId, events, presence? }`, answered with the appended
1105
- events. A `GET` carrying an upgrade header becomes the multiplexed
1106
- WebSocket when `options.upgrade` is present, and answers `426` when it
1107
- is not.
1108
-
1109
- Parsing is protocol, hooks are policy. A request that fails to parse
1110
- (missing `sessionId`, malformed bounds, a bad push envelope) answers
1111
- `INVALID_PAYLOAD` on the wire before any hook runs. `before` sees every
1112
- parsed intent, HTTP requests and socket frames alike; over the socket,
1113
- each subscribe arrives as a `stream` intent and each push as a `push`
1114
- intent, with `request` always the original upgrade Request. Returning a
1115
- Response short-circuits: over HTTP it is the response, verbatim; over
1116
- the socket it is translated into the wire's own vocabulary (a denied
1117
- subscribe answers `unsubscribed`, a denied push a non-retryable error
1118
- ack), because a Response cannot cross a socket.
1119
-
1120
- `after` runs only where the library produced an HTTP response: never
1121
- after a short-circuit, never for `ws-upgrade` or socket frames. It may
1122
- mutate `response.headers` in place or return a replacement Response.
1123
- `outcome.covered` on a history read means the closed range came back
1124
- fully covered (`events.length === lte - gte + 1`): an immutable slice
1125
- of the append-only log, safe to cache under whatever policy your
1126
- `after` applies. The history response carries no cache headers of its
1127
- own.
1128
-
1129
- The socket is one connection for all of a client's sessions:
1130
- `subscribe`/`unsubscribe` frames open and close per-session lanes at
1131
- their own resume frontiers, `sessionId` tags route pushes, presence,
1132
- and acks, and a lane ending or failing answers `unsubscribed` without
1133
- taking the socket down. See [Transports](/guides/transports).
1134
-
1135
- ### The rest of the entry
1136
-
1137
- | Helper | What it does |
1138
- | ----------------------- | ------------------------------------------------------------------------------ |
1139
- | `schedulerHandler(...servers)` | returns the delivery route after synchronously verifying one shared scheduler |
1140
- | `errorResponse(err)` | serializes an `A2Error` to `{ error: { code, message, details } }` + status; the natural return value of a refusing `before` hook |
1141
- | `deserializeError(body)` | rebuilds an `A2Error` from a wire body, or `null` if the body isn't one |
1142
-
1143
- `schedulerHandler(...servers)` is the application-facing scheduler route. It
1144
- requires at least one A2 server. Every server must have a scheduler, use the
1145
- exact same scheduler instance, and have a unique contract name. Non-A2 values
1146
- also fail. These checks throw before the request handler is returned, so bad
1147
- wiring fails when the route module loads. The helper then delegates delivery
1148
- to the shared adapter's `A2Scheduler.handler(...)` method.
1149
-
1150
- Different scheduler instances use different routes. Match each QStash route to
1151
- that instance's resolved `url`; additional QStash routes pass an explicit
1152
- `url`. Match each Vercel Queues route and trigger to that instance's `topic`.
1153
-
1154
- `errorResponse` and `deserializeError` are the `A2Error` wire format
1155
- that `push` and the push lane share. See
1156
- [Errors](/reference/errors#over-the-wire).
1157
-
1158
1216
  ## `experimental-a2/cache-indexeddb`
1159
1217
 
1160
1218
  ```ts
@@ -1232,112 +1290,6 @@ log.
1232
1290
  renames and additions are breaking for dashboards, and are called out in the
1233
1291
  package's `CHANGELOG.md`.
1234
1292
 
1235
- ## `experimental-a2/devtools`
1236
-
1237
- The shared, isomorphic protocol for browser Devtools, the CLI, and `.a2log`
1238
- captures.
1239
-
1240
- | Export | Contract |
1241
- | --- | --- |
1242
- | `DEVTOOLS_PROTOCOL_VERSION` | Current HTTP wire protocol version |
1243
- | `DEVTOOLS_CAPTURE_VERSION` | Current `.a2log` record format version |
1244
- | `DEVTOOLS_CAPTURE_MEDIA_TYPE` | `application/x-ndjson` |
1245
- | `DEVTOOLS_SESSION_PAGE_LIMIT` | Default event count requested per session page |
1246
- | `DEVTOOLS_SESSION_PAGE_MAX_LIMIT` | Maximum event count accepted per session page |
1247
- | `DEVTOOLS_CAPABILITIES` | Protocol resources and capture format advertised by the server |
1248
- | `devtoolsSessionRevision({ events, snapshots })` | Compute the lifecycle invalidation token after assembling pages |
1249
- | `encodeDevtoolsCapture(detail, { capturedAt? })` | Encode one session detail as exact NDJSON and add its SHA-256 footer |
1250
- | `parseDevtoolsCapture(value)` | Parse NDJSON text or bytes into a `DevtoolsCapture` while retaining unknown fields on known records |
1251
- | `verifyDevtoolsCapture(capture)` | Verify versions, record shapes, session identity, indexes, counts, dates, and the SHA-256 digest |
1252
-
1253
- The wire types are `DevtoolsWireSessionSummary`, `DevtoolsWireEvent`,
1254
- `DevtoolsWireSnapshot`, `DevtoolsContractsResponse`,
1255
- `DevtoolsSessionsResponse`, `DevtoolsSessionDetail`, `DevtoolsSessionPage`, and
1256
- `DevtoolsCapabilities`. Capture records use `DevtoolsCaptureManifest`,
1257
- `DevtoolsCaptureEvent`, `DevtoolsCaptureSnapshot`,
1258
- `DevtoolsCaptureFooter`, and `DevtoolsCaptureRecord`. A parsed
1259
- `DevtoolsCapture` exposes the same canonical sequence through `records`.
1260
-
1261
- ```ts test/verify-a2log.ts
1262
- import { readFile } from 'node:fs/promises'
1263
- import {
1264
- parseDevtoolsCapture,
1265
- verifyDevtoolsCapture,
1266
- } from 'experimental-a2/devtools'
1267
-
1268
- export async function verifyA2Log(path: string) {
1269
- const capture = parseDevtoolsCapture(await readFile(path))
1270
- await verifyDevtoolsCapture(capture)
1271
- return capture
1272
- }
1273
- ```
1274
-
1275
- The manifest records the contract, session, revision, capture time, protocol
1276
- version, and capture version. Event records contain the exact durable payload
1277
- and operational bookkeeping. Snapshot records contain metadata but never
1278
- cached reducer state. The final `end` record contains counts, the highest event
1279
- index, and a digest of every preceding encoded record. The session revision is
1280
- an invalidation token, not the integrity digest. Encoding rejects durable
1281
- values that canonical JSON would coerce or omit.
1282
-
1283
- ## `experimental-a2/devtools/server`
1284
-
1285
- ```ts
1286
- // anywhere on the server:
1287
- import type { DevtoolsServer } from 'experimental-a2/devtools/server'
1288
-
1289
- declare function createDevtools(options: {
1290
- servers: readonly DevtoolsServer[]
1291
- authorize?: (
1292
- request: Request,
1293
- ) => boolean | Response | Promise<boolean | Response>
1294
- }): {
1295
- handler(): (request: Request) => Promise<Response>
1296
- }
1297
- ```
1298
-
1299
- A read-only dashboard over the servers' durable logs. The handler serves the
1300
- complete browser application, its JSON endpoints, and live SSE invalidations.
1301
- It discovers sessions and reads stored causal, dispatch, completion, failure,
1302
- and snapshot metadata. The causal forest and lifecycle timeline come directly
1303
- from stored events. The initial page response includes the selected dashboard
1304
- data so the browser does not need a contracts, sessions, and detail request
1305
- waterfall. It never drains or heals a session.
1306
-
1307
- Every route is a `GET`. The versioned resources are `capabilities`,
1308
- `contracts`, `sessions`, `session`, `watch`, and `export` under the handler's
1309
- `_a2/` path. `export` returns an integrity-checkable `.a2log` with the media
1310
- type from `DEVTOOLS_CAPTURE_MEDIA_TYPE`.
1311
-
1312
- `session` without pagination parameters returns one complete detail. Add a
1313
- positive `limit` to receive a `DevtoolsSessionPage`. Its `throughIndex` is the
1314
- inclusive event frontier frozen by the first request. Pass its opaque `cursor`
1315
- back until the cursor is null. Snapshot metadata appears on the first page
1316
- only. The cursor is bound to its contract and session, and the server rejects
1317
- reuse against another log. The advertised maximum page size is clamped by the
1318
- server.
1319
-
1320
- The browser, CLI, watch loop, and export route walk these bounded pages for
1321
- you. They reject a gap or early end. An exact export includes every event
1322
- through one finite frontier, even when a backend provider limits one range
1323
- response. Events appended during the walk belong to a later read.
1324
-
1325
- Without `authorize`, the handler is available only when `NODE_ENV` is exactly
1326
- `development`. Any other value, including unset, returns 404. When `authorize`
1327
- is present, only a literal `true` grants access. Returning `false` also returns
1328
- 404. Returning a `Response` passes that response through, which supports
1329
- redirects and authentication challenges. The mounted handler is the
1330
- authorization boundary for the browser, CLI, SSE stream, and exact capture
1331
- download. A2 does not add a separate Devtools credential store.
1332
-
1333
- The built-in memory, SQLite, Postgres, and Redis stores support inspection.
1334
- Custom `A2Store` implementations can omit the optional `inspect` interface; the
1335
- dashboard returns 501 for those logs. A custom inspection implementation may
1336
- add `readEvents(sessionId, { afterIndex, throughIndex?, limit })` for efficient
1337
- bounded reads. It returns `{ events, throughIndex }`, where the first call
1338
- freezes the inclusive frontier and continuations preserve it. Without this
1339
- method, A2 reads the complete log for each page and slices it in memory.
1340
-
1341
1293
  ## `experimental-a2/scheduler-vercel`
1342
1294
 
1343
1295
  `vercelQueues(options?)` returns an `A2Scheduler` backed by Vercel Queues.
@@ -1449,10 +1401,9 @@ Mount the application handler once and pass every server that shares the
1449
1401
  scheduler:
1450
1402
 
1451
1403
  ```ts app/api/a2/scheduler/route.ts
1452
- import { schedulerHandler } from 'experimental-a2/http'
1453
- import { ordersServer, billingServer } from '@/server'
1404
+ import { billingServer, ordersServer, scheduler } from '@/server'
1454
1405
 
1455
- export const POST = schedulerHandler(ordersServer, billingServer)
1406
+ export const POST = scheduler.handler(ordersServer, billingServer)
1456
1407
  ```
1457
1408
 
1458
1409
  The route must be reachable by the selected QStash server after any platform
@@ -1533,46 +1484,6 @@ duplicate-message error is success. A2's durable AI tool execution uses this
1533
1484
  private classification. Direct `session.schedule()` calls still reject with
1534
1485
  the original error object.
1535
1486
 
1536
- ## `a2 devtools`
1537
-
1538
- The package installs an `a2` binary with a read-only `devtools` namespace.
1539
-
1540
- | Command | Purpose |
1541
- | --- | --- |
1542
- | `contracts` | List contracts mounted at the Devtools URL |
1543
- | `sessions --contract NAME` | List session summaries, with optional `--limit` and `--cursor` |
1544
- | `show --contract NAME --session ID` | Read one session's exact durable detail |
1545
- | `export --contract NAME --session ID` | Verify and save an `.a2log`; use `--output` and opt into replacement with `--force` |
1546
- | `check --contract NAME --session ID` | Verify a live capture and optionally require `--settled`, `--no-dead-letters`, `--no-caught-failures`, or `--max-redispatches N` |
1547
- | `verify FILE` | Verify a saved capture offline |
1548
-
1549
- Live commands read the mounted URL from `--url` or `A2_DEVTOOLS_URL`.
1550
- `A2_DEVTOOLS_TOKEN` supplies the default bearer token. `--bearer-env ENV` and
1551
- repeatable `--header-env HEADER=ENV` reference other environment variables.
1552
- Credential values are never accepted as CLI arguments or URL components.
1553
- Output uses `--format human`, `json`, or `ndjson`.
1554
-
1555
- ## `experimental-a2/testing`
1556
-
1557
- Node-only helpers for verified capture loading and pure reducer replay.
1558
-
1559
- | Export | Contract |
1560
- | --- | --- |
1561
- | `loadCapture(source, contract)` | Read a filesystem path or URL, verify its integrity, validate its contract and payloads, and return typed events |
1562
- | `prepareCapture(capture, contract)` | Apply the same verification and contract typing to an already parsed capture |
1563
- | `replayCapture(loaded, reducer, { throughIndex? })` | Fold captured events through the reducer, stopping at an optional inclusive index |
1564
-
1565
- `LoadedCapture` contains the verified `capture`, the `contract`, typed public
1566
- `events` with `createdAt` revived as `Date`, and unmodified operational
1567
- `rawEvents`.
1568
- `CaptureReplay` returns `{ state, index }`. A cutpoint of 0 returns the initial
1569
- state and index 0. Supporting types are `CaptureSource`, `LoadedCapture`,
1570
- `ReplayCaptureOptions`, and `CaptureReplay`.
1571
-
1572
- Replay does not use cached snapshot state, construct a server, dispatch a
1573
- handler, invoke an AI model or tool, or repeat external effects. It reproduces
1574
- only the current reducer's pure projection over already recorded events.
1575
-
1576
1487
  ## Entry points
1577
1488
 
1578
1489
  | Entry point | Ships | Peer dependency |
@@ -1583,7 +1494,6 @@ only the current reducer's pure projection over already recorded events.
1583
1494
  | `experimental-a2/react` | `createReact` | `react` |
1584
1495
  | `experimental-a2/ai` | agent contract, `handlerContext`, reducer, and append builders: isomorphic | `ai` |
1585
1496
  | `experimental-a2/ai/server` | AI SDK runner and built-in handlers | `ai` |
1586
- | `experimental-a2/http` | route-side transport and scheduler helpers | none |
1587
1497
  | `experimental-a2/store-postgres` | `postgres`: Postgres store backend | `pg` (or inject a client) |
1588
1498
  | `experimental-a2/store-redis` | `redis`: Redis Streams store backend, push-native streaming | `ioredis` (or inject a client) |
1589
1499
  | `experimental-a2/store-redis-http` | `redisHttp`: the same Redis store over provider REST APIs (Upstash) | none |
@@ -1593,9 +1503,6 @@ only the current reducer's pure projection over already recorded events.
1593
1503
  | `experimental-a2/scheduler-qstash` | `qstash`: signed recovery and timers | `@upstash/qstash` |
1594
1504
  | `experimental-a2/cache-indexeddb` | `indexedDb` browser cache | none |
1595
1505
  | `experimental-a2/otel` | `otel` telemetry adapter | `@opentelemetry/api` |
1596
- | `experimental-a2/devtools` | versioned wire protocol and exact capture codec | none |
1597
- | `experimental-a2/devtools/server` | durable read-only dashboard handler | none |
1598
- | `experimental-a2/testing` | Node-only capture loading and pure reducer replay | none |
1599
1506
 
1600
1507
  Core `experimental-a2` imports none of the backends, enforced by a browser-bundle
1601
1508
  test in CI, not just convention. Importing `experimental-a2/store-postgres` is what
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Errors
3
- description: One error class, five codes, and clear rules about what append will never throw for.
3
+ description: One error class, stable codes, and clear rules about what append will never throw for.
4
4
  ---
5
5
 
6
6
  ## `A2Error`
@@ -35,6 +35,7 @@ export async function POST(req: Request) {
35
35
  | Code | Thrown when | Retryable |
36
36
  | ------------------------- | ------------------------------------------------------------------ | --------- |
37
37
  | `INVALID_PAYLOAD` | a payload fails its schema; nothing was written, and `details` carries the issues ([Standard Schema](https://standardschema.dev) format) | no |
38
+ | `FORBIDDEN` | `server.fetch` authorization denies a parsed stream, history read, push, or presence update | no |
38
39
  | `UNKNOWN_EVENT_TYPE` | an event type isn't in the contract's `events` map | no |
39
40
  | `UNKNOWN_PRESENCE_FIELD` | a presence field isn't in the contract's `presence` map | no |
40
41
  | `PRESENCE_NOT_SUPPORTED` | the contract declares `presence` but the configured store has no presence capability (thrown at construction; for environment-resolved stores, deferred to the first store use) | no |
@@ -73,12 +74,9 @@ The push route serializes an `A2Error` as:
73
74
  ```
74
75
 
75
76
  with a mapped status: `INVALID_PAYLOAD`, `UNKNOWN_EVENT_TYPE`, and
76
- `PARTIAL_DUPLICATE_BATCH` are 400; `SUPERSEDED_ATTEMPT` is 409;
77
- `STORE_UNAVAILABLE` is 503.
77
+ `PARTIAL_DUPLICATE_BATCH` are 400; `FORBIDDEN` is 403;
78
+ `SUPERSEDED_ATTEMPT` is 409; `STORE_UNAVAILABLE` is 503.
78
79
 
79
80
  The client's `push` deserializes the body back into an `A2Error`, so client
80
81
  and server code branch on identical codes. `push` auto-retries only
81
- `STORE_UNAVAILABLE`. The serializer pair, `errorResponse` and
82
- `deserializeError`, ships in `experimental-a2/http` alongside `handle`;
83
- a `before` hook that wants the wire's own error shapes returns
84
- `errorResponse(new A2Error(...))`.
82
+ `STORE_UNAVAILABLE`. `server.fetch` owns the HTTP serialization.
package/package.json CHANGED
@@ -1,12 +1,9 @@
1
1
  {
2
2
  "name": "experimental-a2",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "description": "Durable sync and reactions for things with a lifecycle: one event log, derived state, and live client per session.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
- "bin": {
8
- "a2": "./dist/cli-bin.js"
9
- },
10
7
  "sideEffects": false,
11
8
  "engines": {
12
9
  "node": ">=22.13"
@@ -39,7 +36,6 @@
39
36
  "browser": "./dist/ai-server.browser.js",
40
37
  "default": "./dist/ai-server.js"
41
38
  },
42
- "./http": "./dist/http.js",
43
39
  "./store-memory": "./dist/store-memory.js",
44
40
  "./store-sqlite": "./dist/store-sqlite.js",
45
41
  "./store-postgres": "./dist/store-postgres.js",
@@ -49,15 +45,6 @@
49
45
  "./scheduler-vercel": "./dist/scheduler-vercel.js",
50
46
  "./cache-indexeddb": "./dist/cache-indexeddb.js",
51
47
  "./otel": "./dist/otel.js",
52
- "./devtools": "./dist/devtools.js",
53
- "./devtools/server": {
54
- "browser": "./dist/devtools-server.browser.js",
55
- "default": "./dist/devtools-server.js"
56
- },
57
- "./testing": {
58
- "browser": "./dist/testing.browser.js",
59
- "default": "./dist/testing.js"
60
- },
61
48
  "./package.json": "./package.json"
62
49
  },
63
50
  "peerDependencies": {