experimental-a2 0.10.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 +39 -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 -208
- package/dist/client.js +2 -1206
- 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-DlLyvaSH.js → server-Dkz2a84E.js} +165 -62
- package/dist/server-Dkz2a84E.js.map +1 -0
- package/dist/{server-DgCrSuhB.d.ts → server-DwPrMqHB.d.ts} +2 -2
- package/dist/{server-DgCrSuhB.d.ts.map → server-DwPrMqHB.d.ts.map} +1 -1
- 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 +15 -2
- package/docs/reference/01-api.mdx +60 -16
- 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 +253 -96
- 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.ts +146 -19
- package/src/session-socket.ts +25 -7
- 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-USo5MyuF.d.ts +0 -136
- package/dist/actor-shared-USo5MyuF.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-DlLyvaSH.js.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
|
@@ -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`
|
|
@@ -793,25 +794,27 @@ transition and the destination should adopt the same optimistic session.
|
|
|
793
794
|
| --------------- | ------------------------------------------------------ |
|
|
794
795
|
| `sessionId` | which session to subscribe to |
|
|
795
796
|
| `initialState` | server-rendered state |
|
|
796
|
-
| `initialIndex` | the fold's frontier, where
|
|
797
|
+
| `initialIndex` | the fold's frontier, where a new session starts streaming |
|
|
797
798
|
| `initialEvents` | optional earlier raw events for a history UI |
|
|
798
799
|
| `participant` | overrides the client's `participant`; one of the two is required to call `setPresence` |
|
|
799
800
|
|
|
800
801
|
Opens the stream on mount, closes it on unmount, reconnects with
|
|
801
|
-
backoff from
|
|
802
|
+
backoff from its last streamed event. `participant` binds at the session's
|
|
802
803
|
first resolution; a changed provider prop is ignored for the session's
|
|
803
804
|
runtime lifetime.
|
|
804
805
|
|
|
805
806
|
`initialState` and `initialIndex` are the complete server-to-client handoff.
|
|
806
|
-
|
|
807
|
-
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
|
|
808
809
|
`events` feed for a UI that needs entries from before that frontier. It does
|
|
809
|
-
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.
|
|
810
813
|
|
|
811
814
|
### `useSession()`
|
|
812
815
|
|
|
813
816
|
```ts
|
|
814
|
-
const { state, push, events, index, loadHistory, history, connection,
|
|
817
|
+
const { state, push, reconnect, events, index, loadHistory, history, connection,
|
|
815
818
|
presence, setPresence } = useSession()
|
|
816
819
|
```
|
|
817
820
|
|
|
@@ -819,7 +822,7 @@ const { state, push, events, index, loadHistory, history, connection,
|
|
|
819
822
|
shared reducer. `events` is the raw event feed: observed, explicitly
|
|
820
823
|
seeded, or backscrolled. Unless `initialEvents` seeds earlier entries or
|
|
821
824
|
`loadHistory` fetches them, it begins after `initialIndex`. `index` is
|
|
822
|
-
the
|
|
825
|
+
the latest server-confirmed state index, the `lastSeenIndex` for
|
|
823
826
|
[cancellation](/guides/cancellation).
|
|
824
827
|
|
|
825
828
|
`loadHistory({ before?, limit? })` backscrolls: it fetches a bounded
|
|
@@ -828,10 +831,9 @@ query parameters) and merges it into `events`, deduped, ordered, and
|
|
|
828
831
|
shared across every handle of the session. It resolves with the events
|
|
829
832
|
in the requested range. Defaults walk backward 50 at a time from the
|
|
830
833
|
oldest loaded event; `before` is an exclusive upper bound. After a
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
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
|
|
835
837
|
serialize per session and already-loaded ranges are not refetched.
|
|
836
838
|
`history` is the progress: `{ loading, complete, oldestLoaded }`, where
|
|
837
839
|
`complete` means the feed reaches index 1 (or the log is empty). The `ws` api has no history lane; there `loadHistory`
|
|
@@ -856,7 +858,7 @@ contract; it still never folds presence). `setPresence` requires a
|
|
|
856
858
|
[Presence](/guides/presence).
|
|
857
859
|
|
|
858
860
|
`push` appends optimistically: validated locally, rolled back on
|
|
859
|
-
rejection, retried
|
|
861
|
+
rejection, retried for `STORE_UNAVAILABLE` and transport failures. Awaiting it gives the
|
|
860
862
|
server ack; the same result carries `confirmed`, a lazy promise for
|
|
861
863
|
the later moment when the live stream has delivered the batch back and
|
|
862
864
|
the view shows server truth:
|
|
@@ -875,18 +877,31 @@ only for the previous call's ack or final rejection, not for `confirmed`.
|
|
|
875
877
|
Other session IDs and clients remain concurrent; they converge on the log's
|
|
876
878
|
durable order.
|
|
877
879
|
|
|
878
|
-
A rejected push rejects both promises with the same `
|
|
880
|
+
A rejected push rejects both promises with the same `A2ClientError`;
|
|
879
881
|
`confirmed` is materialized only when accessed, so ignoring it costs
|
|
880
882
|
nothing. It also reads as intent: `await push(...).confirmed` is
|
|
881
883
|
"continue once this is server truth".
|
|
882
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
|
+
|
|
883
894
|
`connection` is a discriminated union; impossible states are
|
|
884
895
|
unrepresentable (an `error` only exists while disconnected):
|
|
885
896
|
|
|
886
897
|
```ts
|
|
898
|
+
// in a client module:
|
|
899
|
+
import type { A2ClientError } from 'experimental-a2/client'
|
|
900
|
+
|
|
887
901
|
type Connection =
|
|
888
902
|
| { status: 'idle' }
|
|
889
|
-
| { status: 'connecting'; reconnects: number; error:
|
|
903
|
+
| { status: 'connecting'; reconnects: number; error: A2ClientError | null }
|
|
904
|
+
| { status: 'paused'; reconnects: number; error: A2ClientError | null }
|
|
890
905
|
| { status: 'live'; reconnects: number }
|
|
891
906
|
| { status: 'closed' }
|
|
892
907
|
```
|
|
@@ -1206,6 +1221,11 @@ createClient(options: {
|
|
|
1206
1221
|
api: ClientApi
|
|
1207
1222
|
gcTime?: number // idle session lifetime; 5 minutes by default
|
|
1208
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
|
|
1209
1229
|
}): A2Client
|
|
1210
1230
|
|
|
1211
1231
|
type ClientApi =
|
|
@@ -1217,6 +1237,30 @@ type ClientApi =
|
|
|
1217
1237
|
type SessionUrl = string | ((sessionId: string) => string)
|
|
1218
1238
|
```
|
|
1219
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
|
+
|
|
1220
1264
|
A `SessionUrl` function runs for each HTTP request or WebSocket connection.
|
|
1221
1265
|
Use it for session-specific paths. A2 still adds its `sessionId` and read-bound
|
|
1222
1266
|
query parameters. The function does not authorize that ID. Bind any path scope
|
|
@@ -1229,7 +1273,7 @@ subscription with frontier resume and reconnection, the optimistic push
|
|
|
1229
1273
|
queue with ack/rollback, and the local fold. `client.session(id, {
|
|
1230
1274
|
initialState?, initialIndex?, initialEvents?, participant? })` returns a
|
|
1231
1275
|
handle with `getSnapshot()`/`subscribe()` (the `useSyncExternalStore`
|
|
1232
|
-
contract), `push()`, `loadHistory()`, `connect()`, and `close()`.
|
|
1276
|
+
contract), `push()`, `loadHistory()`, `connect()`, `reconnect()`, and `close()`.
|
|
1233
1277
|
`connect()` takes a lease on the live stream and returns its release:
|
|
1234
1278
|
leases refcount per handle (the first connects, releasing the last
|
|
1235
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
|
}
|
package/src/actor-react.ts
CHANGED
|
@@ -41,6 +41,7 @@ import type { A2Client, Connection } from './client.ts'
|
|
|
41
41
|
import type { ContractEvent, EventDefs, PresenceDefs } from './contract.ts'
|
|
42
42
|
import type { Reducer } from './reducer.ts'
|
|
43
43
|
import { useSession } from './react.ts'
|
|
44
|
+
import { useMemo } from 'react'
|
|
44
45
|
|
|
45
46
|
/** The server-to-client handoff: `await def.actor(id).state()`. */
|
|
46
47
|
export type ActorSnapshot<S> = { state: S; index: number }
|
|
@@ -66,13 +67,14 @@ type PresenceOf<D extends AnyActorDefinition> = D extends {
|
|
|
66
67
|
: never
|
|
67
68
|
|
|
68
69
|
export type UseActorResult<D extends AnyActorDefinition, View> = {
|
|
69
|
-
/** The
|
|
70
|
+
/** The live view, advanced by call answers, refreshes, and stream commits. */
|
|
70
71
|
state: View
|
|
71
|
-
/** The
|
|
72
|
+
/** The latest adopted state index. */
|
|
72
73
|
index: number
|
|
73
74
|
connection: Connection
|
|
74
75
|
/** Typed calls over POST — the response is the answer. */
|
|
75
76
|
call: ActorClientCall<D, View>
|
|
77
|
+
refresh: () => Promise<ActorSnapshot<View>>
|
|
76
78
|
/** The state-commit feed this browser has observed. */
|
|
77
79
|
events: ContractEvent<EventDefs>[]
|
|
78
80
|
/**
|
|
@@ -128,12 +130,25 @@ export function useActor<D extends AnyActorDefinition, View = ActorStateOf<D>>(
|
|
|
128
130
|
? {}
|
|
129
131
|
: { participant: options.participant }),
|
|
130
132
|
})
|
|
131
|
-
const { call } =
|
|
133
|
+
const { call, state: refresh } = useMemo(
|
|
134
|
+
() =>
|
|
135
|
+
createActorClient<D, View>({
|
|
136
|
+
api,
|
|
137
|
+
onSnapshot: ({ state, index }) => {
|
|
138
|
+
client.session(options.id, {
|
|
139
|
+
initialState: state,
|
|
140
|
+
initialIndex: index,
|
|
141
|
+
})
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
[api, client, options.id],
|
|
145
|
+
)
|
|
132
146
|
return {
|
|
133
147
|
state: session.state as View,
|
|
134
148
|
index: session.index,
|
|
135
149
|
connection: session.connection,
|
|
136
150
|
call,
|
|
151
|
+
refresh,
|
|
137
152
|
events: session.events,
|
|
138
153
|
presence: session.presence as ActorPresenceMap<PresenceOf<D>>,
|
|
139
154
|
setPresence: session.setPresence as (
|
package/src/actor.ts
CHANGED
|
@@ -169,9 +169,10 @@ export type ActorOptions<D extends ActorProtocol> = {
|
|
|
169
169
|
/**
|
|
170
170
|
* One HTTP operation `handle.fetch` is about to serve — the actor
|
|
171
171
|
* mirror of core's `A2Operation`. `authorize` sees it before any
|
|
172
|
-
* write or subscription; returning `false` answers 403.
|
|
172
|
+
* read, write, or subscription; returning `false` answers 403.
|
|
173
173
|
*/
|
|
174
174
|
export type ActorOperation =
|
|
175
|
+
| { readonly type: 'state'; readonly id: string }
|
|
175
176
|
| {
|
|
176
177
|
readonly type: 'stream'
|
|
177
178
|
readonly id: string
|
|
@@ -736,6 +737,19 @@ export function actor<D extends ActorProtocol>(
|
|
|
736
737
|
|
|
737
738
|
if (request.method === 'GET') {
|
|
738
739
|
const url = new URL(request.url)
|
|
740
|
+
if (url.searchParams.has('snapshot')) {
|
|
741
|
+
if (!(await authorized({ type: 'state', id }))) return forbidden()
|
|
742
|
+
const result = await readState()
|
|
743
|
+
return Response.json(
|
|
744
|
+
{
|
|
745
|
+
state: fetchOptions?.view
|
|
746
|
+
? applyView(fetchOptions.view, result.state)
|
|
747
|
+
: result.state,
|
|
748
|
+
index: result.index,
|
|
749
|
+
},
|
|
750
|
+
{ headers: { 'cache-control': 'private, no-store' } },
|
|
751
|
+
)
|
|
752
|
+
}
|
|
739
753
|
const raw = Number(url.searchParams.get('index'))
|
|
740
754
|
const startAfter = Number.isSafeInteger(raw) && raw >= 0 ? raw : 0
|
|
741
755
|
if (!(await authorized({ type: 'stream', id, startAfter }))) {
|
|
@@ -851,6 +865,7 @@ export function actor<D extends ActorProtocol>(
|
|
|
851
865
|
index: result.index,
|
|
852
866
|
}
|
|
853
867
|
: result,
|
|
868
|
+
{ headers: { 'cache-control': 'private, no-store' } },
|
|
854
869
|
)
|
|
855
870
|
} catch (error) {
|
|
856
871
|
if (error instanceof ActorRefusedError) {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { A2Error, type A2ErrorCode } from './errors.ts'
|
|
2
|
+
|
|
3
|
+
export type A2ClientErrorOptions = {
|
|
4
|
+
details?: unknown
|
|
5
|
+
cause?: unknown
|
|
6
|
+
status?: number
|
|
7
|
+
closeCode?: number
|
|
8
|
+
closeReason?: string
|
|
9
|
+
phase?: 'stream' | 'subscribe'
|
|
10
|
+
timeoutMs?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class A2ClientError extends A2Error {
|
|
14
|
+
readonly status: number | undefined
|
|
15
|
+
readonly closeCode: number | undefined
|
|
16
|
+
readonly closeReason: string | undefined
|
|
17
|
+
readonly phase: 'stream' | 'subscribe' | undefined
|
|
18
|
+
readonly timeoutMs: number | undefined
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
code: A2ErrorCode,
|
|
22
|
+
message: string,
|
|
23
|
+
options?: A2ClientErrorOptions,
|
|
24
|
+
) {
|
|
25
|
+
super(code, message, options)
|
|
26
|
+
this.name = 'A2ClientError'
|
|
27
|
+
this.status = options?.status
|
|
28
|
+
this.closeCode = options?.closeCode
|
|
29
|
+
this.closeReason = options?.closeReason
|
|
30
|
+
this.phase = options?.phase
|
|
31
|
+
this.timeoutMs = options?.timeoutMs
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function asClientError(
|
|
36
|
+
error: unknown,
|
|
37
|
+
options?: { code?: A2ErrorCode },
|
|
38
|
+
): A2ClientError {
|
|
39
|
+
if (error instanceof A2ClientError) return error
|
|
40
|
+
if (error instanceof A2Error) {
|
|
41
|
+
return new A2ClientError(error.code, error.message, {
|
|
42
|
+
details: error.details,
|
|
43
|
+
cause: error,
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
return new A2ClientError(
|
|
47
|
+
options?.code ?? 'UNKNOWN',
|
|
48
|
+
error instanceof Error ? error.message : 'client operation failed',
|
|
49
|
+
{ cause: error },
|
|
50
|
+
)
|
|
51
|
+
}
|