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.
- package/CHANGELOG.md +53 -0
- package/dist/actor-D_54lz_1.d.ts +310 -0
- package/dist/actor-D_54lz_1.d.ts.map +1 -0
- package/dist/actor-client.d.ts +13 -4
- package/dist/actor-client.d.ts.map +1 -1
- package/dist/actor-client.js +63 -7
- package/dist/actor-client.js.map +1 -1
- package/dist/actor-react.d.ts +5 -4
- package/dist/actor-react.d.ts.map +1 -1
- package/dist/actor-react.js +16 -2
- package/dist/actor-react.js.map +1 -1
- package/dist/actor.d.ts +2 -176
- package/dist/actor.js +13 -2
- package/dist/actor.js.map +1 -1
- package/dist/ai-server.d.ts +1 -1
- package/dist/ai-server.js +2 -2
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +1 -1
- package/dist/client-Bf6uSEAk.js +1342 -0
- package/dist/client-Bf6uSEAk.js.map +1 -0
- package/dist/client-P_NNNRM-.d.ts +243 -0
- package/dist/client-P_NNNRM-.d.ts.map +1 -0
- package/dist/client.d.ts +2 -202
- package/dist/client.js +2 -1026
- package/dist/errors-DCk6ch5n.js.map +1 -1
- package/dist/errors-DvhSXnxk.d.ts +28 -0
- package/dist/errors-DvhSXnxk.d.ts.map +1 -0
- package/dist/index.d.ts +3 -35
- package/dist/{internal-DRXJ56EI.js → internal-Dq2qYxou.js} +2 -2
- package/dist/{internal-DRXJ56EI.js.map → internal-Dq2qYxou.js.map} +1 -1
- package/dist/platform-B4TnJtWu.js +34 -0
- package/dist/platform-B4TnJtWu.js.map +1 -0
- package/dist/react.d.ts +3 -1
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +2 -1
- package/dist/react.js.map +1 -1
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +3 -2
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +2 -2
- package/dist/{server-CBET-jSz.js → server-Dkz2a84E.js} +295 -133
- package/dist/server-Dkz2a84E.js.map +1 -0
- package/dist/{server-CKY3_lbw.d.ts → server-DwPrMqHB.d.ts} +4 -2
- package/dist/server-DwPrMqHB.d.ts.map +1 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/{store-DGHeBtIQ.d.ts → store-DtDOWLSn.d.ts} +4 -5
- package/dist/{store-DGHeBtIQ.d.ts.map → store-DtDOWLSn.d.ts.map} +1 -1
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.js +1 -1
- package/dist/{store-polling-6DW7F1DT.js → store-polling-CmxUbV93.js} +56 -7
- package/dist/store-polling-CmxUbV93.js.map +1 -0
- package/dist/store-postgres.d.ts +6 -4
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +701 -53
- package/dist/store-postgres.js.map +1 -1
- package/dist/store-presence-polling-C7-XZyW9.js +94 -0
- package/dist/store-presence-polling-C7-XZyW9.js.map +1 -0
- package/dist/store-redis-http.d.ts +2 -2
- package/dist/store-redis-http.d.ts.map +1 -1
- package/dist/store-redis-http.js +184 -38
- package/dist/store-redis-http.js.map +1 -1
- package/dist/{store-redis-core-z-ykbyMg.js → store-redis-notify-BUCyXOn0.js} +491 -27
- package/dist/store-redis-notify-BUCyXOn0.js.map +1 -0
- package/dist/store-redis.d.ts +5 -13
- package/dist/store-redis.d.ts.map +1 -1
- package/dist/store-redis.js +27 -271
- package/dist/store-redis.js.map +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.js +1 -1
- package/dist/{wire--yji6mO3.js → wire-BO5wWCb1.js} +18 -16
- package/dist/wire-BO5wWCb1.js.map +1 -0
- package/docs/actors/04-routes.mdx +40 -8
- package/docs/guides/03-react.mdx +52 -10
- package/docs/guides/05-production.mdx +54 -5
- package/docs/guides/09-presence.mdx +12 -7
- package/docs/guides/10-transports.mdx +85 -22
- package/docs/reference/01-api.mdx +90 -28
- package/docs/reference/02-errors.mdx +45 -11
- package/examples/playground/package.json +1 -1
- package/package.json +1 -1
- package/src/actor-client.ts +114 -14
- package/src/actor-react.ts +18 -3
- package/src/actor.ts +16 -1
- package/src/client-errors.ts +51 -0
- package/src/client.ts +577 -175
- package/src/errors.ts +4 -9
- package/src/index.ts +1 -1
- package/src/internal.ts +1 -1
- package/src/postgres-notification-scope.ts +134 -0
- package/src/postgres-notifications.ts +244 -0
- package/src/postgres-pool.ts +65 -0
- package/src/postgres-resources.ts +185 -0
- package/src/presence-recovery.ts +120 -0
- package/src/react.ts +3 -0
- package/src/redis-http-subscriptions.ts +199 -0
- package/src/server-fetch.ts +51 -23
- package/src/server.ts +146 -19
- package/src/session-socket.ts +241 -88
- package/src/sse.ts +17 -0
- package/src/store-polling.ts +32 -12
- package/src/store-postgres.ts +292 -75
- package/src/store-presence-polling.ts +125 -0
- package/src/store-redis-core.ts +37 -30
- package/src/store-redis-http.ts +51 -45
- package/src/store-redis-notify.ts +464 -0
- package/src/store-redis.ts +9 -364
- package/src/store.ts +3 -4
- package/src/stream-activity.ts +32 -0
- package/src/wire.ts +39 -15
- package/dist/actor-shared-BACubf4x.d.ts +0 -136
- package/dist/actor-shared-BACubf4x.d.ts.map +0 -1
- package/dist/actor.d.ts.map +0 -1
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/server-CBET-jSz.js.map +0 -1
- package/dist/server-CKY3_lbw.d.ts.map +0 -1
- package/dist/store-polling-6DW7F1DT.js.map +0 -1
- package/dist/store-redis-core-z-ykbyMg.js.map +0 -1
- 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
|
-
- **
|
|
21
|
-
|
|
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
|
|
25
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
client-generated ids, so
|
|
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
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
|
678
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
|
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 `
|
|
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:
|
|
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
|
-
|
|
|
1205
|
-
| { type: 'http'; push:
|
|
1206
|
-
| { type: 'ws'; url:
|
|
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:
|
|
3
|
+
description: Stable server and client error codes, transport details, and append guarantees.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
## `A2Error`
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
`
|
|
10
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
|
114
|
-
and server code branch on identical codes. `
|
|
115
|
-
|
|
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.
|
package/package.json
CHANGED
package/src/actor-client.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
}
|