experimental-a2 0.9.0 → 0.11.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 (123) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/actor-D_54lz_1.d.ts +310 -0
  3. package/dist/actor-D_54lz_1.d.ts.map +1 -0
  4. package/dist/actor-client.d.ts +13 -4
  5. package/dist/actor-client.d.ts.map +1 -1
  6. package/dist/actor-client.js +63 -7
  7. package/dist/actor-client.js.map +1 -1
  8. package/dist/actor-react.d.ts +5 -4
  9. package/dist/actor-react.d.ts.map +1 -1
  10. package/dist/actor-react.js +16 -2
  11. package/dist/actor-react.js.map +1 -1
  12. package/dist/actor.d.ts +2 -176
  13. package/dist/actor.js +13 -2
  14. package/dist/actor.js.map +1 -1
  15. package/dist/ai-server.d.ts +1 -1
  16. package/dist/ai-server.js +2 -2
  17. package/dist/ai.d.ts +1 -1
  18. package/dist/ai.js +1 -1
  19. package/dist/client-Bf6uSEAk.js +1342 -0
  20. package/dist/client-Bf6uSEAk.js.map +1 -0
  21. package/dist/client-P_NNNRM-.d.ts +243 -0
  22. package/dist/client-P_NNNRM-.d.ts.map +1 -0
  23. package/dist/client.d.ts +2 -202
  24. package/dist/client.js +2 -1026
  25. package/dist/errors-DCk6ch5n.js.map +1 -1
  26. package/dist/errors-DvhSXnxk.d.ts +28 -0
  27. package/dist/errors-DvhSXnxk.d.ts.map +1 -0
  28. package/dist/index.d.ts +3 -35
  29. package/dist/{internal-DRXJ56EI.js → internal-Dq2qYxou.js} +2 -2
  30. package/dist/{internal-DRXJ56EI.js.map → internal-Dq2qYxou.js.map} +1 -1
  31. package/dist/platform-B4TnJtWu.js +34 -0
  32. package/dist/platform-B4TnJtWu.js.map +1 -0
  33. package/dist/react.d.ts +3 -1
  34. package/dist/react.d.ts.map +1 -1
  35. package/dist/react.js +2 -1
  36. package/dist/react.js.map +1 -1
  37. package/dist/scheduler-qstash.d.ts +2 -2
  38. package/dist/scheduler-qstash.js +3 -2
  39. package/dist/scheduler-qstash.js.map +1 -1
  40. package/dist/scheduler-vercel.d.ts +2 -2
  41. package/dist/scheduler-vercel.js +2 -2
  42. package/dist/{server-CBET-jSz.js → server-Dkz2a84E.js} +295 -133
  43. package/dist/server-Dkz2a84E.js.map +1 -0
  44. package/dist/{server-CKY3_lbw.d.ts → server-DwPrMqHB.d.ts} +4 -2
  45. package/dist/server-DwPrMqHB.d.ts.map +1 -0
  46. package/dist/server.d.ts +2 -2
  47. package/dist/server.js +1 -1
  48. package/dist/{store-DGHeBtIQ.d.ts → store-DtDOWLSn.d.ts} +4 -5
  49. package/dist/{store-DGHeBtIQ.d.ts.map → store-DtDOWLSn.d.ts.map} +1 -1
  50. package/dist/store-N8PXxDAS.js.map +1 -1
  51. package/dist/store-memory.d.ts +1 -1
  52. package/dist/store-memory.js +1 -1
  53. package/dist/{store-polling-6DW7F1DT.js → store-polling-CmxUbV93.js} +56 -7
  54. package/dist/store-polling-CmxUbV93.js.map +1 -0
  55. package/dist/store-postgres.d.ts +6 -4
  56. package/dist/store-postgres.d.ts.map +1 -1
  57. package/dist/store-postgres.js +701 -53
  58. package/dist/store-postgres.js.map +1 -1
  59. package/dist/store-presence-polling-C7-XZyW9.js +94 -0
  60. package/dist/store-presence-polling-C7-XZyW9.js.map +1 -0
  61. package/dist/store-redis-http.d.ts +2 -2
  62. package/dist/store-redis-http.d.ts.map +1 -1
  63. package/dist/store-redis-http.js +184 -38
  64. package/dist/store-redis-http.js.map +1 -1
  65. package/dist/{store-redis-core-z-ykbyMg.js → store-redis-notify-BUCyXOn0.js} +491 -27
  66. package/dist/store-redis-notify-BUCyXOn0.js.map +1 -0
  67. package/dist/store-redis.d.ts +5 -13
  68. package/dist/store-redis.d.ts.map +1 -1
  69. package/dist/store-redis.js +27 -271
  70. package/dist/store-redis.js.map +1 -1
  71. package/dist/store-sqlite.d.ts +1 -1
  72. package/dist/store-sqlite.js +1 -1
  73. package/dist/{wire--yji6mO3.js → wire-BO5wWCb1.js} +18 -16
  74. package/dist/wire-BO5wWCb1.js.map +1 -0
  75. package/docs/actors/04-routes.mdx +40 -8
  76. package/docs/guides/03-react.mdx +52 -10
  77. package/docs/guides/05-production.mdx +54 -5
  78. package/docs/guides/09-presence.mdx +12 -7
  79. package/docs/guides/10-transports.mdx +85 -22
  80. package/docs/reference/01-api.mdx +90 -28
  81. package/docs/reference/02-errors.mdx +45 -11
  82. package/examples/playground/package.json +1 -1
  83. package/package.json +1 -1
  84. package/src/actor-client.ts +114 -14
  85. package/src/actor-react.ts +18 -3
  86. package/src/actor.ts +16 -1
  87. package/src/client-errors.ts +51 -0
  88. package/src/client.ts +577 -175
  89. package/src/errors.ts +4 -9
  90. package/src/index.ts +1 -1
  91. package/src/internal.ts +1 -1
  92. package/src/postgres-notification-scope.ts +134 -0
  93. package/src/postgres-notifications.ts +244 -0
  94. package/src/postgres-pool.ts +65 -0
  95. package/src/postgres-resources.ts +185 -0
  96. package/src/presence-recovery.ts +120 -0
  97. package/src/react.ts +3 -0
  98. package/src/redis-http-subscriptions.ts +199 -0
  99. package/src/server-fetch.ts +51 -23
  100. package/src/server.ts +146 -19
  101. package/src/session-socket.ts +241 -88
  102. package/src/sse.ts +17 -0
  103. package/src/store-polling.ts +32 -12
  104. package/src/store-postgres.ts +292 -75
  105. package/src/store-presence-polling.ts +125 -0
  106. package/src/store-redis-core.ts +37 -30
  107. package/src/store-redis-http.ts +51 -45
  108. package/src/store-redis-notify.ts +464 -0
  109. package/src/store-redis.ts +9 -364
  110. package/src/store.ts +3 -4
  111. package/src/stream-activity.ts +32 -0
  112. package/src/wire.ts +39 -15
  113. package/dist/actor-shared-BACubf4x.d.ts +0 -136
  114. package/dist/actor-shared-BACubf4x.d.ts.map +0 -1
  115. package/dist/actor.d.ts.map +0 -1
  116. package/dist/client.d.ts.map +0 -1
  117. package/dist/client.js.map +0 -1
  118. package/dist/index.d.ts.map +0 -1
  119. package/dist/server-CBET-jSz.js.map +0 -1
  120. package/dist/server-CKY3_lbw.d.ts.map +0 -1
  121. package/dist/store-polling-6DW7F1DT.js.map +0 -1
  122. package/dist/store-redis-core-z-ykbyMg.js.map +0 -1
  123. package/dist/wire--yji6mO3.js.map +0 -1
