experimental-a2 0.4.0 → 0.5.1

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 (182) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/{ai-B4YhEnfw.d.ts → ai-D_PGS-JR.d.ts} +3 -2
  3. package/dist/ai-D_PGS-JR.d.ts.map +1 -0
  4. package/dist/ai-server.browser.js +2 -0
  5. package/dist/ai-server.browser.js.map +1 -0
  6. package/dist/ai-server.d.ts +4 -3
  7. package/dist/ai-server.d.ts.map +1 -0
  8. package/dist/ai-server.js +4 -2
  9. package/dist/ai-server.js.map +1 -0
  10. package/dist/ai.d.ts +1 -1
  11. package/dist/ai.js +3 -1
  12. package/dist/ai.js.map +1 -0
  13. package/dist/cli-B3VuxoDe.js +2 -0
  14. package/dist/cli-B3VuxoDe.js.map +1 -0
  15. package/dist/cli-bin.js +2 -0
  16. package/dist/cli-bin.js.map +1 -0
  17. package/dist/cli.d.ts +2 -1
  18. package/dist/cli.d.ts.map +1 -0
  19. package/dist/{client-Bt4tAKi9.js → client-BKlyLiOU.js} +295 -85
  20. package/dist/client-BKlyLiOU.js.map +1 -0
  21. package/dist/{client-BrfDXQ8A.d.ts → client-D7mvIXrF.d.ts} +40 -4
  22. package/dist/client-D7mvIXrF.d.ts.map +1 -0
  23. package/dist/client.d.ts +2 -2
  24. package/dist/client.js +1 -1
  25. package/dist/contract-48bUMgcL.js +2 -0
  26. package/dist/contract-48bUMgcL.js.map +1 -0
  27. package/dist/contract-jIfaR085.d.ts +2 -1
  28. package/dist/contract-jIfaR085.d.ts.map +1 -0
  29. package/dist/devtools-J_jZ2vQf.d.ts +2 -1
  30. package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
  31. package/dist/devtools-kJJaORn-.js +2 -0
  32. package/dist/devtools-kJJaORn-.js.map +1 -0
  33. package/dist/devtools-server.browser.js +2 -0
  34. package/dist/devtools-server.browser.js.map +1 -0
  35. package/dist/devtools-server.d.ts +2 -1
  36. package/dist/devtools-server.d.ts.map +1 -0
  37. package/dist/devtools-server.js +2 -0
  38. package/dist/devtools-server.js.map +1 -0
  39. package/dist/errors-BQuJpe82.js +2 -0
  40. package/dist/errors-BQuJpe82.js.map +1 -0
  41. package/dist/errors-W6nwJ-fm.d.ts +2 -1
  42. package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
  43. package/dist/http.d.ts +121 -72
  44. package/dist/http.d.ts.map +1 -0
  45. package/dist/http.js +503 -178
  46. package/dist/http.js.map +1 -0
  47. package/dist/idempotent-replay-DuqEkYA7.js +2 -0
  48. package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
  49. package/dist/index.d.ts +2 -2
  50. package/dist/inspection-DaxB5jM2.js +2 -0
  51. package/dist/inspection-DaxB5jM2.js.map +1 -0
  52. package/dist/{internal-aEotMzu_.js → internal-DstsI6Re.js} +3 -1
  53. package/dist/internal-DstsI6Re.js.map +1 -0
  54. package/dist/otel.d.ts +3 -2
  55. package/dist/otel.d.ts.map +1 -0
  56. package/dist/otel.js +2 -0
  57. package/dist/otel.js.map +1 -0
  58. package/dist/platform-B4TnJtWu.js +2 -0
  59. package/dist/platform-B4TnJtWu.js.map +1 -0
  60. package/dist/react.d.ts +12 -3
  61. package/dist/react.d.ts.map +1 -0
  62. package/dist/react.js +5 -1
  63. package/dist/react.js.map +1 -0
  64. package/dist/retryable-lazy-DZWmHpii.js +2 -0
  65. package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
  66. package/dist/scheduler-qstash.d.ts +4 -3
  67. package/dist/scheduler-qstash.d.ts.map +1 -0
  68. package/dist/scheduler-qstash.js +4 -2
  69. package/dist/scheduler-qstash.js.map +1 -0
  70. package/dist/scheduler-task-BpzhPnRS.js +2 -0
  71. package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
  72. package/dist/scheduler-vercel.d.ts +4 -3
  73. package/dist/scheduler-vercel.d.ts.map +1 -0
  74. package/dist/scheduler-vercel.js +4 -2
  75. package/dist/scheduler-vercel.js.map +1 -0
  76. package/dist/{server-YtPq7hjw.d.ts → server-DpvjhdoE.d.ts} +6 -5
  77. package/dist/server-DpvjhdoE.d.ts.map +1 -0
  78. package/dist/{server-CcNnFnoW.js → server-Duw6MVlB.js} +112 -54
  79. package/dist/server-Duw6MVlB.js.map +1 -0
  80. package/dist/server.browser.js +2 -0
  81. package/dist/server.browser.js.map +1 -0
  82. package/dist/server.d.ts +2 -2
  83. package/dist/server.js +1 -1
  84. package/dist/{store-C3sNAaBT.d.ts → store-DysUkTH3.d.ts} +10 -1
  85. package/dist/store-DysUkTH3.d.ts.map +1 -0
  86. package/dist/store-N8PXxDAS.js +2 -0
  87. package/dist/store-N8PXxDAS.js.map +1 -0
  88. package/dist/store-codec-DTG0Ftek.js +2 -0
  89. package/dist/store-codec-DTG0Ftek.js.map +1 -0
  90. package/dist/store-memory.d.ts +3 -2
  91. package/dist/store-memory.d.ts.map +1 -0
  92. package/dist/store-memory.js +19 -11
  93. package/dist/store-memory.js.map +1 -0
  94. package/dist/{store-polling-DgrrAE3d.js → store-polling-dSeLxzfb.js} +3 -1
  95. package/dist/store-polling-dSeLxzfb.js.map +1 -0
  96. package/dist/store-postgres.d.ts +3 -2
  97. package/dist/store-postgres.d.ts.map +1 -0
  98. package/dist/store-postgres.js +57 -1
  99. package/dist/store-postgres.js.map +1 -0
  100. package/dist/{store-redis-core-DWqx3F47.js → store-redis-core-BFLwz0Wj.js} +3 -1
  101. package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
  102. package/dist/store-redis-http.d.ts +3 -2
  103. package/dist/store-redis-http.d.ts.map +1 -0
  104. package/dist/store-redis-http.js +4 -2
  105. package/dist/store-redis-http.js.map +1 -0
  106. package/dist/store-redis.d.ts +3 -2
  107. package/dist/store-redis.d.ts.map +1 -0
  108. package/dist/store-redis.js +5 -3
  109. package/dist/store-redis.js.map +1 -0
  110. package/dist/store-sqlite.d.ts +3 -2
  111. package/dist/store-sqlite.d.ts.map +1 -0
  112. package/dist/store-sqlite.js +3 -1
  113. package/dist/store-sqlite.js.map +1 -0
  114. package/dist/{telemetry-BjYHTfh2.d.ts → telemetry-CpeclqB2.d.ts} +4 -3
  115. package/dist/telemetry-CpeclqB2.d.ts.map +1 -0
  116. package/dist/testing.browser.js +2 -0
  117. package/dist/testing.browser.js.map +1 -0
  118. package/dist/testing.d.ts +2 -1
  119. package/dist/testing.d.ts.map +1 -0
  120. package/dist/testing.js +2 -0
  121. package/dist/testing.js.map +1 -0
  122. package/dist/validate-XKT4FSNn.js +2 -0
  123. package/dist/validate-XKT4FSNn.js.map +1 -0
  124. package/dist/{wire-DCUZBUlT.js → wire-BFQmSJ-9.js} +77 -15
  125. package/dist/wire-BFQmSJ-9.js.map +1 -0
  126. package/docs/guides/03-react.mdx +59 -39
  127. package/docs/guides/06-ai-agents.mdx +5 -27
  128. package/docs/guides/09-presence.mdx +19 -40
  129. package/docs/guides/10-transports.mdx +49 -40
  130. package/docs/reference/01-api.mdx +107 -26
  131. package/docs/reference/02-errors.mdx +4 -2
  132. package/package.json +2 -1
  133. package/src/ai-coordinator.ts +358 -0
  134. package/src/ai-projector.ts +524 -0
  135. package/src/ai-sdk-step.ts +261 -0
  136. package/src/ai-server.browser.ts +5 -0
  137. package/src/ai-server.ts +1719 -0
  138. package/src/ai.ts +2155 -0
  139. package/src/cache-indexeddb.ts +10 -0
  140. package/src/cli-bin.ts +5 -0
  141. package/src/cli.ts +1046 -0
  142. package/src/client.ts +1826 -0
  143. package/src/contract.ts +206 -0
  144. package/src/deterministic-id.ts +72 -0
  145. package/src/devtools-app.ts +989 -0
  146. package/src/devtools-server.browser.ts +5 -0
  147. package/src/devtools-server.ts +604 -0
  148. package/src/devtools.ts +716 -0
  149. package/src/errors.ts +50 -0
  150. package/src/http.ts +394 -0
  151. package/src/idempotent-replay.ts +53 -0
  152. package/src/index.ts +37 -0
  153. package/src/inspection.ts +39 -0
  154. package/src/internal.ts +426 -0
  155. package/src/otel.ts +59 -0
  156. package/src/platform.ts +60 -0
  157. package/src/push-envelope.ts +137 -0
  158. package/src/react.ts +284 -0
  159. package/src/reducer.ts +108 -0
  160. package/src/retryable-lazy.ts +27 -0
  161. package/src/scheduler-qstash.ts +915 -0
  162. package/src/scheduler-task.ts +106 -0
  163. package/src/scheduler-vercel.ts +437 -0
  164. package/src/server.browser.ts +12 -0
  165. package/src/server.ts +2700 -0
  166. package/src/session-socket.ts +548 -0
  167. package/src/sse.ts +141 -0
  168. package/src/standard-schema.ts +77 -0
  169. package/src/store-codec.ts +10 -0
  170. package/src/store-memory.ts +788 -0
  171. package/src/store-polling.ts +102 -0
  172. package/src/store-postgres.ts +1212 -0
  173. package/src/store-redis-core.ts +1494 -0
  174. package/src/store-redis-http.ts +116 -0
  175. package/src/store-redis.ts +458 -0
  176. package/src/store-sqlite.ts +1108 -0
  177. package/src/store.ts +385 -0
  178. package/src/telemetry.ts +54 -0
  179. package/src/testing.browser.ts +5 -0
  180. package/src/testing.ts +185 -0
  181. package/src/validate.ts +39 -0
  182. package/src/wire.ts +454 -0
