experimental-a2 0.3.0 → 0.5.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 (205) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/dist/ai-CFNeCrRl.d.ts +334 -0
  3. package/dist/ai-CFNeCrRl.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 +49 -9
  7. package/dist/ai-server.d.ts.map +1 -0
  8. package/dist/ai-server.js +123 -49
  9. package/dist/ai-server.js.map +1 -0
  10. package/dist/ai.d.ts +2 -303
  11. package/dist/ai.js +233 -86
  12. package/dist/ai.js.map +1 -0
  13. package/dist/cli-B3VuxoDe.js +599 -0
  14. package/dist/cli-B3VuxoDe.js.map +1 -0
  15. package/dist/cli-bin.d.ts +1 -0
  16. package/dist/cli-bin.js +7 -0
  17. package/dist/cli-bin.js.map +1 -0
  18. package/dist/cli.d.ts +20 -0
  19. package/dist/cli.d.ts.map +1 -0
  20. package/dist/cli.js +2 -0
  21. package/dist/client-BKlyLiOU.js +1008 -0
  22. package/dist/client-BKlyLiOU.js.map +1 -0
  23. package/dist/client-D7mvIXrF.d.ts +191 -0
  24. package/dist/client-D7mvIXrF.d.ts.map +1 -0
  25. package/dist/client.d.ts +2 -90
  26. package/dist/client.js +1 -409
  27. package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +12 -2
  28. package/dist/contract-48bUMgcL.js.map +1 -0
  29. package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +63 -8
  30. package/dist/contract-jIfaR085.d.ts.map +1 -0
  31. package/dist/devtools-J_jZ2vQf.d.ts +152 -0
  32. package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
  33. package/dist/devtools-kJJaORn-.js +340 -0
  34. package/dist/devtools-kJJaORn-.js.map +1 -0
  35. package/dist/devtools-server.browser.js +3 -1
  36. package/dist/devtools-server.browser.js.map +1 -0
  37. package/dist/devtools-server.d.ts +4 -3
  38. package/dist/devtools-server.d.ts.map +1 -0
  39. package/dist/devtools-server.js +226 -43
  40. package/dist/devtools-server.js.map +1 -0
  41. package/dist/devtools.d.ts +2 -0
  42. package/dist/devtools.js +2 -0
  43. package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +6 -4
  44. package/dist/errors-BQuJpe82.js.map +1 -0
  45. package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +3 -2
  46. package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
  47. package/dist/http.d.ts +135 -28
  48. package/dist/http.d.ts.map +1 -0
  49. package/dist/http.js +647 -61
  50. package/dist/http.js.map +1 -0
  51. package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +4 -2
  52. package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
  53. package/dist/index.d.ts +5 -5
  54. package/dist/index.js +2 -2
  55. package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +4 -1
  56. package/dist/inspection-DaxB5jM2.js.map +1 -0
  57. package/dist/internal-DstsI6Re.js +211 -0
  58. package/dist/internal-DstsI6Re.js.map +1 -0
  59. package/dist/otel.d.ts +3 -2
  60. package/dist/otel.d.ts.map +1 -0
  61. package/dist/otel.js +2 -0
  62. package/dist/otel.js.map +1 -0
  63. package/dist/platform-B4TnJtWu.js +34 -0
  64. package/dist/platform-B4TnJtWu.js.map +1 -0
  65. package/dist/react.d.ts +47 -15
  66. package/dist/react.d.ts.map +1 -0
  67. package/dist/react.js +30 -15
  68. package/dist/react.js.map +1 -0
  69. package/dist/retryable-lazy-DZWmHpii.js +2 -0
  70. package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
  71. package/dist/scheduler-qstash.d.ts +79 -0
  72. package/dist/scheduler-qstash.d.ts.map +1 -0
  73. package/dist/scheduler-qstash.js +501 -0
  74. package/dist/scheduler-qstash.js.map +1 -0
  75. package/dist/scheduler-task-BpzhPnRS.js +56 -0
  76. package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
  77. package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +18 -24
  78. package/dist/scheduler-vercel.d.ts.map +1 -0
  79. package/dist/scheduler-vercel.js +228 -0
  80. package/dist/scheduler-vercel.js.map +1 -0
  81. package/dist/server-C72KOw51.js +1457 -0
  82. package/dist/server-C72KOw51.js.map +1 -0
  83. package/dist/server-DUF9pjsx.d.ts +261 -0
  84. package/dist/server-DUF9pjsx.d.ts.map +1 -0
  85. package/dist/server.browser.js +2 -0
  86. package/dist/server.browser.js.map +1 -0
  87. package/dist/server.d.ts +4 -155
  88. package/dist/server.js +2 -2
  89. package/dist/{log-ldf5g8Cx.d.ts → store-DysUkTH3.d.ts} +120 -35
  90. package/dist/store-DysUkTH3.d.ts.map +1 -0
  91. package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +3 -1
  92. package/dist/store-N8PXxDAS.js.map +1 -0
  93. package/dist/store-codec-DTG0Ftek.js +10 -0
  94. package/dist/store-codec-DTG0Ftek.js.map +1 -0
  95. package/dist/store-memory.d.ts +12 -0
  96. package/dist/store-memory.d.ts.map +1 -0
  97. package/dist/{log-memory.js → store-memory.js} +145 -34
  98. package/dist/store-memory.js.map +1 -0
  99. package/dist/{log-polling-DZ1MiKLg.js → store-polling-dSeLxzfb.js} +7 -5
  100. package/dist/store-polling-dSeLxzfb.js.map +1 -0
  101. package/dist/{log-postgres.d.ts → store-postgres.d.ts} +7 -6
  102. package/dist/store-postgres.d.ts.map +1 -0
  103. package/dist/{log-postgres.js → store-postgres.js} +214 -24
  104. package/dist/store-postgres.js.map +1 -0
  105. package/dist/{log-redis-core-CyJ5L8yR.js → store-redis-core-BFLwz0Wj.js} +271 -66
  106. package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
  107. package/dist/{log-redis-http.d.ts → store-redis-http.d.ts} +8 -7
  108. package/dist/store-redis-http.d.ts.map +1 -0
  109. package/dist/{log-redis-http.js → store-redis-http.js} +22 -12
  110. package/dist/store-redis-http.js.map +1 -0
  111. package/dist/{log-redis.d.ts → store-redis.d.ts} +7 -6
  112. package/dist/store-redis.d.ts.map +1 -0
  113. package/dist/{log-redis.js → store-redis.js} +101 -39
  114. package/dist/store-redis.js.map +1 -0
  115. package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +7 -6
  116. package/dist/store-sqlite.d.ts.map +1 -0
  117. package/dist/{log-sqlite.js → store-sqlite.js} +118 -22
  118. package/dist/store-sqlite.js.map +1 -0
  119. package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +3 -2
  120. package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
  121. package/dist/testing.browser.d.ts +1 -0
  122. package/dist/testing.browser.js +6 -0
  123. package/dist/testing.browser.js.map +1 -0
  124. package/dist/testing.d.ts +32 -0
  125. package/dist/testing.d.ts.map +1 -0
  126. package/dist/testing.js +103 -0
  127. package/dist/testing.js.map +1 -0
  128. package/dist/validate-XKT4FSNn.js +2 -0
  129. package/dist/validate-XKT4FSNn.js.map +1 -0
  130. package/dist/wire-BFQmSJ-9.js +284 -0
  131. package/dist/wire-BFQmSJ-9.js.map +1 -0
  132. package/docs/01-quickstart.mdx +3 -3
  133. package/docs/concepts/01-contracts.mdx +18 -13
  134. package/docs/concepts/02-handlers.mdx +7 -7
  135. package/docs/concepts/03-durability.mdx +21 -21
  136. package/docs/concepts/04-state.mdx +18 -21
  137. package/docs/guides/01-timers.mdx +154 -49
  138. package/docs/guides/02-cancellation.mdx +30 -4
  139. package/docs/guides/03-react.mdx +76 -57
  140. package/docs/guides/04-local-first.mdx +1 -1
  141. package/docs/guides/05-production.mdx +310 -61
  142. package/docs/guides/06-ai-agents.mdx +252 -74
  143. package/docs/guides/07-devtools.mdx +137 -12
  144. package/docs/guides/08-application-data.mdx +1 -1
  145. package/docs/guides/09-presence.mdx +263 -0
  146. package/docs/guides/10-transports.mdx +140 -0
  147. package/docs/index.mdx +18 -14
  148. package/docs/reference/01-api.mdx +839 -110
  149. package/docs/reference/02-errors.mdx +13 -6
  150. package/package.json +24 -7
  151. package/src/ai-coordinator.ts +358 -0
  152. package/src/ai-projector.ts +524 -0
  153. package/src/ai-sdk-step.ts +261 -0
  154. package/src/ai-server.browser.ts +5 -0
  155. package/src/ai-server.ts +1719 -0
  156. package/src/ai.ts +2155 -0
  157. package/src/cache-indexeddb.ts +10 -0
  158. package/src/cli-bin.ts +5 -0
  159. package/src/cli.ts +1046 -0
  160. package/src/client.ts +1826 -0
  161. package/src/contract.ts +206 -0
  162. package/src/deterministic-id.ts +72 -0
  163. package/src/devtools-app.ts +989 -0
  164. package/src/devtools-server.browser.ts +5 -0
  165. package/src/devtools-server.ts +604 -0
  166. package/src/devtools.ts +716 -0
  167. package/src/errors.ts +50 -0
  168. package/src/http.ts +394 -0
  169. package/src/idempotent-replay.ts +53 -0
  170. package/src/index.ts +37 -0
  171. package/src/inspection.ts +39 -0
  172. package/src/internal.ts +426 -0
  173. package/src/otel.ts +59 -0
  174. package/src/platform.ts +60 -0
  175. package/src/push-envelope.ts +137 -0
  176. package/src/react.ts +284 -0
  177. package/src/reducer.ts +108 -0
  178. package/src/retryable-lazy.ts +27 -0
  179. package/src/scheduler-qstash.ts +915 -0
  180. package/src/scheduler-task.ts +106 -0
  181. package/src/scheduler-vercel.ts +437 -0
  182. package/src/server.browser.ts +12 -0
  183. package/src/server.ts +2700 -0
  184. package/src/session-socket.ts +548 -0
  185. package/src/sse.ts +141 -0
  186. package/src/standard-schema.ts +77 -0
  187. package/src/store-codec.ts +10 -0
  188. package/src/store-memory.ts +788 -0
  189. package/src/store-polling.ts +102 -0
  190. package/src/store-postgres.ts +1212 -0
  191. package/src/store-redis-core.ts +1494 -0
  192. package/src/store-redis-http.ts +116 -0
  193. package/src/store-redis.ts +458 -0
  194. package/src/store-sqlite.ts +1108 -0
  195. package/src/store.ts +385 -0
  196. package/src/telemetry.ts +47 -0
  197. package/src/testing.browser.ts +5 -0
  198. package/src/testing.ts +185 -0
  199. package/src/validate.ts +39 -0
  200. package/src/wire.ts +454 -0
  201. package/dist/internal-gCd5qMry.js +0 -44
  202. package/dist/log-memory.d.ts +0 -11
  203. package/dist/recovery-vercel.js +0 -119
  204. package/dist/server-BcLa4RFL.js +0 -877
  205. package/dist/wire-BVsgR8o9.js +0 -62