@@ -13,20 +13,33 @@ it:
13
13
  ```ts
14
14
  // in a 'use client' session module:
15
15
  api: '/api/order-events'
16
+ api: (sessionId) => `/api/orders/${encodeURIComponent(sessionId)}/events`
16
17
  api: { type: 'http', push: '/api/order-push', stream: '/api/order-stream' }
17
18
  api: { type: 'ws', url: '/api/order-events' }
18
19
  ```
19
20
 
20
- - **The string** is the default and the recommendation: one route
21
- serving GET (SSE stream and history) and POST (push).
21
+ - **One route** is the default and the recommendation. Pass a fixed URL or a
22
+ session URL function. It serves GET (SSE stream and history) and POST (push).
22
23
  - **`http` split** serves the two verbs from separate routes. Use it
23
24
  when platform duration limits differ per verb.
24
- - **`ws`** rides streams, pushes, and presence over one multiplexed
25
- WebSocket. One socket carries every session of the client.
25
+ - **`ws`** rides streams, pushes, and presence over WebSocket. It opens
26
+ one socket per session. Set `multiplex: true` on the client and
27
+ `multiplexWebSocket: true` on the route to share one socket across the
28
+ client's sessions.
26
29
 
27
30
  A split socket is unrepresentable on purpose. The socket is one
28
31
  connection in both directions.
29
32
 
33
+ Every session-scoped URL may instead be a `(sessionId) => string` function.
34
+ A2 resolves it for each request or reconnect, then adds its standard query
35
+ parameters. This supports paths scoped to a session. Encode the ID when placing
36
+ it in a path segment. The function chooses a route, not authority: if a path or
37
+ connection token is scoped to one session, `authorize` must compare that scope
38
+ to `operation.sessionId`. Bind URL tokens to the same session and keep them
39
+ short-lived because URLs can appear in infrastructure logs. Multiplexed
40
+ WebSockets take one plain URL because the connection carries more than one
41
+ session.
42
+
30
43
  One wire is missing from `ws` by design: the history lane.