@@ -23,52 +23,57 @@ reducer, and the provider + hook it returns.
23
23
 
24
24
  ## The API route
25
25
 
26
- One file exposes a session over HTTP: `GET` streams events, `POST` appends.
27
- These are ordinary route handlers; put whatever checks you like in front.
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.
28
29
 
29
30
  ```ts app/api/order-events/route.ts
30
- import { A2Error } from 'experimental-a2'
31
+ import { handle } from 'experimental-a2/http'
31
32
  import { ordersServer } from '@/server/orders'
32
- import { errorResponse, parsePushBody, sseResponse } from 'experimental-a2/http'
33
33
 
34
- export async function GET(req: Request) {
35
- const { searchParams } = new URL(req.url)
36
- const sessionId = searchParams.get('sessionId')
37
- if (!sessionId) {
38
- return errorResponse(new A2Error('INVALID_PAYLOAD', 'missing sessionId'))
39
- }
40
- const startAfter = Number(searchParams.get('index')) || 0
41
-
42
- // here's where you'd do auth, or any other checks
43
-
44
- return sseResponse(ordersServer.session(sessionId).stream({ startAfter }))
45
- }
46
-
47
- export async function POST(req: Request) {
48
- try {
49
- const { sessionId, events } = await parsePushBody(req)
50
-
51
- // here's where you'd do auth, or any other checks
52
-
53
- const result = await ordersServer.session(sessionId).append(...events)
54
- return Response.json(result)
55
- } catch (err) {
56
- return errorResponse(err)
57
- }
58
- }
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
+ })
59
42
  ```