@@ -0,0 +1,263 @@
1
+ ---
2
+ title: Presence
3
+ description: "Ephemeral participant state on a durable session: cursors, typing, live progress. Presence repaints; the log replays."
4
+ ---
5
+
6
+ ## Two planes, one session
7
+
8
+ Everything in the log is a fact: stored forever, folded by reducers,
9
+ replayed on demand. Presence is not a fact. "Her cursor is at (312, 88)"
10
+ has a useful lifetime of about two seconds, no replay value, and no
11
+ durability requirement; a lost update is repainted by the next one.
12
+
13
+ The test that separates the planes:
14
+
15
+ > Will anyone, human or reducer, care about this datum after the moment
16
+ > passes? Yes: event, in the log. No: presence.
17
+
18
+ Messages, document updates, cancellations: events. Cursors, selections,
19
+ typing, "who's online", a handler's live progress: presence.
20
+
21
+ A2 gives presence its own plane on the session you already have. Same
22
+ contract, same route, same auth, same stream. Each participant owns a
23
+ small ephemeral state; A2 replicates the latest value of each field to
24
+ everyone subscribed and stores none of it in the log. Reducers never see
25
+ it, `history()` never returns it, and the browser cache never persists
26
+ it. One invariant covers the whole design:
27
+
28
+ > **Presence repaints; the log replays.** Anything that decides (a fold,
29
+ > a retry, an abort match) reads the log. Presence only hurries pixels.
30
+
31
+ ## Declare it in the contract
32
+
33
+ `presence` is a second vocabulary next to `events`: each key is a field
34
+ of one participant's ephemeral state, each value a schema for it.
35
+
36
+ ```ts contracts.ts
37
+ import { z } from 'zod'
38
+ import * as a2 from 'experimental-a2'
39
+
40
+ export const canvas = a2.contract({
41
+ name: 'canvas',
42
+ events: {
43
+ 'shape.created': z.object({
44
+ id: z.string(),
45
+ kind: z.enum(['rect', 'ellipse']),
46
+ x: z.number(),
47
+ y: z.number(),
48
+ }),
49
+ 'shape.moved': z.object({ id: z.string(), x: z.number(), y: z.number() }),
50
+ },
51
+ presence: {
52
+ cursor: z.object({ x: z.number(), y: z.number() }),
53
+ dragging: z.object({ shapeId: z.string(), x: z.number(), y: z.number() }),
54
+ },
55
+ })
56
+ ```
57
+
58
+ The field is the unit of replication: last writer wins per participant
59
+ per field. Setting `cursor` does not touch `dragging`. Fields are absent
60
+ until a participant sets them, and clear when set to `null` or when
61
+ their backend TTL lapses (a closed tab disappears by silence, no
62
+ explicit leave required).
63
+
64
+ ## The route
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`.
71
+
72
+ ```ts app/api/canvas-events/route.ts
73
+ import { handle } from 'experimental-a2/http'
74
+ import { canvasServer } from '@/server/canvas'
75
+
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
+ })
87
+ ```
88
+
89
+ `setPresence` validates each field against the contract, then
90
+ broadcasts. No append transaction, no dispatch, no scheduler arm, no log
91
+ row. A bad field throws `INVALID_PAYLOAD`; an unknown field throws
92
+ `UNKNOWN_PRESENCE_FIELD`; nothing is broadcast on either. A
93
+ presence-only push acks `[]`.
94
+
95
+ ## The browser
96
+
97
+ The provider takes a `participant` id (yours to mint: a user id, a tab
98
+ nonce, the playground's guest name). The hook grows two members. The
99
+ session module is the usual pair from
100
+ [Live UI](/guides/react#the-session-module), bound to a reducer that
101
+ carries the canvas contract:
102
+
103
+ ```ts app/canvas/[canvasId]/session.ts
104
+ 'use client'
105
+ import { createClient } from 'experimental-a2/client'
106
+ import { createReact } from 'experimental-a2/react'
107
+ import { canvas } from '@/contracts'
108
+
109
+ // a minimal scene fold; the real one lives with your reducers
110
+ const sceneReducer = canvas
111
+ .reducer({ name: 'scene', initialState: {} })
112
+ .fold((state) => state)
113
+
114
+ export const canvasClient = createClient({
115
+ reducer: sceneReducer,
116
+ api: '/api/canvas-events',
117
+ })
118
+ export const { SessionProvider, useSession } = createReact({
119
+ client: canvasClient,
120
+ })
121
+ ```
122
+
123
+ ```tsx app/canvas/[canvasId]/canvas-client.tsx
124
+ 'use client'
125
+ import { useSession } from './session'
126
+
127
+ export function CanvasClient() {
128
+ const { presence, setPresence } = useSession()
129
+
130
+ return (
131
+ <div
132
+ onPointerMove={(e) => setPresence({ cursor: { x: e.clientX, y: e.clientY } })}
133
+ >
134
+ {Object.entries(presence).map(([participant, fields]) =>
135
+ // render-time expiry: a cursor silent for a few seconds is gone
136
+ fields.cursor && Date.now() - fields.cursor.at.getTime() < 5_000 ? (
137
+ <Cursor key={participant} {...fields.cursor.value} />
138
+ ) : null,
139
+ )}
140
+ </div>
141
+ )
142
+ }
143
+
144
+ // your cursor sprite:
145
+ // function Cursor({ x, y }: { x: number; y: number }) { ... }
146
+ const Cursor = (props: { x: number; y: number }) => null
147
+ ```
148
+
149
+ - **`setPresence(values)`** is fire-and-forget: validated locally,
150
+ merged into the local map immediately, coalesced on the wire to a
151
+ fixed cadence. The first set sends immediately; within the interval,
152
+ later values merge field by field (latest wins, `null` included) and
153
+ one trailing send carries the merged batch, so a 60Hz pointer becomes
154
+ about fifteen sends per second and losing one is repainted by the
155
+ next. Sends may overlap in flight; that is safe because merges are
156
+ last-writer-wins by `at` per field. No ack, no `confirmed`, no retry,
157
+ no optimistic rollback: there is nothing to roll back, because
158
+ nothing was ever true.
159
+ - **`presence`** is the replicated map,
160
+ `Record<participantId, { [field]: { value, seen, at } }>`, including
161
+ your own participant. `at` is the sender's stamp for when the value
162
+ was set, and the last-writer-wins comparator; the client stamps its
163
+ sends monotonically, so overlapping sends can never invert. How long
164
+ a cursor stays painted is view logic, decided at render time. Wall
165
+ clocks never enter folds, and presence is not folded state.
166
+
167
+ Set a field to `null` to clear it (pointer leaves the canvas, blur,
168
+ unmount). Everything else expires by TTL when a tab goes silent: 60
169
+ seconds by default, set `createServer({ presence: { ttlMs } })` to
170
+ change it. Expiry counts on the backend's clock, not the sender's
171
+ stamp.
172
+
173
+ Both members are typed from the contract's `presence` map, through the
174
+ reducer, with no type arguments: field names, payloads, and `null`
175
+ clears all autocomplete. On a contract without `presence` the members
176
+ do not exist, so reaching for them is a compile error, not a silent
177
+ no-op. One symptom worth knowing: if the map holds only your own
178
+ echo while others are active, the GET route forgot `presence: true`.
179
+
180
+ ## Reconciling the planes: `seen`
181
+
182
+ Every presence value carries `seen`, the sender's stream frontier at the
183
+ moment it was set. The client stamps it automatically. This is what lets
184
+ the two planes agree on an order without wall clocks.
185
+
186
+ The classic race: a participant drags a shape (presence), drops it
187
+ (event), and a stale `dragging` patch arrives after the durable
188
+ `shape.moved`. Without a shared coordinate system the shape flickers
189
+ backwards. With `seen`, superseding is one render-time rule:
190
+
191
+ ```ts
192
+ // in render code: a ghost is dead once the log has moved past it.
193
+ // show fields.dragging only if
194
+ // fields.dragging.seen >= state.shapes[fields.dragging.value.shapeId].movedAt
195
+ // where movedAt is the index your reducer recorded for the last shape.moved
196
+ ```
197
+
198
+ The durable event at index 214 beats any presence stamped 213 or lower,
199
+ deterministically. A2 carries the stamp and exposes the frontiers; the
200
+ comparison is yours, in view code, next to the pixels it protects.
201
+
202
+ ## Handlers broadcast too
203
+
204
+ `ctx.session.setPresence` is the same session operation. A handler
205
+ grinding through work can publish live progress without writing
206
+ non-facts to the log:
207
+
208
+ ```ts server/canvas.ts
209
+ import { createServer } from 'experimental-a2/server'
210
+ import { canvas } from '@/contracts'
211
+
212
+ export const canvasServer = createServer({
213
+ contract: canvas,
214
+ handlers: {
215
+ 'shape.created': async (ctx) => {
216
+ // long work; broadcast progress as it goes:
217
+ // await ctx.session.setPresence({
218
+ // participant: 'system:thumbnails',
219
+ // values: { progress: { step: 2, of: 5 } },
220
+ // })
221
+ },
222
+ },
223
+ })
224
+ ```
225
+
226
+ The participant id is the handler's choice; a `system:` prefix keeps
227
+ robots and humans apart in the map. Append the durable outcome as an
228
+ event when the work completes; the progress was never a fact, the result
229
+ is.
230
+
231
+ ## What presence never does
232
+
233
+ - Never appears in `history()`, `state()`, or a reducer's input type.
234
+ The separation is structural: presence fields are not events, so a
235
+ fold cannot reach them even by accident.
236
+ - Never arms the scheduler, takes a claim, or retries. A crashed presence
237
+ update is a non-event, literally.
238
+ - Never enters the IndexedDB cache. Cache the log, never the moment.
239
+ - Never blocks an append. The planes share a route, not a transaction.
240
+
241
+ ## Backends
242
+
243
+ Presence needs a broadcast primitive, and backends differ. Declaring
244
+ `presence` in a contract requires a store with the presence capability;
245
+ `createServer` throws `PRESENCE_NOT_SUPPORTED` at construction
246
+ otherwise. All built-in stores have it, at two tiers:
247
+
248
+ | Backend | Delivery |
249
+ | --- | --- |
250
+ | `store-redis` | push: TTL'd hash plus pub/sub patch, single-digit ms to parked subscribers |
251
+ | `store-memory` | in-process, immediate |
252
+ | `store-postgres`, `store-sqlite`, `store-redis-http` | degraded: bounded TTL'd rows, read on the live feed's poll cadence |
253
+
254
+ Degraded means later, not lost while watched: presence-only traffic
255
+ surfaces on a fixed 250ms re-read tick plus event wakes; the 25ms
256
+ adaptive floor engages only while events flow. The push tier never
257
+ broadcasts TTL expiry: a participant that departs silently keeps their
258
+ last values in connected clients' maps until those clients reconnect,
259
+ which is why the render-time expiry above is the guard. Cursors want
260
+ the push tier; typing indicators and progress read fine on either. The presence rows are
261
+ bounded per session and participant, so this is not log growth in
262
+ disguise, but on metered backends every patch is still a network
263
+ operation. Presence makes ephemeral state correct, not free.
@@ -0,0 +1,140 @@
1
+ ---
2
+ title: Transports
3
+ description: "One client, three wires: a single HTTP route, split routes for per-route timeouts, or one WebSocket for both directions."
4
+ ---
5
+
6
+ ## One wire seam, same client
7
+
8
+ Everything the client does is transport-agnostic: optimistic pushes
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:
12
+
13
+ ```ts
14
+ // in a 'use client' session module:
15
+ api: '/api/order-events'
16
+ api: { type: 'http', push: '/api/order-push', stream: '/api/order-stream' }
17
+ api: { type: 'ws', url: '/api/order-events' }
18
+ ```
19
+
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.
33
+
34
+ A split socket is unrepresentable on purpose. The socket is one
35
+ connection in both directions; there is nothing left to split.
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
+
41
+ ## The WebSocket route
42
+
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.
47
+
48
+ ```ts app/api/order-events/route.ts
49
+ import { experimental_upgradeWebSocket } from '@vercel/functions'
50
+ import { handle } from 'experimental-a2/http'
51
+ import { ordersServer } from '@/server/orders'
52
+
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
+ })
65
+ ```
66
+
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.
88
+
89
+ ## The client
90
+
91
+ ```ts app/orders/[orderId]/session.ts
92
+ 'use client'
93
+ import { createClient } from 'experimental-a2/client'
94
+ import { createReact } from 'experimental-a2/react'
95
+ import { ordersReducer } from '@/reducer'
96
+
97
+ export const ordersClient = createClient({
98
+ reducer: ordersReducer,
99
+ api: { type: 'ws', url: '/api/order-events' },
100
+ })
101
+ export const { SessionProvider, useSession } = createReact({
102
+ client: ordersClient,
103
+ })
104
+ ```
105
+
106
+ 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.
111
+
112
+ ## Lifecycle
113
+
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.
package/docs/index.mdx CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Introduction
3
- description: A2 is durable sync and reactions for things with a lifecycle. Append events, run handlers, and keep one live view from server to browser.
3
+ description: A2 is durable sync and reactions for things with a lifecycle. Append or schedule events, run handlers, and keep one live view from server to browser.
4
4
  ---
5
5
 
6
6
  ```bash
