experimental-a2 0.9.0 → 0.10.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.
@@ -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,58 @@ 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 and leaves other sessions connected. On either socket shape, a
168
+ denied event push receives a non-retryable `FORBIDDEN` ack. Denied
169
+ presence is silently dropped because presence is fire-and-forget and
170
+ repaints on the next update.
122
171
 
123
172
  A2 reads the platform's ambient invocation deadline and closes the
124
173
  socket cleanly before it. `waitUntil` and deadline discovery remain
@@ -148,10 +197,11 @@ shared above the wire seam.
148
197
 
149
198
  ## Lifecycle
150
199
 
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.
200
+ When a socket closes, the client reconnects with backoff at the current
201
+ frontier, receives a fresh presence snapshot, and re-sends its own latest
202
+ presence values. A multiplexed socket re-subscribes every active session
203
+ at its own frontier. Event pushes keep their client-generated ids, so
204
+ retry remains idempotent.
155
205
 
156
206
  SSE and WebSocket are transport choices, not different consistency
157
207
  models. Both resume from the durable event frontier and treat presence
@@ -199,6 +199,7 @@ server.fetch(
199
199
  upgradeWebSocket?: (
200
200
  attach: (socket: A2Socket) => void,
201
201
  ) => Response | Promise<Response>
202
+ multiplexWebSocket?: boolean
202
203
  },
203
204
  ): Promise<Response>
204
205
 
@@ -307,13 +308,20 @@ export function GET(request: Request): Promise<Response> {
307
308
  }
308
309
  ```
309
310
 
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.
311
+ By default, the `sessionId` and `index` query parameters bind one session to the
312
+ socket. A2 authorizes the stream before upgrading. Frames on that socket cannot
313
+ select another session.
314
+
315
+ Set `multiplexWebSocket: true` here and `multiplex: true` in the client's `ws`
316
+ API to share one socket.
317
+ Authenticate the physical WebSocket GET before calling `server.fetch`. Validate
318
+ `Origin` too when browser cookies authenticate it; WebSocket handshakes do not
319
+ use a CORS preflight. Use `authorize` for each session subscribe and each event
320
+ or presence push. Only enable multiplexing when that request context may access
321
+ every session accepted by `authorize`. A denied subscribe receives
322
+ `unsubscribed` and does not affect other sessions. A denied event push receives
323
+ a `FORBIDDEN` ack. Denied presence is silently dropped because it is
324
+ fire-and-forget.
317
325
 
318
326
  SSE and WebSocket lifetimes use the platform's ambient invocation deadline.
319
327
  A2 also uses the platform's ambient `waitUntil` capability for background work.
@@ -674,8 +682,8 @@ session.stream(options: {
674
682
 
675
683
  A live feed of the session's events. `startAfter` is a non-negative safe integer;
676
684
  `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.
685
+ Server-side only; `server.fetch` exposes it over SSE and WebSocket. Subscribing
686
+ never dispatches handlers.
679
687
 
680
688
  With `presence: true`, the feed yields one `PresenceSnapshot` first:
681
689
  `{ snapshot }`, the current pruned map with each field's own `value`,
@@ -1201,11 +1209,21 @@ createClient(options: {
1201
1209
  }): A2Client
1202
1210
 
1203
1211
  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
1212
+ | SessionUrl // one route: GET SSE stream + POST push
1213
+ | { type: 'http'; push: SessionUrl; stream: SessionUrl }
1214
+ | { type: 'ws'; url: SessionUrl; multiplex?: false }
1215
+ | { type: 'ws'; url: string; multiplex: true }
1216
+
1217
+ type SessionUrl = string | ((sessionId: string) => string)
1207
1218
  ```
1208
1219
 
1220
+ A `SessionUrl` function runs for each HTTP request or WebSocket connection.
1221
+ Use it for session-specific paths. A2 still adds its `sessionId` and read-bound
1222
+ query parameters. The function does not authorize that ID. Bind any path scope
1223
+ or URL token to `operation.sessionId` in `authorize`; keep URL tokens short-lived
1224
+ because URLs can appear in infrastructure logs. A multiplexed WebSocket URL is
1225
+ a string because its connection carries more than one session.
1226
+
1209
1227
  The framework-agnostic session client `experimental-a2/react` is built on: the SSE
1210
1228
  subscription with frontier resume and reconnection, the optimistic push
1211
1229
  queue with ack/rollback, and the local fold. `client.session(id, {
@@ -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.10.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.10.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",