60
43
 
61
- `GET` is the read path. `stream({ startAfter })` is a live `AsyncIterable` of
62
- one session's events starting after a given position, and `sseResponse`
63
- pipes it into a server-sent events response. Clients pass `index` to resume
64
- exactly where they left off, after a first paint or a dropped
65
- connection.
44
+ `GET` is the read path. A plain `GET` is the live stream: a server-sent
45
+ events response of one session's events, resumed after the `index` query
46
+ parameter, so clients pick up exactly where they left off after a first
47
+ paint or a dropped connection. A `GET` carrying `gte`/`lte` query
48
+ parameters is a history slice instead: the bounded log range as JSON,
49
+ the cold read [`loadHistory`](#the-client-component) rides.
50
+
51
+ `POST` is the write path. The push envelope is validated (garbage
52
+ answers `INVALID_PAYLOAD` before your hooks run), then `append` does the
53
+ rest. The response is the appended events: an ack, not a stream. Thrown
54
+ [`A2Error`s](/reference/errors#over-the-wire) serialize onto the wire so
55
+ the client can branch on the same codes.
56
+
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.
63
+
64
+ ```ts
65
+ // app/api/order-events/route.ts, now with response decoration:
66
+ import { handle } from 'experimental-a2/http'
67
+ import { ordersServer } from '@/server/orders'
66
68
 
67
- `POST` is the write path. `parsePushBody` validates the envelope (and
68
- throws `INVALID_PAYLOAD` on garbage), then `append` does the rest. The
69
- response is the appended events: an ack, not a stream. `errorResponse`
70
- serializes any thrown [`A2Error`](/reference/errors#over-the-wire) so the
71
- client can branch on the same codes.
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
+ })
76
+ ```
72
77
 
73
78
  ## The session module
74
79
 
@@ -174,6 +179,21 @@ What the hook gives you:
174
179
  seeded with. With only the server snapshot, it begins after `initialIndex`.
175
180
  Use it for UI that wants the log itself: an activity feed, a debug panel.
176
181
  Earlier events are not needed to hydrate `state`.
182
+ - **`loadHistory`**: backscroll. `loadHistory({ before?, limit? })`
183
+ fetches a bounded slice of the log from below the frontier (the same
184
+ route, `gte`/`lte` query parameters) and merges it into `events`:
185
+ deduped, ordered, shared across every handle of the session. By
186
+ default each call walks backward 50 events at a time from the oldest
187
+ one loaded. After a hydrate jump (returning to a session whose
188
+ frontier advanced while away), default paging still continues from
189
+ the oldest loaded event; pass an explicit `before` to fill the gap
190
+ between the old feed and the new frontier. It never touches `state` or the optimistic overlay;
191
+ backscrolled events are display data. Calls serialize per session, so
192
+ a double-tap never fetches the same range twice. The `ws` api has no
193
+ history lane; `loadHistory` throws a `TypeError` there.
194
+ - **`history`**: backscroll progress, `{ loading, complete,
195
+ oldestLoaded }`. `complete` means the feed reaches index 1 (or the
196
+ log is empty): nothing older is left, hide the "load older" button.
177
197
  - **`index`**: the stream frontier, the last server-confirmed log
178
198
  position. This is the `lastSeenIndex` that makes
179
199
  [cancellation](/guides/cancellation) exact.
@@ -129,36 +129,14 @@ 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 { A2Error } from 'experimental-a2'
133
- import { errorResponse, parsePushBody, sseResponse } from 'experimental-a2/http'
132
+ import { handle } from 'experimental-a2/http'
134
133
  import { assistantServer } from '@/server/assistant'
135
134
 
136
- export async function GET(req: Request): Promise<Response> {
137
- const { searchParams } = new URL(req.url)
138
- const sessionId = searchParams.get('sessionId')
139
- const startAfter = Number(searchParams.get('index')) || 0
140
-
141
- if (!sessionId) {
142
- return errorResponse(new A2Error('INVALID_PAYLOAD', 'missing sessionId'))
143
- }
144
-
145
- // here's where you'd do auth, or any other checks
146
-
147
- return sseResponse(assistantServer.session(sessionId).stream({ startAfter }))
148
- }
149
-
150
- export async function POST(req: Request): Promise<Response> {
151
- try {
152
- const { sessionId, events } = await parsePushBody(req)
153
-
135
+ export const { GET, POST } = handle(assistantServer, {
136
+ before({ request, intent }) {
154
137
  // here's where you'd do auth, or any other checks
155
-
156
- const appended = await assistantServer.session(sessionId).append(...events)
157
- return Response.json(appended)
158
- } catch (error) {
159
- return errorResponse(error)
160
- }
161
- }
138
+ },
139
+ })
162
140
  ```
163
141
 
164
142
  The route never calls the model directly. The browser appends user facts such
@@ -63,55 +63,34 @@ explicit leave required).
63
63
 
64
64
  ## The route
65
65
 
66
- The same two handlers as [Live UI](/guides/react), with one option and
67
- one branch. `stream({ presence: true })` interleaves presence patches
68
- with events on the SSE response, starting with a snapshot of the current
69
- map. The push body grows an optional `presence` sibling to `events`.
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`.
70
71
 
71
72
  ```ts app/api/canvas-events/route.ts
72
- import { A2Error } from 'experimental-a2'
73
- import { errorResponse, parsePushBody, sseResponse } from 'experimental-a2/http'
73
+ import { handle } from 'experimental-a2/http'
74
74
  import { canvasServer } from '@/server/canvas'
75
75
 
76
- export async function GET(req: Request) {
77
- const { searchParams } = new URL(req.url)
78
- const sessionId = searchParams.get('sessionId')
79
- if (!sessionId) {
80
- return errorResponse(new A2Error('INVALID_PAYLOAD', 'missing sessionId'))
81
- }
82
- const startAfter = Number(searchParams.get('index')) || 0
83
-
84
- // here's where you'd do auth, or any other checks
85
-
86
- return sseResponse(
87
- canvasServer.session(sessionId).stream({ startAfter, presence: true }),
88
- )
89
- }
90
-
91
- export async function POST(req: Request) {
92
- try {
93
- const { sessionId, events, presence } = await parsePushBody(req)
94
- const session = canvasServer.session(sessionId)
95
-
96
- // here's where you'd do auth, or any other checks; the participant
97
- // id is caller-supplied, so authorize it like you authorize events.
98
- // createServer's validatePush is the same seam: on the presence
99
- // plane it receives { sessionId, events: [], presence }, the whole
100
- // patch, participant included
101
-
102
- if (presence) await session.setPresence(presence)
103
- if (events.length === 0) return Response.json([])
104
- return Response.json(await session.append(...events))
105
- } catch (err) {
106
- return errorResponse(err)
107
- }
108
- }
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
+ })
109
87
  ```
110
88
 
111
89
  `setPresence` validates each field against the contract, then
112
90
  broadcasts. No append transaction, no dispatch, no scheduler arm, no log
113
91
  row. A bad field throws `INVALID_PAYLOAD`; an unknown field throws
114
- `UNKNOWN_PRESENCE_FIELD`; nothing is broadcast on either.
92
+ `UNKNOWN_PRESENCE_FIELD`; nothing is broadcast on either. A
93
+ presence-only push acks `[]`.
115
94
 
116
95
  ## The browser
117
96
 
@@ -24,58 +24,67 @@ api: { type: 'ws', url: '/api/order-events' }
24
24
  platform duration limits differ per verb: the stream is a long-lived
25
25
  read that wants a high `maxDuration`, the push is a short write that
26
26
  doesn't.
27
- - **`ws`** rides everything over one WebSocket: the stream comes down
28
- it, pushes and presence go up it. Use it when latency or per-message
29
- cost matters; at a presence cadence of fifteen sends a second, each
30
- send is a socket frame instead of a route invocation.
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.
31
33
 
32
34
  A split socket is unrepresentable on purpose. The socket is one
33
35
  connection in both directions; there is nothing left to split.
34
36
 
37
+ One wire is missing from `ws` by design: the history lane.
38
+ [`loadHistory`](/guides/react#the-client-component) is a bounded cold
39
+ read and rides plain HTTP; on a `ws` api it throws a `TypeError`.
40
+
35
41
  ## The WebSocket route
36
42
 
37
- The same route can serve both transports by branching on the upgrade
38
- header. Auth runs before the upgrade, while the request is still a
39
- request.
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.
40
47
 
41
48
  ```ts app/api/order-events/route.ts
42
49
  import { experimental_upgradeWebSocket } from '@vercel/functions'
43
- import { A2Error } from 'experimental-a2'
44
- import { errorResponse, sessionSocket, sseResponse } from 'experimental-a2/http'
50
+ import { handle } from 'experimental-a2/http'
45
51
  import { ordersServer } from '@/server/orders'
46
52
 
47
- export async function GET(req: Request) {
48
- const { searchParams } = new URL(req.url)
49
- const sessionId = searchParams.get('sessionId')
50
- if (!sessionId) {
51
- return errorResponse(new A2Error('INVALID_PAYLOAD', 'missing sessionId'))
52
- }
53
- const startAfter = Number(searchParams.get('index')) || 0
54
-
55
- // here's where you'd do auth, or any other checks
56
-
57
- if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
58
- return experimental_upgradeWebSocket(
59
- (ws) => sessionSocket(ordersServer.session(sessionId), ws, { startAfter }),
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
60
  // ws defaults to 100 MiB per frame; POST bodies cap at about
61
61
  // 4.5 MB on the platform. Keep the two ingress paths at parity.
62
- { maxPayload: 4 * 1024 * 1024 },
63
- )
64
- }
65
- return sseResponse(ordersServer.session(sessionId).stream({ startAfter }))
66
- }
62
+ maxPayload: 4 * 1024 * 1024,
63
+ }),
64
+ })
67
65
  ```
68
66
 
69
- `sessionSocket` speaks the whole protocol against any `ws`-shaped
70
- socket: it pumps the session's stream down as JSON frames, accepts push
71
- and presence frames up, validates them exactly as `parsePushBody` does
72
- (same provenance brand, same `validatePush` calls, once per plane), and
73
- answers each push with its ack. POST keeps working unchanged next to
74
- it; a `ws` client never calls it, an `http` client never upgrades. The
75
- transports are additive.
76
-
77
- Contracts that declare presence pass `{ presence: true }` in the
78
- options, the same opt-in as `stream()`.
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.
79
88
 
80
89
  ## The client
81
90
 
@@ -105,9 +114,9 @@ are the same machinery above the wire seam.
105
114
  A socket closes when the platform ends the function invocation, or
106
115
  when the server closes it deliberately ahead of a known deadline. The
107
116
  client treats every close the same way it treats a dropped SSE stream:
108
- reconnect with backoff, resume from the current frontier, receive a
109
- fresh presence snapshot, re-send its own presence fields set since the
110
- disconnect. A push whose socket died before the ack rejects as
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
111
120
  retryable: nothing was acknowledged, and if the append had already
112
121
  committed, the client-generated event ids make the retry an idempotent
113
122
  replay (you get the original events back). Push retries wait for the
@@ -123,8 +123,8 @@ Server-only by construction: `experimental-a2/server` is the only entry point th
123
123
  can reach a store backend, and its exports map resolves to a loud error
124
124
  under the browser condition.
125
125
 
126
- `validatePush(context)` runs only for input that came from `parsePushBody()`,
127
- once per plane. The events plane invokes it with `{ sessionId, events }` before
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
128
  contract schema validation and before the store append, so throwing rejects the
129
129
  complete push without writing anything. The presence plane invokes it with
130
130
  `{ sessionId, events: [], presence }`, the whole pushed patch with its
@@ -132,7 +132,7 @@ participant, before field validation and the broadcast, so authorizing the
132
132
  participant id (and applying any size or cardinality policy) happens at the
133
133
  same seam. The patch arrives frozen: authorize, don't rewrite (a mutation
134
134
  attempt throws and fails the push). Direct trusted server appends, handler appends, and server-side
135
- `setPresence` bypass it. `parsePushBody()` creates
135
+ `setPresence` bypass it. The envelope parser inside `handle` creates
136
136
  the runtime provenance brand after reading the envelope; a caller-supplied
137
137
  field with the same name is ignored, and the brand is not stored in the log.
138
138
 
@@ -428,8 +428,8 @@ The only way to move a session forward. Payloads are validated against
428
428
  the contract's schemas before anything is written. A multi-event append
429
429
  is atomic: all-or-nothing, consecutive positions, one transaction. Pass
430
430
  `id` to make an append idempotent across retries; re-sending an
431
- identical batch returns the original rows. (Events parsed by
432
- `parsePushBody` are accepted directly, the push-route path.) See
431
+ identical batch returns the original rows. (Events that arrived through
432
+ `handle`'s push lane are accepted directly.) See
433
433
  [Durability](/concepts/durability).
434
434
 
435
435
  After the write, A2 dispatches only event types with registered handlers.
@@ -537,14 +537,14 @@ session.stream(options: {
537
537
 
538
538
  A live feed of the session's events. `startAfter` is a non-negative safe integer;
539
539
  `startAfter: 20` begins with event 21.
540
- Server-side only; expose it over SSE with `sseResponse`. Subscribing never
541
- dispatches handlers.
540
+ Server-side only; `handle` exposes it over SSE, and over the multiplexed
541
+ socket when `upgrade` is set. Subscribing never dispatches handlers.
542
542
 
543
543
  With `presence: true`, the feed yields one `PresenceSnapshot` first:
544
544
  `{ snapshot }`, the current pruned map with each field's own `value`,
545
545
  `seen`, and `at` stamp. Live presence patches then interleave with
546
- events. `sseResponse` sends both as named SSE frames, so clients that
547
- don't know them skip them. The return type widens only under the
546
+ events. The stream response sends both as named SSE frames, so clients
547
+ that don't know them skip them. The return type widens only under the
548
548
  literal `presence: true`; without it, existing consumers keep
549
549
  `AsyncIterable<Event>`. The option itself exists only on sessions of
550
550
  contracts that declare `presence`; elsewhere it is a type error, not a
@@ -580,9 +580,9 @@ by backend TTL when a participant goes silent (default 60 seconds; set
580
580
  the storage clock, not the sender stamp, so a hostile stamp can only
581
581
  vandalize its own field and still expires on schedule.
582
582
 
583
- The patch is structurally the `presence` object `parsePushBody` returns,
584
- so a push route forwards it whole: `session.setPresence(presence)`. The
585
- API mirrors the wire.
583
+ The patch is structurally the `presence` sibling of the push envelope;
584
+ `handle`'s push lane forwards it whole to `session.setPresence(presence)`.
585
+ The API mirrors the wire.
586
586
 
587
587
  The handler-scoped form `ctx.session.setPresence(...)` is the same
588
588
  operation; `seen` defaults to the triggering event's `index`.
@@ -666,16 +666,32 @@ not affect state hydration or stream resumption.
666
666
  ### `useSession()`
667
667
 
668
668
  ```ts
669
- const { state, push, events, index, connection, presence, setPresence } =
670
- useSession()
669
+ const { state, push, events, index, loadHistory, history, connection,
670
+ presence, setPresence } = useSession()
671
671
  ```
672
672
 
673
673
  `state` starts from the initial snapshot and folds live events through the
674
- shared reducer. `events` is the raw observed feed. Unless `initialEvents`
675
- explicitly seeds earlier entries, it begins after `initialIndex`. `index` is
674
+ shared reducer. `events` is the raw event feed: observed, explicitly
675
+ seeded, or backscrolled. Unless `initialEvents` seeds earlier entries or
676
+ `loadHistory` fetches them, it begins after `initialIndex`. `index` is
676
677
  the stream frontier, the `lastSeenIndex` for
677
678
  [cancellation](/guides/cancellation).
678
679
 
680
+ `loadHistory({ before?, limit? })` backscrolls: it fetches a bounded
681
+ slice of the log from below the frontier (the same route, `gte`/`lte`
682
+ query parameters) and merges it into `events`, deduped, ordered, and
683
+ shared across every handle of the session. It resolves with the events
684
+ in the requested range. Defaults walk backward 50 at a time from the
685
+ oldest loaded event; `before` is an exclusive upper bound. After a
686
+ hydrate jump (returning to a session whose frontier advanced while
687
+ away), default paging still continues from the oldest loaded event;
688
+ pass an explicit `before` to fill the gap between the old feed and the
689
+ new frontier. It never touches `state` or the optimistic overlay. Calls
690
+ serialize per session and already-loaded ranges are not refetched.
691
+ `history` is the progress: `{ loading, complete, oldestLoaded }`, where
692
+ `complete` means the feed reaches index 1 (or the log is empty). The `ws` api has no history lane; there `loadHistory`
693
+ throws a `TypeError`.
694
+
679
695
  `presence` is the replicated ephemeral map,
680
696
  `Record<participantId, { [field]: { value, seen, at } }>`, including
681
697
  this client. `setPresence(values)` is fire-and-forget: validated
@@ -1032,10 +1048,10 @@ subscription with frontier resume and reconnection, the optimistic push
1032
1048
  queue with ack/rollback, and the local fold. `client.session(id, {
1033
1049
  initialState?, initialIndex?, initialEvents?, participant? })` returns a
1034
1050
  handle with `getSnapshot()`/`subscribe()` (the `useSyncExternalStore`
1035
- contract), `push()`, `connect()`, and `close()`. Snapshots carry
1036
- `state`, `events`, `index`, and `connection` (the same fields
1037
- `useSession` exposes), and `push` returns the same ack-then-`confirmed`
1038
- result. On contracts that declare `presence` the handle also carries
1051
+ contract), `push()`, `loadHistory()`, `connect()`, and `close()`.
1052
+ Snapshots carry `state`, `events`, `index`, `history`, and `connection`
1053
+ (the same fields `useSession` exposes), and `push` returns the same
1054
+ ack-then-`confirmed` result. On contracts that declare `presence` the handle also carries
1039
1055
  `setPresence()` and snapshots carry the `presence` map, exactly like
1040
1056
  the hook; `participant` is the identity `setPresence` sends under. Use
1041
1057
  it directly from any other framework, or none.
@@ -1051,13 +1067,77 @@ across route transitions, while IndexedDB preserves the replica across reloads.
1051
1067
 
1052
1068
  ## `experimental-a2/http`
1053
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
+
1054
1137
  | Helper | What it does |
1055
1138
  | ----------------------- | ------------------------------------------------------------------------------ |
1056
1139
  | `schedulerHandler(...servers)` | returns the delivery route after synchronously verifying one shared scheduler |
1057
- | `parsePushBody(req)` | validates the push envelope `{ sessionId, events, presence? }`, throws `INVALID_PAYLOAD` |
1058
- | `sseResponse(iterable)` | pipes a `session.stream()` iterable into an SSE `Response`, with a `: connected` prelude, a `: ping` heartbeat every 15s, and a clean close one second before an ambient Vercel invocation deadline when available; presence patches ride as named `presence` frames |
1059
- | `sessionSocket(session, socket, options?)` | speaks the A2 wire over any `ws`-shaped socket: the stream pumps down as JSON frames, pushes and presence come up with the same validation and `validatePush` seam as the POST route; `options` carries `startAfter`, `presence`, and an optional `deadline` for clean pre-deadline closes. See [Transports](/guides/transports) |
1060
- | `errorResponse(err)` | serializes an `A2Error` to `{ error: { code, message, details } }` + status |
1140
+ | `errorResponse(err)` | serializes an `A2Error` to `{ error: { code, message, details } }` + status; the natural return value of a refusing `before` hook |
1061
1141
  | `deserializeError(body)` | rebuilds an `A2Error` from a wire body, or `null` if the body isn't one |
1062
1142
 
1063
1143
  `schedulerHandler(...servers)` is the application-facing scheduler route. It
@@ -1071,8 +1151,9 @@ Different scheduler instances use different routes. Match each QStash route to
1071
1151
  that instance's resolved `url`; additional QStash routes pass an explicit
1072
1152
  `url`. Match each Vercel Queues route and trigger to that instance's `topic`.
1073
1153
 
1074
- Together the last two are the `A2Error` wire format that `push` and the
1075
- push route share. See [Errors](/reference/errors#over-the-wire).
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).
1076
1157
 
1077
1158
  ## `experimental-a2/cache-indexeddb`
1078
1159
 
@@ -78,5 +78,7 @@ with a mapped status: `INVALID_PAYLOAD`, `UNKNOWN_EVENT_TYPE`, and
78
78
 
79
79
  The client's `push` deserializes the body back into an `A2Error`, so client
80
80
  and server code branch on identical codes. `push` auto-retries only
81
- `STORE_UNAVAILABLE`. The serializer pair ships in `experimental-a2/http`, alongside
82
- `parsePushBody` and `sseResponse`.
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(...))`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "experimental-a2",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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",
@@ -23,6 +23,7 @@
23
23
  "files": [
24
24
  "dist",
25
25
  "docs",
26
+ "src",
26
27
  "CHANGELOG.md"
27
28
  ],
28
29
  "exports": {