@@ -86,9 +86,11 @@ export async function POST(req: Request) {
86
86
  }
87
87
  ```
88
88
 
89
- That append is durable. The event hits the log before anything else happens,
90
- then A2 starts its handler inline. In production, queue-backed recovery wakes
91
- pending work after a crash or timeout.
89
+ A2 commits the event before its handler can start. The handler may begin before
90
+ `append` returns; the return confirms durability, not handler completion.
91
+ A queue-backed scheduler wakes pending work after a crash or timeout. The same
92
+ scheduler lets a session append a typed event later with
93
+ `session.schedule(name, { delay: '30s' }, event)`.
92
94
 
93
95
  ## State is a fold
94
96
 
@@ -167,8 +169,8 @@ twenty lines; [Live UI](/guides/react) wires it end to end.
167
169
 
168
170
  - **Handlers are plain async functions.** They run concurrently by default.
169
171
  Use a lane for selective FIFO execution.
170
- - **Handlers are optional per event type.** A log-only event settles during
171
- append and creates no reaction or recovery work.
172
+ - **Handlers are optional per event type.** A history-only event settles during
173
+ append and creates no reaction or scheduler work.
172
174
  - **History is real.** [`history()`](/concepts/state) returns what
173
175
  actually happened, in order. Debugging is reading, not reconstructing.
174
176
  - **Nothing inside a session is sacred except its log.** Snapshots, claims, queue
@@ -182,7 +184,7 @@ twenty lines; [Live UI](/guides/react) wires it end to end.
182
184
 
183
185
  Yes. Omit `handlers` and use `append`, `history`, reducers, and live streams as
184
186
  a durable event log. Events settle in their append transaction, with no drain,
185
- claim, or queue message. A contract can also mix handled and log-only event
187
+ claim, or queue message. A contract can also mix handled and history-only event
186
188
  types. A2 decides per event type.
187
189
 
188
190
  See [Events without handlers](/concepts/handlers#events-without-handlers).
@@ -192,9 +194,10 @@ See [Events without handlers](/concepts/handlers#events-without-handlers).
192
194
  <details>
193
195
  <summary>How do I schedule an event for later?</summary>
194
196
 
195
- Anything that can deliver an HTTP call later (QStash, a cron, a payment
196
- provider's webhook) can hit a route that appends. Schedule the session id and
197
- event as the request payload. See [Timers](/guides/timers).
197
+ Configure Vercel Queues or QStash, then call
198
+ `session.schedule(name, { delay: '30s' }, event)` or use `{ at: Date }`. The
199
+ same scheduler route handles recovery and the delayed append. Names and omitted
200
+ event ids stay stable across retries. See [Timers](/guides/timers).
198
201
 
199
202
  </details>
200
203
 
@@ -220,10 +223,11 @@ recovery story behind them.
220
223
  <details>
221
224
  <summary>What does production need?</summary>
222
225
 
223
- A durable log backend, such as Postgres or Redis. Add queue-backed recovery
224
- when the contract has handlers. A log-only contract needs no reaction
225
- infrastructure. Development needs neither: SQLite appears under `.a2/` and
226
- state survives restarts. See [Going to production](/guides/production).
226
+ A durable store backend, such as Postgres or Redis. Add a queue-backed scheduler
227
+ when the contract has handlers or calls `session.schedule`. A history-only
228
+ contract with no timers needs no reaction infrastructure. Development needs
229
+ neither: SQLite appears under `.a2/` and state survives restarts. See
230
+ [Going to production](/guides/production).
227
231
 
228
232
  </details>
229
233