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.
- package/CHANGELOG.md +14 -0
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-react.d.ts +1 -1
- package/dist/{actor-shared-BACubf4x.d.ts → actor-shared-USo5MyuF.d.ts} +2 -2
- package/dist/{actor-shared-BACubf4x.d.ts.map → actor-shared-USo5MyuF.d.ts.map} +1 -1
- package/dist/actor.d.ts +2 -2
- package/dist/actor.js +1 -1
- package/dist/ai-server.d.ts +1 -1
- package/dist/ai-server.js +1 -1
- package/dist/ai.d.ts +1 -1
- package/dist/client.d.ts +13 -7
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +234 -54
- package/dist/client.js.map +1 -1
- package/dist/scheduler-qstash.d.ts +1 -1
- package/dist/scheduler-qstash.js +1 -1
- package/dist/scheduler-vercel.d.ts +1 -1
- package/dist/scheduler-vercel.js +1 -1
- package/dist/{server-CKY3_lbw.d.ts → server-DgCrSuhB.d.ts} +3 -1
- package/dist/server-DgCrSuhB.d.ts.map +1 -0
- package/dist/{server-CBET-jSz.js → server-DlLyvaSH.js} +135 -76
- package/dist/server-DlLyvaSH.js.map +1 -0
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1 -1
- package/docs/guides/10-transports.mdx +72 -22
- package/docs/reference/01-api.mdx +30 -12
- package/examples/playground/package.json +1 -1
- package/package.json +1 -1
- package/src/client.ts +332 -87
- package/src/server-fetch.ts +51 -23
- package/src/session-socket.ts +216 -81
- package/dist/server-CBET-jSz.js.map +0 -1
- package/dist/server-CKY3_lbw.d.ts.map +0 -1
package/src/session-socket.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The A2 socket protocol's server half (specs/a2-api.md §13): the
|
|
3
3
|
* per-socket shell — teardown, backpressure, heartbeat, the malformed
|
|
4
|
-
* budget, the in-flight bound
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* platform hands over) is re-exported as public surface.
|
|
4
|
+
* budget, and the in-flight bound. `sessionSocket` binds one session to
|
|
5
|
+
* one socket; `sessionsSocket` is the opt-in multiplexed handler. Only
|
|
6
|
+
* `A2Socket`, the structural socket the platform hands over, is public.
|
|
8
7
|
*/
|
|
9
8
|
|
|
10
9
|
import { A2Error } from './errors.ts'
|
|
@@ -256,17 +255,210 @@ export type SessionsSocketOptions = {
|
|
|
256
255
|
): A2Error | null | Promise<A2Error | null>
|
|
257
256
|
}
|
|
258
257
|
|
|
258
|
+
export type SessionSocketOptions = SessionsSocketOptions & {
|
|
259
|
+
startAfter?: number
|
|
260
|
+
}
|
|
261
|
+
|
|
259
262
|
type SocketSubscription = {
|
|
260
263
|
target: SocketSession
|
|
261
264
|
iterator: AsyncIterator<Event | PresencePatch | PresenceSnapshot>
|
|
262
265
|
stopped: boolean
|
|
263
266
|
}
|
|
264
267
|
|
|
268
|
+
const socketSubscription = (
|
|
269
|
+
target: SocketSession,
|
|
270
|
+
startAfter: number,
|
|
271
|
+
presence: boolean,
|
|
272
|
+
): SocketSubscription => ({
|
|
273
|
+
target,
|
|
274
|
+
iterator: (presence
|
|
275
|
+
? target.stream({ startAfter, presence: true })
|
|
276
|
+
: target.stream({ startAfter }))[Symbol.asyncIterator](),
|
|
277
|
+
stopped: false,
|
|
278
|
+
})
|
|
279
|
+
|
|
265
280
|
const stopSubscription = (subscription: SocketSubscription): void => {
|
|
266
281
|
subscription.stopped = true
|
|
267
282
|
void subscription.iterator.return?.()?.catch(() => {})
|
|
268
283
|
}
|
|
269
284
|
|
|
285
|
+
const pumpSubscription = async (
|
|
286
|
+
shell: SocketShell,
|
|
287
|
+
subscription: SocketSubscription,
|
|
288
|
+
frameFor: (item: Event | PresencePatch | PresenceSnapshot) => string,
|
|
289
|
+
): Promise<void> => {
|
|
290
|
+
for (;;) {
|
|
291
|
+
while (
|
|
292
|
+
!shell.torn() &&
|
|
293
|
+
!subscription.stopped &&
|
|
294
|
+
shell.buffered() > SOCKET_TIMINGS.highWaterMarkBytes
|
|
295
|
+
) {
|
|
296
|
+
// oxlint-disable-next-line no-await-in-loop -- backpressure park
|
|
297
|
+
await new Promise((wake) => setTimeout(wake, SOCKET_TIMINGS.resumePollMs))
|
|
298
|
+
}
|
|
299
|
+
if (shell.torn() || subscription.stopped) return
|
|
300
|
+
// oxlint-disable-next-line no-await-in-loop -- stream pump
|
|
301
|
+
const { value, done } = await subscription.iterator.next()
|
|
302
|
+
if (shell.torn() || subscription.stopped || done) return
|
|
303
|
+
shell.send(frameFor(value))
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const handleSocketPush = async (
|
|
308
|
+
shell: SocketShell,
|
|
309
|
+
target: SocketSession,
|
|
310
|
+
sessionId: string,
|
|
311
|
+
frame: Record<string, unknown>,
|
|
312
|
+
req: number,
|
|
313
|
+
ackSessionId: string | undefined,
|
|
314
|
+
gatePush: SessionsSocketOptions['gatePush'],
|
|
315
|
+
): Promise<void> => {
|
|
316
|
+
try {
|
|
317
|
+
const events = parsePushEvents(frame['events'])
|
|
318
|
+
const denial = (await gatePush?.(sessionId, events, undefined)) ?? null
|
|
319
|
+
if (denial !== null) {
|
|
320
|
+
shell.send(socketErrorAckFor(req, denial, ackSessionId))
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
// oxlint-disable-next-line a2/await-handler-append -- this transport append is awaited before its ack
|
|
324
|
+
const appended = await target.append(...events)
|
|
325
|
+
shell.send(socketAckFor(req, appended, ackSessionId))
|
|
326
|
+
} catch (error) {
|
|
327
|
+
shell.send(socketErrorAckFor(req, asA2Error(error), ackSessionId))
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const applySocketPresence = async (
|
|
332
|
+
shell: SocketShell,
|
|
333
|
+
target: SocketSession,
|
|
334
|
+
sessionId: string,
|
|
335
|
+
frame: Record<string, unknown>,
|
|
336
|
+
gatePush: SessionsSocketOptions['gatePush'],
|
|
337
|
+
): Promise<void> => {
|
|
338
|
+
if (typeof target.setPresence !== 'function') {
|
|
339
|
+
shell.malformedFrame('presence frame on a presence-less session')
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
let patch: ParsedPushPresence | undefined
|
|
343
|
+
try {
|
|
344
|
+
patch = parsePresenceSibling(frame)
|
|
345
|
+
} catch {
|
|
346
|
+
shell.malformedFrame('invalid presence frame')
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
if (!patch) return
|
|
350
|
+
try {
|
|
351
|
+
const denial = (await gatePush?.(sessionId, [], patch)) ?? null
|
|
352
|
+
if (denial !== null) return
|
|
353
|
+
await target.setPresence(patch)
|
|
354
|
+
} catch (error) {
|
|
355
|
+
const failure = asA2Error(error)
|
|
356
|
+
if (
|
|
357
|
+
failure.code === 'INVALID_PAYLOAD' ||
|
|
358
|
+
failure.code === 'UNKNOWN_PRESENCE_FIELD'
|
|
359
|
+
) {
|
|
360
|
+
shell.malformedFrame('invalid presence frame')
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
shell.shutdown(1011, 'internal error')
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function sessionSocket<D extends EventDefs>(
|
|
368
|
+
sessionId: string,
|
|
369
|
+
session: Pick<Session<D>, 'stream'>,
|
|
370
|
+
socket: A2Socket,
|
|
371
|
+
options?: SessionSocketOptions,
|
|
372
|
+
): void {
|
|
373
|
+
const target = session as unknown as SocketSession
|
|
374
|
+
const subscription = socketSubscription(
|
|
375
|
+
target,
|
|
376
|
+
options?.startAfter ?? 0,
|
|
377
|
+
options?.presence === true,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
const handleFrame = (frame: Record<string, unknown>): void => {
|
|
381
|
+
switch (frame['kind']) {
|
|
382
|
+
case 'push': {
|
|
383
|
+
const req = frame['req']
|
|
384
|
+
if (typeof req !== 'number') {
|
|
385
|
+
shell.malformedFrame('push frame without a numeric req')
|
|
386
|
+
return
|
|
387
|
+
}
|
|
388
|
+
if (frame['sessionId'] !== undefined) {
|
|
389
|
+
shell.send(
|
|
390
|
+
socketErrorAckFor(
|
|
391
|
+
req,
|
|
392
|
+
new A2Error(
|
|
393
|
+
'INVALID_PAYLOAD',
|
|
394
|
+
'sessionId is not allowed on a single-session socket',
|
|
395
|
+
),
|
|
396
|
+
),
|
|
397
|
+
)
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
if (shell.overCap()) {
|
|
401
|
+
shell.send(
|
|
402
|
+
socketErrorAckFor(
|
|
403
|
+
req,
|
|
404
|
+
new A2Error(
|
|
405
|
+
'STORE_UNAVAILABLE',
|
|
406
|
+
'push shed: too many in flight on this socket',
|
|
407
|
+
),
|
|
408
|
+
),
|
|
409
|
+
)
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
shell.track(
|
|
413
|
+
handleSocketPush(
|
|
414
|
+
shell,
|
|
415
|
+
target,
|
|
416
|
+
sessionId,
|
|
417
|
+
frame,
|
|
418
|
+
req,
|
|
419
|
+
undefined,
|
|
420
|
+
options?.gatePush,
|
|
421
|
+
),
|
|
422
|
+
)
|
|
423
|
+
return
|
|
424
|
+
}
|
|
425
|
+
case 'presence':
|
|
426
|
+
if (frame['sessionId'] !== undefined) {
|
|
427
|
+
shell.malformedFrame(
|
|
428
|
+
'sessionId is not allowed on a single-session socket',
|
|
429
|
+
)
|
|
430
|
+
return
|
|
431
|
+
}
|
|
432
|
+
if (!shell.overCap()) {
|
|
433
|
+
shell.track(
|
|
434
|
+
applySocketPresence(
|
|
435
|
+
shell,
|
|
436
|
+
target,
|
|
437
|
+
sessionId,
|
|
438
|
+
frame,
|
|
439
|
+
options?.gatePush,
|
|
440
|
+
),
|
|
441
|
+
)
|
|
442
|
+
}
|
|
443
|
+
return
|
|
444
|
+
default:
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const shell = socketShell(
|
|
450
|
+
socket,
|
|
451
|
+
() => stopSubscription(subscription),
|
|
452
|
+
handleFrame,
|
|
453
|
+
options?.deadline,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
void pumpSubscription(shell, subscription, socketFrameFor).then(
|
|
457
|
+
() => shell.shutdown(1000),
|
|
458
|
+
() => shell.shutdown(1011, 'stream failed'),
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
|
|
270
462
|
const parseSubscribeEntries = (
|
|
271
463
|
value: unknown,
|
|
272
464
|
): Array<{ id: string; index: number }> | null => {
|
|
@@ -308,38 +500,14 @@ export function sessionsSocket<D extends EventDefs>(
|
|
|
308
500
|
if (subscriptions.get(id) === subscription) subscriptions.delete(id)
|
|
309
501
|
}
|
|
310
502
|
|
|
311
|
-
const pump = async (
|
|
312
|
-
id: string,
|
|
313
|
-
subscription: SocketSubscription,
|
|
314
|
-
): Promise<void> => {
|
|
315
|
-
for (;;) {
|
|
316
|
-
for (;;) {
|
|
317
|
-
if (
|
|
318
|
-
shell.torn() ||
|
|
319
|
-
subscription.stopped ||
|
|
320
|
-
shell.buffered() <= SOCKET_TIMINGS.highWaterMarkBytes
|
|
321
|
-
)
|
|
322
|
-
break
|
|
323
|
-
// oxlint-disable-next-line no-await-in-loop -- backpressure park
|
|
324
|
-
await new Promise((wake) =>
|
|
325
|
-
setTimeout(wake, SOCKET_TIMINGS.resumePollMs),
|
|
326
|
-
)
|
|
327
|
-
}
|
|
328
|
-
if (shell.torn() || subscription.stopped) return
|
|
329
|
-
// oxlint-disable-next-line no-await-in-loop -- stream pump
|
|
330
|
-
const { value, done } = await subscription.iterator.next()
|
|
331
|
-
if (shell.torn() || subscription.stopped) return
|
|
332
|
-
if (done) return
|
|
333
|
-
shell.send(socketFrameFor(value, id))
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
503
|
const runSubscription = async (
|
|
338
504
|
id: string,
|
|
339
505
|
subscription: SocketSubscription,
|
|
340
506
|
): Promise<void> => {
|
|
341
507
|
try {
|
|
342
|
-
await
|
|
508
|
+
await pumpSubscription(shell, subscription, (item) =>
|
|
509
|
+
socketFrameFor(item, id),
|
|
510
|
+
)
|
|
343
511
|
if (shell.torn() || subscription.stopped) return
|
|
344
512
|
retire(id, subscription)
|
|
345
513
|
shell.send(socketUnsubscribedFor(id))
|
|
@@ -371,16 +539,7 @@ export function sessionsSocket<D extends EventDefs>(
|
|
|
371
539
|
const target = session as unknown as SocketSession
|
|
372
540
|
const existing = subscriptions.get(id)
|
|
373
541
|
if (existing) stopSubscription(existing)
|
|
374
|
-
const
|
|
375
|
-
presence
|
|
376
|
-
? target.stream({ startAfter: index, presence: true })
|
|
377
|
-
: target.stream({ startAfter: index })
|
|
378
|
-
)[Symbol.asyncIterator]()
|
|
379
|
-
const subscription: SocketSubscription = {
|
|
380
|
-
target,
|
|
381
|
-
iterator,
|
|
382
|
-
stopped: false,
|
|
383
|
-
}
|
|
542
|
+
const subscription = socketSubscription(target, index, presence)
|
|
384
543
|
subscriptions.set(id, subscription)
|
|
385
544
|
shell.send(socketSubscribedFor(id))
|
|
386
545
|
void runSubscription(id, subscription)
|
|
@@ -415,19 +574,15 @@ export function sessionsSocket<D extends EventDefs>(
|
|
|
415
574
|
)
|
|
416
575
|
return
|
|
417
576
|
}
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
shell.send(socketAckFor(req, appended, sessionId))
|
|
428
|
-
} catch (err) {
|
|
429
|
-
shell.send(socketErrorAckFor(req, asA2Error(err), sessionId))
|
|
430
|
-
}
|
|
577
|
+
await handleSocketPush(
|
|
578
|
+
shell,
|
|
579
|
+
subscription.target,
|
|
580
|
+
sessionId,
|
|
581
|
+
frame,
|
|
582
|
+
req,
|
|
583
|
+
sessionId,
|
|
584
|
+
options?.gatePush,
|
|
585
|
+
)
|
|
431
586
|
}
|
|
432
587
|
|
|
433
588
|
const handlePresence = async (
|
|
@@ -437,33 +592,13 @@ export function sessionsSocket<D extends EventDefs>(
|
|
|
437
592
|
const subscription = subscriptions.get(sessionId)
|
|
438
593
|
// An unsubscribe race, not malformedness — the plane repaints.
|
|
439
594
|
if (subscription === undefined) return
|
|
440
|
-
|
|
441
|
-
shell
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
} catch {
|
|
448
|
-
shell.malformedFrame('invalid presence frame')
|
|
449
|
-
return
|
|
450
|
-
}
|
|
451
|
-
if (!patch) return
|
|
452
|
-
try {
|
|
453
|
-
const denial = (await options?.gatePush?.(sessionId, [], patch)) ?? null
|
|
454
|
-
if (denial !== null) return
|
|
455
|
-
await subscription.target.setPresence(patch)
|
|
456
|
-
} catch (error) {
|
|
457
|
-
const failure = asA2Error(error)
|
|
458
|
-
if (
|
|
459
|
-
failure.code === 'INVALID_PAYLOAD' ||
|
|
460
|
-
failure.code === 'UNKNOWN_PRESENCE_FIELD'
|
|
461
|
-
) {
|
|
462
|
-
shell.malformedFrame('invalid presence frame')
|
|
463
|
-
return
|
|
464
|
-
}
|
|
465
|
-
shell.shutdown(1011, 'internal error')
|
|
466
|
-
}
|
|
595
|
+
await applySocketPresence(
|
|
596
|
+
shell,
|
|
597
|
+
subscription.target,
|
|
598
|
+
sessionId,
|
|
599
|
+
frame,
|
|
600
|
+
options?.gatePush,
|
|
601
|
+
)
|
|
467
602
|
}
|
|
468
603
|
|
|
469
604
|
const handleFrame = (frame: Record<string, unknown>): void => {
|