31
44
  [`loadHistory`](/guides/react#the-client-component) is a bounded cold
32
45
  read and rides plain HTTP. On a `ws` API it throws a `TypeError`.
@@ -103,22 +116,61 @@ export function GET(request: Request): Promise<Response> {
103
116
  }
104
117
  ```
105
118
 
106
- The socket is multiplexed. Subscribe frames open per-session lanes,
107
- each resuming from its own frontier. Every down frame carries its
108
- `sessionId`; pushes and presence route by it. One heartbeat and one
109
- connection serve all of the client's sessions.
119
+ The socket is bound to the `sessionId` in its upgrade URL. The route
120
+ authorizes that session before upgrading, and frames cannot select a
121
+ different session afterward. Each connected session has its own socket.
122
+
123
+ Authenticate the physical upgrade before calling `server.fetch`. For browser
124
+ sockets authenticated by cookies, validate the request's `Origin` too. A
125
+ WebSocket handshake does not use a CORS preflight.
126
+ Then pass `authorize` to check the session stream and every push or
127
+ presence frame. There is no separate upgrade operation: request policy
128
+ belongs to the route, while A2 operation policy belongs to `authorize`.
129
+
130
+ To multiplex, opt in on both sides:
131
+
132
+ ```ts app/api/multiplexed-order-events/route.ts
133
+ import { experimental_upgradeWebSocket } from '@vercel/functions'
134
+ import { ordersServer } from '@/server/orders'
135
+
136
+ export function GET(request: Request): Promise<Response> {
137
+ return ordersServer.fetch(request, {
138
+ multiplexWebSocket: true,
139
+ upgradeWebSocket: (attach) =>
140
+ experimental_upgradeWebSocket(attach, {
141
+ maxPayload: 4 * 1024 * 1024,
142
+ }),
143
+ })
144
+ }
145
+ ```
146
+
147
+ ```ts app/orders/multiplexed-session.ts
148
+ 'use client'
149
+ import { createClient } from 'experimental-a2/client'
150
+ import { ordersReducer } from '@/reducer'
151
+
152
+ export const ordersClient = createClient({
153
+ reducer: ordersReducer,
154
+ api: {
155
+ type: 'ws',
156
+ url: '/api/multiplexed-order-events',
157
+ multiplex: true,
158
+ },
159
+ })
160
+ ```
110
161
 
111
- Authenticate the physical upgrade before calling `server.fetch`.
112
- Then pass `authorize` to check every session subscribe and every push
113
- or presence frame. There is no separate upgrade operation: request
114
- policy belongs to the route, while A2 operation policy belongs to
115
- `authorize`.
162
+ Only enable multiplexing when the request's authentication context may
163
+ open more than one session and `authorize` checks each supplied session
164
+ ID. Subscribe frames open the lanes after the physical upgrade.
116
165
 
117
- A denied subscribe receives an `unsubscribed` notice and leaves other
118
- sessions connected. A denied event push receives a non-retryable
119
- `FORBIDDEN` ack. Denied presence is silently dropped because presence
120
- is fire-and-forget and repaints on the next update. Failed presence
121
- authorization is dropped the same way and leaves the socket connected.
166
+ On a multiplexed socket, a denied subscribe receives an `unsubscribed`
167
+ notice with a structured `FORBIDDEN` error and leaves other sessions
168
+ connected. A failed stream or thrown authorization check carries
169
+ `STORE_UNAVAILABLE`. The client exposes these as `A2ClientError` instances
170
+ to its reconnection policy. On either socket shape, a
171
+ denied event push receives a non-retryable `FORBIDDEN` ack. Denied
172
+ presence is silently dropped because presence is fire-and-forget and
173
+ repaints on the next update.
122
174
 
123
175
  A2 reads the platform's ambient invocation deadline and closes the
124
176
  socket cleanly before it. `waitUntil` and deadline discovery remain
@@ -148,10 +200,21 @@ shared above the wire seam.
148
200
 
149
201
  ## Lifecycle
150
202
 
151
- When a socket closes, the client reconnects with backoff, re-subscribes
152
- every session at its own frontier, receives fresh presence snapshots,
153
- and re-sends its own latest presence values. Event pushes keep their
154
- client-generated ids, so retry remains idempotent.
203
+ When a socket closes, the client follows its reconnection policy at the current
204
+ frontier, receives a fresh presence snapshot, and re-sends its own latest
205
+ presence values. A multiplexed socket re-subscribes every active session
206
+ at its own frontier. Event pushes keep their client-generated ids, so
207
+ retry remains idempotent.
208
+
209
+ Configure `createClient({ reconnect })` to choose a delay or pause by
210
+ returning `false`. A paused session keeps its state and leases; call
211
+ `session.reconnect()` to resume it. The default policy reconnects with
212
+ backoff indefinitely. A clean close also runs the policy, with `error: null`.
213
+
214
+ Pre-upgrade authorization remains in the HTTP route. Browser WebSockets do
215
+ not expose a rejected upgrade's HTTP status or body, so those failures remain
216
+ opaque `CONNECTION_FAILED` errors. Structured subscription errors are
217
+ available after a multiplexed socket has opened.
155
218
 
156
219
  SSE and WebSocket are transport choices, not different consistency
157
220
  models. Both resume from the durable event frontier and treat presence
@@ -59,7 +59,8 @@ invalidates, and the next read refolds from raw events. See
59
59
 
60
60
  ### `A2Error`
61
61
 
62
- Everything A2 throws. One class, discriminated by `code`. See
62
+ The shared base for A2 failures, discriminated by `code`. The session
63
+ client exposes its `A2ClientError` subclass. See
63
64
  [Errors](/reference/errors).
64
65
 
65
66
  ## `experimental-a2/server`
@@ -199,6 +200,7 @@ server.fetch(
199
200
  upgradeWebSocket?: (
200
201
  attach: (socket: A2Socket) => void,
201
202
  ) => Response | Promise<Response>
203
+ multiplexWebSocket?: boolean
202
204
  },
203
205
  ): Promise<Response>
204
206
 
@@ -307,13 +309,20 @@ export function GET(request: Request): Promise<Response> {
307
309
  }
308
310
  ```
309
311
 
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.
312
+ By default, the `sessionId` and `index` query parameters bind one session to the
313
+ socket. A2 authorizes the stream before upgrading. Frames on that socket cannot
314
+ select another session.
315
+
316
+ Set `multiplexWebSocket: true` here and `multiplex: true` in the client's `ws`
317
+ API to share one socket.
318
+ Authenticate the physical WebSocket GET before calling `server.fetch`. Validate
319
+ `Origin` too when browser cookies authenticate it; WebSocket handshakes do not
320
+ use a CORS preflight. Use `authorize` for each session subscribe and each event
321
+ or presence push. Only enable multiplexing when that request context may access
322
+ every session accepted by `authorize`. A denied subscribe receives
323
+ `unsubscribed` and does not affect other sessions. A denied event push receives
324
+ a `FORBIDDEN` ack. Denied presence is silently dropped because it is
325
+ fire-and-forget.
317
326
 
318
327
  SSE and WebSocket lifetimes use the platform's ambient invocation deadline.
319
328
  A2 also uses the platform's ambient `waitUntil` capability for background work.
@@ -674,8 +683,8 @@ session.stream(options: {
674
683
 
675
684
  A live feed of the session's events. `startAfter` is a non-negative safe integer;
676
685
  `startAfter: 20` begins with event 21.
677
- Server-side only; `server.fetch` exposes it over SSE, and over the multiplexed
678
- socket when `upgrade` is set. Subscribing never dispatches handlers.
686
+ Server-side only; `server.fetch` exposes it over SSE and WebSocket. Subscribing
687
+ never dispatches handlers.
679
688
 
680
689
  With `presence: true`, the feed yields one `PresenceSnapshot` first:
681
690
  `{ snapshot }`, the current pruned map with each field's own `value`,
@@ -785,25 +794,27 @@ transition and the destination should adopt the same optimistic session.
785
794
  | --------------- | ------------------------------------------------------ |
786
795
  | `sessionId` | which session to subscribe to |
787
796
  | `initialState` | server-rendered state |
788
- | `initialIndex` | the fold's frontier, where the stream resumes |
797
+ | `initialIndex` | the fold's frontier, where a new session starts streaming |
789
798
  | `initialEvents` | optional earlier raw events for a history UI |
790
799
  | `participant` | overrides the client's `participant`; one of the two is required to call `setPresence` |
791
800
 
792
801
  Opens the stream on mount, closes it on unmount, reconnects with
793
- backoff from the current frontier. `participant` binds at the session's
802
+ backoff from its last streamed event. `participant` binds at the session's
794
803
  first resolution; a changed provider prop is ignored for the session's
795
804
  runtime lifetime.
796
805
 
797
806
  `initialState` and `initialIndex` are the complete server-to-client handoff.
798
- The stream resumes after `initialIndex`, including events appended between the
799
- server render and the connection. `initialEvents` only preloads the raw
807
+ For a new session, the stream starts after `initialIndex`, including events
808
+ appended between the server render and the connection. `initialEvents` only preloads the raw
800
809
  `events` feed for a UI that needs entries from before that frontier. It does
801
- not affect state hydration or stream resumption.
810
+ not affect state hydration or stream resumption. Later snapshots advance
811
+ state while preserving the stream's resume position, so delayed events
812
+ still enter the feed without folding into state again.
802
813
 
803
814
  ### `useSession()`
804
815
 
805
816
  ```ts
806
- const { state, push, events, index, loadHistory, history, connection,
817
+ const { state, push, reconnect, events, index, loadHistory, history, connection,
807
818
  presence, setPresence } = useSession()
808
819
  ```
809
820
 
@@ -811,7 +822,7 @@ const { state, push, events, index, loadHistory, history, connection,
811
822
  shared reducer. `events` is the raw event feed: observed, explicitly
812
823
  seeded, or backscrolled. Unless `initialEvents` seeds earlier entries or
813
824
  `loadHistory` fetches them, it begins after `initialIndex`. `index` is
814
- the stream frontier, the `lastSeenIndex` for
825
+ the latest server-confirmed state index, the `lastSeenIndex` for
815
826
  [cancellation](/guides/cancellation).
816
827
 
817
828
  `loadHistory({ before?, limit? })` backscrolls: it fetches a bounded
@@ -820,10 +831,9 @@ query parameters) and merges it into `events`, deduped, ordered, and
820
831
  shared across every handle of the session. It resolves with the events
821
832
  in the requested range. Defaults walk backward 50 at a time from the
822
833
  oldest loaded event; `before` is an exclusive upper bound. After a
823
- hydrate jump (returning to a session whose frontier advanced while
824
- away), default paging still continues from the oldest loaded event;
825
- pass an explicit `before` to fill the gap between the old feed and the
826
- new frontier. It never touches `state` or the optimistic overlay. Calls
834
+ newer snapshot hydrates the session, default paging still continues from
835
+ the oldest loaded event. Pass an explicit `before` to read another range.
836
+ It never touches `state` or the optimistic overlay. Calls
827
837
  serialize per session and already-loaded ranges are not refetched.
828
838
  `history` is the progress: `{ loading, complete, oldestLoaded }`, where
829
839
  `complete` means the feed reaches index 1 (or the log is empty). The `ws` api has no history lane; there `loadHistory`
@@ -848,7 +858,7 @@ contract; it still never folds presence). `setPresence` requires a
848
858
  [Presence](/guides/presence).
849
859
 
850
860
  `push` appends optimistically: validated locally, rolled back on
851
- rejection, retried only for `STORE_UNAVAILABLE`. Awaiting it gives the
861
+ rejection, retried for `STORE_UNAVAILABLE` and transport failures. Awaiting it gives the
852
862
  server ack; the same result carries `confirmed`, a lazy promise for
853
863
  the later moment when the live stream has delivered the batch back and
854
864
  the view shows server truth:
@@ -867,18 +877,31 @@ only for the previous call's ack or final rejection, not for `confirmed`.
867
877
  Other session IDs and clients remain concurrent; they converge on the log's
868
878
  durable order.
869
879
 
870
- A rejected push rejects both promises with the same `A2Error`;
880
+ A rejected push rejects both promises with the same `A2ClientError`;
871
881
  `confirmed` is materialized only when accessed, so ignoring it costs
872
882
  nothing. It also reads as intent: `await push(...).confirmed` is
873
883
  "continue once this is server truth".
874
884
 
885
+ `session.reconnect(): void` immediately restarts a leased subscription,
886
+ including one paused by the reconnection policy,
887
+ from its current cursor. It preserves state, pending writes, and leases, and
888
+ does nothing without an active lease. The React `useSession()` result exposes
889
+ the same `reconnect` function. Use it after a custom REST mutation; it requests
890
+ a restart rather than returning a promise for catch-up completion. A
891
+ multiplexed WebSocket keeps its physical socket and other session lanes;
892
+ a single-session WebSocket replaces its socket.
893
+
875
894
  `connection` is a discriminated union; impossible states are
876
895
  unrepresentable (an `error` only exists while disconnected):
877
896
 
878
897
  ```ts
898
+ // in a client module:
899
+ import type { A2ClientError } from 'experimental-a2/client'
900
+
879
901
  type Connection =
880
902
  | { status: 'idle' }
881
- | { status: 'connecting'; reconnects: number; error: Error | null }
903
+ | { status: 'connecting'; reconnects: number; error: A2ClientError | null }
904
+ | { status: 'paused'; reconnects: number; error: A2ClientError | null }
882
905
  | { status: 'live'; reconnects: number }
883
906
  | { status: 'closed' }
884
907
  ```
@@ -1198,20 +1221,59 @@ createClient(options: {
1198
1221
  api: ClientApi
1199
1222
  gcTime?: number // idle session lifetime; 5 minutes by default
1200
1223
  participant?: string // default presence identity for every session
1224
+ reconnect?: (failure: {
1225
+ sessionId: string
1226
+ error: A2ClientError | null
1227
+ attempt: number
1228
+ }) => number | false
1201
1229
  }): A2Client
1202
1230
 
1203
1231
  type ClientApi =
1204
- | string // one route: GET SSE stream + POST push
1205
- | { type: 'http'; push: string; stream: string } // split routes
1206
- | { type: 'ws'; url: string } // one socket, both directions
1232
+ | SessionUrl // one route: GET SSE stream + POST push
1233
+ | { type: 'http'; push: SessionUrl; stream: SessionUrl }
1234
+ | { type: 'ws'; url: SessionUrl; multiplex?: false }
1235
+ | { type: 'ws'; url: string; multiplex: true }
1236
+
1237
+ type SessionUrl = string | ((sessionId: string) => string)
1207
1238
  ```
1208
1239
 
1240
+ `reconnect` runs after a failed connection or a stream ending, including a
1241
+ clean end with `error: null`. Return the delay in milliseconds before the next
1242
+ attempt, or `false` to pause automatic reconnection. Delays must be finite,
1243
+ non-negative, and at most 2,147,483,647 ms. The callback is synchronous; a
1244
+ throw or invalid result pauses the connection with an `A2ClientError`.
1245
+
1246
+ `attempt` starts at 1 for the first retry. It counts consecutive attempts
1247
+ per session and resets when the stream becomes live or `session.reconnect()`
1248
+ explicitly restarts it. It is separate from the lifetime `reconnects` count.
1249
+ Without a callback, A2 retries indefinitely with delays starting at 500 ms,
1250
+ doubling up to five seconds. The callback does not run for an explicit
1251
+ restart, an idle-stream refresh, or a caller closing the stream.
1252
+
1253
+ While paused, `connection.status` is `paused` and its last error remains
1254
+ available. State, hydration, optimistic writes, and connection leases remain
1255
+ intact. Another consumer taking a lease does not resume a paused stream.
1256
+ Call `session.reconnect()` to resume immediately with a fresh attempt count.
1257
+ Releasing the last lease still closes the stream. A later first lease starts
1258
+ a new connection. Push retries are independent of this policy; a push does
1259
+ not resume a paused stream. An acknowledged push's `confirmed` promise waits
1260
+ until streamed events or a newer hydrated snapshot cover the acknowledged
1261
+ state index. Resumption preserves the stream's own position, even when
1262
+ hydration has advanced state while paused.
1263
+
1264
+ A `SessionUrl` function runs for each HTTP request or WebSocket connection.
1265
+ Use it for session-specific paths. A2 still adds its `sessionId` and read-bound
1266
+ query parameters. The function does not authorize that ID. Bind any path scope
1267
+ or URL token to `operation.sessionId` in `authorize`; keep URL tokens short-lived
1268
+ because URLs can appear in infrastructure logs. A multiplexed WebSocket URL is
1269
+ a string because its connection carries more than one session.
1270
+
1209
1271
  The framework-agnostic session client `experimental-a2/react` is built on: the SSE
1210
1272
  subscription with frontier resume and reconnection, the optimistic push
1211
1273
  queue with ack/rollback, and the local fold. `client.session(id, {
1212
1274
  initialState?, initialIndex?, initialEvents?, participant? })` returns a
1213
1275
  handle with `getSnapshot()`/`subscribe()` (the `useSyncExternalStore`
1214
- contract), `push()`, `loadHistory()`, `connect()`, and `close()`.
1276
+ contract), `push()`, `loadHistory()`, `connect()`, `reconnect()`, and `close()`.
1215
1277
  `connect()` takes a lease on the live stream and returns its release:
1216
1278
  leases refcount per handle (the first connects, releasing the last
1217
1279
  closes, releasing twice is a no-op), so independent consumers of one
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  title: Errors
3
- description: One error class, stable codes, and clear rules about what append will never throw for.
3
+ description: Stable server and client error codes, transport details, and append guarantees.
4
4
  ---
5
5
 
6
6
  ## `A2Error`
7
7
 
8
- Everything A2 throws is an `A2Error`, a single class discriminated by
9
- `code`, not a subclass hierarchy. One class serializes cleanly across the
10
- push-route boundary and keeps `instanceof` working after bundling.
8
+ A2 failures carry an `A2Error` with a stable `code`. The session client exposes
9
+ `A2ClientError`, which extends `A2Error`, for both server failures and failures
10
+ observed locally. Existing `instanceof A2Error` checks also match client errors.
11
11
 
12
12
  ```ts
13
13
  // e.g. in a route handler:
@@ -45,9 +45,39 @@ export async function POST(req: Request) {
45
45
  | `STORE_UNAVAILABLE` | the store backend failed; nothing was written, original error as `cause` | **yes** |
46
46
  | `STORE_NOT_CONFIGURED` | production boot with no `store` configured (during `next build` page collection, deferred to first use) | no |
47
47
 
48
- Retryability is derivable from the code. `STORE_UNAVAILABLE` is the only one
49
- worth retrying; the rest are deterministic caller bugs, and retrying them
50
- is wasted work.
48
+ Of these server codes, only `STORE_UNAVAILABLE` is automatically retried by
49
+ client pushes. Reconnection has its own caller-configurable policy.
50
+
51
+ ## `A2ClientError`
52
+
53
+ Import `A2ClientError` from `experimental-a2/client`. Connection failures,
54
+ push failures, history read failures, and local A2 validation failures use
55
+ this class. Server failures keep their `code`, `message`, and `details`.
56
+ Unexpected thrown values are retained as `cause`. Invalid API arguments can
57
+ still throw native `TypeError` or `RangeError` exceptions.
58
+
59
+ | Client code | Meaning |
60
+ | --- | --- |
61
+ | `TIMEOUT` | A2 detected a stream stall or an unconfirmed subscription. `phase` is `stream` or `subscribe`; `timeoutMs` is the expired deadline. |
62
+ | `HTTP_ERROR` | An unsuccessful HTTP response without a recognized A2 error body. `status` carries the HTTP status. |
63
+ | `CONNECTION_FAILED` | The transport could not establish or retain a connection. WebSocket close events preserve `closeCode` and `closeReason` when available. |
64
+ | `UNKNOWN` | An unexpected client failure. The original value is available as `cause`. |
65
+
66
+ HTTP failures retain `status` even when they carry a recognized server code.
67
+ A browser WebSocket handshake rejection has no readable HTTP status or body;
68
+ it remains `CONNECTION_FAILED`. A2 does not infer an authorization failure or
69
+ a timeout from an opaque socket error.
70
+
71
+ The stream watchdog detects 35 seconds of silence after connection. A
72
+ multiplexed subscription also has 35 seconds to receive confirmation. There
73
+ is no A2-owned initial connection deadline for HTTP or single-session
74
+ WebSockets. Clean stream termination has no error: the reconnection callback
75
+ and connection snapshot carry `null`.
76
+
77
+ Pushes retain their three-attempt budget for `STORE_UNAVAILABLE` and client
78
+ transport failures. The reconnection callback controls only the live stream.
79
+ Pausing that stream preserves the writer; it does not guarantee that a push
80
+ can succeed while its WebSocket is unavailable.
51
81
 
52
82
  On `PARTIAL_DUPLICATE_BATCH`: retrying an *identical* batch is not an
53
83
  error. It's an idempotent success, and you get the previously appended
@@ -60,7 +90,7 @@ nothing is written.
60
90
 
61
91
  ## NonRetriableError
62
92
 
63
- The second exported error class. Throw it (or a subclass, matched by
93
+ Throw it (or a subclass, matched by
64
94
  `instanceof`) from a handler when the failure is deterministic: the
65
95
  event dead-letters immediately instead of consuming the ten-failure
66
96
  retry budget, because a guard that refuses this attempt will refuse
@@ -110,6 +140,10 @@ with a mapped status: `INVALID_PAYLOAD`, `UNKNOWN_EVENT_TYPE`, and
110
140
  `PARTIAL_DUPLICATE_BATCH` are 400; `FORBIDDEN` is 403;
111
141
  `SUPERSEDED_ATTEMPT` is 409; `STORE_UNAVAILABLE` is 503.
112
142
 
113
- The client's `push` deserializes the body back into an `A2Error`, so client
114
- and server code branch on identical codes. `push` auto-retries only
115
- `STORE_UNAVAILABLE`. `server.fetch` owns the HTTP serialization.
143
+ The session client deserializes the body into an `A2ClientError`, so client
144
+ and server code branch on identical server codes. `server.fetch` owns HTTP
145
+ serialization. Multiplexed WebSocket subscription failures carry the same
146
+ error envelope in their `unsubscribed` frame, alongside the legacy `reason`
147
+ string. Older reason-only frames remain readable as `CONNECTION_FAILED`.
148
+ Unknown server exceptions become `STORE_UNAVAILABLE`; their private causes
149
+ are not sent to clients.
@@ -22,7 +22,7 @@
22
22
  "@vercel/sandbox": "^3.0.0",
23
23
  "ai": "^7.0.58",
24
24
  "codemirror": "^6.0.2",
25
- "experimental-a2": "0.9.0",
25
+ "experimental-a2": "0.11.0",
26
26
  "ioredis": "^5.9.0",
27
27
  "next": "^16.3.0",
28
28
  "pg": "^8.16.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "experimental-a2",
3
- "version": "0.9.0",
3
+ "version": "0.11.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",
@@ -22,6 +22,16 @@ import type { ActorCallOptions, ActorCallResult } from './actor.ts'
22
22
 
23
23
  export { ActorRefusedError }
24
24
 
25
+ export class ActorRequestError extends Error {
26
+ readonly status: number | undefined
27
+
28
+ constructor(message: string, options?: { status?: number; cause?: unknown }) {
29
+ super(message, { cause: options?.cause })
30
+ this.name = 'ActorRequestError'
31
+ this.status = options?.status
32
+ }
33
+ }
34
+
25
35
  /** The loose shape of an `actor()` definition — carried type-only. */
26
36
  export type AnyActorDefinition = {
27
37
  actor(id: string): {
@@ -58,13 +68,15 @@ export type ActorClient<
58
68
  > = {
59
69
  /** Typed calls over POST — the response is the answer. */
60
70
  readonly call: ActorClientCall<D, View>
71
+ readonly state: () => Promise<ActorCallResult<View>>
61
72
  }
62
73
 
63
- export type CreateActorClientOptions = {
74
+ export type CreateActorClientOptions<View = unknown> = {
64
75
  /** The route whose handler delegates to `handle.fetch` for this instance. */
65
76
  api: string
66
77
  /** Injectable transport (tests, custom auth headers). */
67
78
  fetch?: typeof globalThis.fetch
79
+ onSnapshot?: (snapshot: ActorCallResult<View>) => void
68
80
  }
69
81
 
70
82
  const CALL_TIMEOUT_MS = 30_000
@@ -76,7 +88,7 @@ const callOverWire = async (
76
88
  input: unknown,
77
89
  options?: ActorCallOptions,
78
90
  ): Promise<ActorCallResult<unknown>> => {
79
- const response = await fetchImpl(api, {
91
+ const response = await requestOverWire(fetchImpl, api, {
80
92
  method: 'POST',
81
93
  headers: { 'content-type': 'application/json' },
82
94
  body: JSON.stringify({
@@ -86,10 +98,7 @@ const callOverWire = async (
86
98
  }),
87
99
  signal: AbortSignal.timeout(options?.timeoutMs ?? CALL_TIMEOUT_MS),
88
100
  })
89
- const body = (await response.json().catch(() => ({}))) as Record<
90
- string,
91
- unknown
92
- >
101
+ const body = await responseBody(response)
93
102
  if (response.status === 409 && typeof body['error'] === 'string') {
94
103
  throw new ActorRefusedError(
95
104
  typeof body['event'] === 'string' ? body['event'] : event,
@@ -102,7 +111,53 @@ const callOverWire = async (
102
111
  typeof body['error'] === 'string'
103
112
  ? body['error']
104
113
  : `actor call failed (${response.status})`
105
- throw new Error(message)
114
+ throw new ActorRequestError(message, { status: response.status })
115
+ }
116
+ return snapshotBody(body, response.status)
117
+ }
118
+
119
+ const requestOverWire = async (
120
+ fetchImpl: typeof globalThis.fetch,
121
+ api: string,
122
+ init: RequestInit,
123
+ ): Promise<Response> => {
124
+ try {
125
+ return await fetchImpl(api, init)
126
+ } catch (cause) {
127
+ throw new ActorRequestError('actor request did not receive a response', {
128
+ cause,
129
+ })
130
+ }
131
+ }
132
+
133
+ const responseBody = async (
134
+ response: Response,
135
+ ): Promise<Record<string, unknown>> => {
136
+ try {
137
+ const body: unknown = await response.json()
138
+ if (!body || typeof body !== 'object' || Array.isArray(body))
139
+ throw new Error('invalid response')
140
+ return body as Record<string, unknown>
141
+ } catch (cause) {
142
+ throw new ActorRequestError('actor request returned an invalid response', {
143
+ status: response.status,
144
+ cause,
145
+ })
146
+ }
147
+ }
148
+
149
+ const snapshotBody = (
150
+ body: Record<string, unknown>,
151
+ status: number,
152
+ ): ActorCallResult<unknown> => {
153
+ if (
154
+ !('state' in body) ||
155
+ !Number.isSafeInteger(body['index']) ||
156
+ (body['index'] as number) < 0
157
+ ) {
158
+ throw new ActorRequestError('actor request returned an invalid snapshot', {
159
+ status,
160
+ })
106
161
  }
107
162
  return body as ActorCallResult<unknown>
108
163
  }
@@ -116,17 +171,62 @@ const callOverWire = async (
116
171
  export function createActorClient<
117
172
  D extends AnyActorDefinition,
118
173
  View = ActorStateOf<D>,
119
- >(options: CreateActorClientOptions): ActorClient<D, View> {
174
+ >(options: CreateActorClientOptions<View>): ActorClient<D, View> {
120
175
  const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis)
176
+ const adopt = (snapshot: ActorCallResult<unknown>): ActorCallResult<View> => {
177
+ const result = snapshot as ActorCallResult<View>
178
+ options.onSnapshot?.(result)
179
+ return result
180
+ }
181
+ const state = async (): Promise<ActorCallResult<View>> => {
182
+ const api = options.api.split('#')[0]!
183
+ const separator = api.includes('?') ? '&' : '?'
184
+ const response = await requestOverWire(
185
+ fetchImpl,
186
+ `${api}${separator}snapshot=1`,
187
+ {
188
+ cache: 'no-store',
189
+ signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
190
+ },
191
+ )
192
+ const body = await responseBody(response)
193
+ if (!response.ok) {
194
+ throw new ActorRequestError(
195
+ typeof body['error'] === 'string'
196
+ ? body['error']
197
+ : `actor state read failed (${response.status})`,
198
+ { status: response.status },
199
+ )
200
+ }
201
+ return adopt(snapshotBody(body, response.status))
202
+ }
203
+ const methods = new Map<
204
+ string,
205
+ (
206
+ input?: unknown,
207
+ callOptions?: ActorCallOptions,
208
+ ) => Promise<ActorCallResult<View>>
209
+ >()
121
210
  const call = new Proxy(
122
211
  {},
123
212
  {
124
- get: (_target, event) =>
125
- typeof event === 'string'
126
- ? (input?: unknown, callOptions?: ActorCallOptions) =>
127
- callOverWire(fetchImpl, options.api, event, input, callOptions)
128
- : undefined,
213
+ get: (_target, event) => {
214
+ if (typeof event !== 'string') return undefined
215
+ let method = methods.get(event)
216
+ if (!method) {
217
+ method = (input, callOptions) =>
218
+ callOverWire(
219
+ fetchImpl,
220
+ options.api,
221
+ event,
222
+ input,
223
+ callOptions,
224
+ ).then(adopt)
225
+ methods.set(event, method)
226
+ }
227
+ return method
228
+ },
129
229
  },
130
230
  ) as ActorClientCall<D, View>
131
- return { call }
231
+ return { call, state }
132
232
  }