experimental-a2 0.8.1 → 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.
Files changed (98) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/actor-client.d.ts +46 -0
  3. package/dist/actor-client.d.ts.map +1 -0
  4. package/dist/actor-client.js +54 -0
  5. package/dist/actor-client.js.map +1 -0
  6. package/dist/actor-react.d.ts +54 -0
  7. package/dist/actor-react.d.ts.map +1 -0
  8. package/dist/actor-react.js +79 -0
  9. package/dist/actor-react.js.map +1 -0
  10. package/dist/actor-shared-DI7J5upy.js +127 -0
  11. package/dist/actor-shared-DI7J5upy.js.map +1 -0
  12. package/dist/actor-shared-USo5MyuF.d.ts +136 -0
  13. package/dist/actor-shared-USo5MyuF.d.ts.map +1 -0
  14. package/dist/actor.browser.d.ts +1 -0
  15. package/dist/actor.browser.js +13 -0
  16. package/dist/actor.browser.js.map +1 -0
  17. package/dist/actor.d.ts +176 -0
  18. package/dist/actor.d.ts.map +1 -0
  19. package/dist/actor.js +437 -0
  20. package/dist/actor.js.map +1 -0
  21. package/dist/ai-server.d.ts +2 -2
  22. package/dist/ai-server.js +2 -2
  23. package/dist/ai.d.ts +2 -2
  24. package/dist/client.d.ts +14 -8
  25. package/dist/client.d.ts.map +1 -1
  26. package/dist/client.js +238 -58
  27. package/dist/client.js.map +1 -1
  28. package/dist/{errors-BQuJpe82.js → errors-DCk6ch5n.js} +16 -2
  29. package/dist/{errors-BQuJpe82.js.map → errors-DCk6ch5n.js.map} +1 -1
  30. package/dist/{idempotent-replay-DuqEkYA7.js → idempotent-replay-DVOlyYbx.js} +2 -2
  31. package/dist/{idempotent-replay-DuqEkYA7.js.map → idempotent-replay-DVOlyYbx.js.map} +1 -1
  32. package/dist/index.d.ts +16 -3
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +2 -2
  35. package/dist/react.d.ts +1 -1
  36. package/dist/{contract-jIfaR085.d.ts → reducer-DJKWm3cp.d.ts} +39 -39
  37. package/dist/reducer-DJKWm3cp.d.ts.map +1 -0
  38. package/dist/scheduler-qstash.d.ts +2 -2
  39. package/dist/scheduler-qstash.js +2 -2
  40. package/dist/scheduler-vercel.d.ts +2 -2
  41. package/dist/scheduler-vercel.js +1 -1
  42. package/dist/{server-DjPhHnbI.d.ts → server-DgCrSuhB.d.ts} +5 -3
  43. package/dist/server-DgCrSuhB.d.ts.map +1 -0
  44. package/dist/{server-B2XNevQA.js → server-DlLyvaSH.js} +140 -81
  45. package/dist/server-DlLyvaSH.js.map +1 -0
  46. package/dist/server.d.ts +3 -3
  47. package/dist/server.js +1 -1
  48. package/dist/{store-RJO35BMj.d.ts → store-DGHeBtIQ.d.ts} +2 -2
  49. package/dist/{store-RJO35BMj.d.ts.map → store-DGHeBtIQ.d.ts.map} +1 -1
  50. package/dist/store-memory.d.ts +1 -1
  51. package/dist/store-memory.js +2 -2
  52. package/dist/store-postgres.d.ts +1 -1
  53. package/dist/store-postgres.js +2 -2
  54. package/dist/{store-redis-core-DT01r4GZ.js → store-redis-core-z-ykbyMg.js} +3 -3
  55. package/dist/{store-redis-core-DT01r4GZ.js.map → store-redis-core-z-ykbyMg.js.map} +1 -1
  56. package/dist/store-redis-http.d.ts +1 -1
  57. package/dist/store-redis-http.js +2 -2
  58. package/dist/store-redis.d.ts +1 -1
  59. package/dist/store-redis.js +2 -2
  60. package/dist/store-sqlite.d.ts +1 -1
  61. package/dist/store-sqlite.js +2 -2
  62. package/dist/{wire-B6te_wns.js → wire--yji6mO3.js} +2 -2
  63. package/dist/{wire-B6te_wns.js.map → wire--yji6mO3.js.map} +1 -1
  64. package/docs/actors/01-introduction.mdx +189 -0
  65. package/docs/actors/02-concurrency.mdx +154 -0
  66. package/docs/actors/03-timers.mdx +120 -0
  67. package/docs/actors/04-routes.mdx +352 -0
  68. package/docs/actors/meta.ts +1 -0
  69. package/docs/concepts/meta.ts +1 -0
  70. package/docs/guides/10-transports.mdx +72 -22
  71. package/docs/guides/meta.ts +1 -0
  72. package/docs/index.mdx +3 -0
  73. package/docs/reference/01-api.mdx +30 -12
  74. package/docs/reference/02-errors.mdx +33 -0
  75. package/docs/reference/meta.ts +1 -0
  76. package/examples/playground/app/page.tsx +10 -1
  77. package/examples/playground/app/vault/[vaultId]/route.ts +19 -0
  78. package/examples/playground/app/vault/page.tsx +12 -0
  79. package/examples/playground/app/vault/server.ts +9 -0
  80. package/examples/playground/app/vault/vault-client.tsx +124 -0
  81. package/examples/playground/app/vault/vault.test.ts +147 -0
  82. package/examples/playground/app/vault/vault.ts +119 -0
  83. package/examples/playground/package.json +1 -1
  84. package/package.json +7 -1
  85. package/src/actor-client.ts +132 -0
  86. package/src/actor-react.ts +143 -0
  87. package/src/actor-shared.ts +356 -0
  88. package/src/actor.browser.ts +12 -0
  89. package/src/actor.ts +914 -0
  90. package/src/client.ts +341 -88
  91. package/src/errors.ts +15 -0
  92. package/src/index.ts +1 -1
  93. package/src/server-fetch.ts +51 -23
  94. package/src/server.ts +13 -3
  95. package/src/session-socket.ts +216 -81
  96. package/dist/contract-jIfaR085.d.ts.map +0 -1
  97. package/dist/server-B2XNevQA.js.map +0 -1
  98. package/dist/server-DjPhHnbI.d.ts.map +0 -1
package/src/client.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  import { A2Error } from './errors.ts'
18
18
  import {
19
19
  PRESENCE_TIMINGS,
20
+ RESERVED_PARTICIPANT_IDS,
20
21
  STREAM_TIMINGS,
21
22
  nullProtoRecord,
22
23
  } from './internal.ts'
@@ -216,16 +217,21 @@ export type A2Client<
216
217
  }
217
218
 
218
219
  /**
219
- * The wire the client rides. The string form is one route serving GET
220
+ * The wire the client rides. The first form is one route serving GET
220
221
  * (SSE stream) + POST (push). `http` splits the verbs across two
221
222
  * routes, for when platform duration limits differ per verb. `ws`
222
- * rides everything — every session of this client — over one
223
- * multiplexed WebSocket, served by `sessionsSocket`.
223
+ * rides everything over WebSocket, one socket per session by default.
224
+ * Multiplexing every session of the client is an explicit opt-in.
224
225
  */
226
+ export type SessionUrl = string | ((sessionId: string) => string)
227
+
225
228
  export type ClientApi =
226
- | string // one route: GET SSE stream + POST push
227
- | { type: 'http'; push: string; stream: string } // split routes
228
- | { type: 'ws'; url: string } // one socket, all sessions, both directions
229
+ | SessionUrl // one route: GET SSE stream + POST push
230
+ | { type: 'http'; push: SessionUrl; stream: SessionUrl } // split routes
231
+ /** One socket per session. */
232
+ | { type: 'ws'; url: SessionUrl; multiplex?: false }
233
+ /** One socket for every session of this client. */
234
+ | { type: 'ws'; url: string; multiplex: true }
229
235
 
230
236
  /**
231
237
  * The socket surface the `ws` transport drives — satisfied structurally
@@ -343,17 +349,26 @@ type Transport = {
343
349
  awaitReady?(sessionId: string, maxWaitMs: number): Promise<void>
344
350
  }
345
351
 
352
+ const sessionUrl = (url: SessionUrl, sessionId: string): string => {
353
+ const resolved = typeof url === 'function' ? url(sessionId) : url
354
+ if (typeof resolved !== 'string') {
355
+ throw new TypeError('a session URL function must return a string')
356
+ }
357
+ return resolved
358
+ }
359
+
346
360
  const httpTransport = (
347
- routes: { push: string; stream: string },
361
+ routes: { push: SessionUrl; stream: SessionUrl },
348
362
  fetchImpl: typeof globalThis.fetch,
349
363
  // Contracts that declare no presence skip presence frames unparsed,
350
364
  // exactly like unknown named frames.
351
365
  presence: boolean,
352
366
  ): Transport => ({
353
367
  async *connect({ sessionId, startAfter, signal }) {
354
- const sep = routes.stream.includes('?') ? '&' : '?'
368
+ const streamUrl = sessionUrl(routes.stream, sessionId)
369
+ const sep = streamUrl.includes('?') ? '&' : '?'
355
370
  const res = await fetchImpl(
356
- `${routes.stream}${sep}sessionId=${encodeURIComponent(sessionId)}&index=${startAfter}`,
371
+ `${streamUrl}${sep}sessionId=${encodeURIComponent(sessionId)}&index=${startAfter}`,
357
372
  { headers: { accept: 'text/event-stream' }, signal },
358
373
  )
359
374
  if (!res.ok || !res.body) {
@@ -412,7 +427,7 @@ const httpTransport = (
412
427
  },
413
428
 
414
429
  async push(body) {
415
- const res = await fetchImpl(routes.push, {
430
+ const res = await fetchImpl(sessionUrl(routes.push, body.sessionId), {
416
431
  method: 'POST',
417
432
  headers: { 'content-type': 'application/json' },
418
433
  body: JSON.stringify(body),
@@ -434,9 +449,10 @@ const httpTransport = (
434
449
  },
435
450
 
436
451
  async history({ sessionId, gte, lte }) {
437
- const sep = routes.stream.includes('?') ? '&' : '?'
452
+ const streamUrl = sessionUrl(routes.stream, sessionId)
453
+ const sep = streamUrl.includes('?') ? '&' : '?'
438
454
  const res = await fetchImpl(
439
- `${routes.stream}${sep}sessionId=${encodeURIComponent(sessionId)}&gte=${gte}&lte=${lte}`,
455
+ `${streamUrl}${sep}sessionId=${encodeURIComponent(sessionId)}&gte=${gte}&lte=${lte}`,
440
456
  { headers: { accept: 'application/json' } },
441
457
  )
442
458
  if (!res.ok) {
@@ -458,11 +474,14 @@ const httpTransport = (
458
474
  sendPresence(envelope) {
459
475
  void (async () => {
460
476
  try {
461
- const res = await fetchImpl(routes.push, {
462
- method: 'POST',
463
- headers: { 'content-type': 'application/json' },
464
- body: JSON.stringify(envelope),
465
- })
477
+ const res = await fetchImpl(
478
+ sessionUrl(routes.push, envelope.sessionId),
479
+ {
480
+ method: 'POST',
481
+ headers: { 'content-type': 'application/json' },
482
+ body: JSON.stringify(envelope),
483
+ },
484
+ )
466
485
  await res.arrayBuffer()
467
486
  } catch {
468
487
  // Dropped by design — nothing was ever true.
@@ -474,6 +493,289 @@ const httpTransport = (
474
493
  /** `WebSocket.OPEN` — fixed by the spec, identical in every runtime. */
475
494
  const WS_OPEN = 1
476
495
 
496
+ type PendingAck = {
497
+ resolve: (events: WireEvent[]) => void
498
+ reject: (error: A2Error) => void
499
+ }
500
+
501
+ const webSocketUrl = (value: string): URL => {
502
+ let resolved: URL
503
+ if (/^(https?|wss?):\/\//i.test(value)) {
504
+ resolved = new URL(value)
505
+ } else {
506
+ const base = (globalThis as { location?: { href: string } }).location?.href
507
+ if (base === undefined) {
508
+ throw new TypeError(
509
+ `cannot resolve the relative ws url '${value}' without a browser location — pass an absolute ws:// or wss:// url`,
510
+ )
511
+ }
512
+ resolved = new URL(value, base)
513
+ }
514
+ if (resolved.protocol === 'http:') resolved.protocol = 'ws:'
515
+ else if (resolved.protocol === 'https:') resolved.protocol = 'wss:'
516
+ return resolved
517
+ }
518
+
519
+ const socketReadyWaiters = () => {
520
+ const sessions = new Map<string, Set<() => void>>()
521
+
522
+ const notify = (sessionId: string): void => {
523
+ const waiters = sessions.get(sessionId)
524
+ if (!waiters) return
525
+ sessions.delete(sessionId)
526
+ for (const waiter of waiters) waiter()
527
+ }
528
+
529
+ const wait = (sessionId: string, maxWaitMs: number): Promise<void> =>
530
+ new Promise((resolve) => {
531
+ let waiters = sessions.get(sessionId)
532
+ if (!waiters) {
533
+ waiters = new Set()
534
+ sessions.set(sessionId, waiters)
535
+ }
536
+ let settled = false
537
+ const done = (): void => {
538
+ if (settled) return
539
+ settled = true
540
+ clearTimeout(timer)
541
+ waiters.delete(done)
542
+ if (waiters.size === 0) sessions.delete(sessionId)
543
+ // oxlint-disable-next-line promise/no-multiple-resolved -- `settled` guards the timer and notification callers
544
+ resolve()
545
+ }
546
+ const timer = setTimeout(done, maxWaitMs)
547
+ ;(timer as { unref?: () => void }).unref?.()
548
+ waiters.add(done)
549
+ })
550
+
551
+ return { notify, wait }
552
+ }
553
+
554
+ const rejectPendingAcks = (pending: Map<number, PendingAck>): void => {
555
+ if (pending.size === 0) return
556
+ const error = new A2Error(
557
+ 'STORE_UNAVAILABLE',
558
+ 'socket closed with the ack outstanding',
559
+ )
560
+ for (const waiter of pending.values()) waiter.reject(error)
561
+ pending.clear()
562
+ }
563
+
564
+ const sessionWsTransport = (
565
+ url: SessionUrl,
566
+ webSocketImpl: ClientWebSocketConstructor | undefined,
567
+ presence: boolean,
568
+ ): Transport => {
569
+ type SocketConnection = {
570
+ socket: ClientWebSocket
571
+ pending: Map<number, PendingAck>
572
+ }
573
+
574
+ const connections = new Map<string, SocketConnection>()
575
+ const ready = socketReadyWaiters()
576
+ let nextReq = 1
577
+
578
+ const socketUrl = (sessionId: string, startAfter: number): string => {
579
+ const resolved = webSocketUrl(sessionUrl(url, sessionId))
580
+ resolved.searchParams.set('sessionId', sessionId)
581
+ resolved.searchParams.set('index', String(startAfter))
582
+ return resolved.href
583
+ }
584
+
585
+ return {
586
+ async *connect({ sessionId, startAfter, signal }) {
587
+ if (webSocketImpl === undefined) {
588
+ throw new TypeError(
589
+ 'no WebSocket implementation available — pass one via createClient({ webSocket }) where the global is missing',
590
+ )
591
+ }
592
+ const socket = new webSocketImpl(socketUrl(sessionId, startAfter))
593
+ const connection: SocketConnection = { socket, pending: new Map() }
594
+ connections.set(sessionId, connection)
595
+
596
+ const queue: TransportFrame[] = []
597
+ let ended = false
598
+ let failure: Error | null = null
599
+ let wake: (() => void) | null = null
600
+ const notify = (): void => {
601
+ wake?.()
602
+ wake = null
603
+ }
604
+ const finish = (error: Error | null): void => {
605
+ if (ended) return
606
+ ended = true
607
+ failure = error
608
+ if (connections.get(sessionId) === connection) {
609
+ connections.delete(sessionId)
610
+ }
611
+ rejectPendingAcks(connection.pending)
612
+ notify()
613
+ }
614
+
615
+ socket.addEventListener('open', () => {
616
+ if (connections.get(sessionId) !== connection || ended) return
617
+ queue.push({ kind: 'ping' })
618
+ notify()
619
+ ready.notify(sessionId)
620
+ })
621
+ socket.addEventListener('message', (event) => {
622
+ if (connections.get(sessionId) !== connection || ended) return
623
+ if (typeof event.data !== 'string') return
624
+ const frame = parseSocketFrame(event.data)
625
+ if (frame === null) return
626
+ switch (frame.kind) {
627
+ case 'ack': {
628
+ const waiter = connection.pending.get(frame.req)
629
+ if (waiter === undefined) return
630
+ connection.pending.delete(frame.req)
631
+ if (
632
+ frame.sessionId !== undefined ||
633
+ ('events' in frame &&
634
+ frame.events.some((row) => row.sessionId !== sessionId))
635
+ ) {
636
+ waiter.reject(
637
+ new A2Error(
638
+ 'STORE_UNAVAILABLE',
639
+ 'ack does not belong to the socket session',
640
+ ),
641
+ )
642
+ return
643
+ }
644
+ if ('events' in frame) waiter.resolve(frame.events)
645
+ else waiter.reject(frame.error)
646
+ return
647
+ }
648
+ case 'event':
649
+ if (frame.event.sessionId !== sessionId) return
650
+ queue.push({ kind: 'event', event: frame.event })
651
+ break
652
+ case 'presence':
653
+ if (presence && frame.sessionId === undefined) {
654
+ queue.push({ kind: 'presence', patch: frame.patch })
655
+ }
656
+ break
657
+ case 'presence-snapshot':
658
+ if (presence && frame.sessionId === undefined) {
659
+ queue.push({
660
+ kind: 'presence-snapshot',
661
+ snapshot: frame.snapshot,
662
+ })
663
+ }
664
+ break
665
+ case 'ping':
666
+ queue.push(frame)
667
+ break
668
+ case 'subscribed':
669
+ case 'unsubscribed':
670
+ return
671
+ }
672
+ notify()
673
+ })
674
+ socket.addEventListener('close', (event) => {
675
+ finish(
676
+ event.code === 1000
677
+ ? null
678
+ : new Error(
679
+ `socket closed (${event.code}${event.reason ? `: ${event.reason}` : ''})`,
680
+ ),
681
+ )
682
+ })
683
+ socket.addEventListener('error', () => {
684
+ finish(new Error('socket error'))
685
+ })
686
+
687
+ const onAbort = (): void => {
688
+ finish(
689
+ signal.reason instanceof Error
690
+ ? signal.reason
691
+ : new Error('stream aborted'),
692
+ )
693
+ try {
694
+ socket.close(1000)
695
+ } catch {
696
+ // Already closed.
697
+ }
698
+ }
699
+ if (signal.aborted) onAbort()
700
+ else signal.addEventListener('abort', onAbort, { once: true })
701
+
702
+ try {
703
+ for (;;) {
704
+ while (queue.length > 0) yield queue.shift()!
705
+ if (ended) {
706
+ if (failure) throw failure
707
+ return
708
+ }
709
+ await new Promise<void>((resolve) => {
710
+ wake = resolve
711
+ })
712
+ }
713
+ } finally {
714
+ signal.removeEventListener('abort', onAbort)
715
+ finish(null)
716
+ try {
717
+ socket.close(1000)
718
+ } catch {
719
+ // Already closed.
720
+ }
721
+ }
722
+ },
723
+
724
+ async push(body) {
725
+ const connection = connections.get(body.sessionId)
726
+ if (
727
+ connection === undefined ||
728
+ connection.socket.readyState !== WS_OPEN
729
+ ) {
730
+ throw new A2Error('STORE_UNAVAILABLE', 'no open socket for the session')
731
+ }
732
+ const req = nextReq
733
+ nextReq += 1
734
+ return await new Promise<WireEvent[]>((resolve, reject) => {
735
+ connection.pending.set(req, { resolve, reject })
736
+ const frame: SocketPushFrame = {
737
+ kind: 'push',
738
+ req,
739
+ events: body.events,
740
+ }
741
+ try {
742
+ connection.socket.send(JSON.stringify(frame))
743
+ } catch (cause) {
744
+ connection.pending.delete(req)
745
+ reject(
746
+ new A2Error('STORE_UNAVAILABLE', 'socket send failed', { cause }),
747
+ )
748
+ }
749
+ })
750
+ },
751
+
752
+ awaitReady(sessionId, maxWaitMs) {
753
+ const connection = connections.get(sessionId)
754
+ if (connection?.socket.readyState === WS_OPEN) return Promise.resolve()
755
+ return ready.wait(sessionId, maxWaitMs)
756
+ },
757
+
758
+ sendPresence(envelope) {
759
+ const connection = connections.get(envelope.sessionId)
760
+ if (
761
+ connection === undefined ||
762
+ connection.socket.readyState !== WS_OPEN
763
+ ) {
764
+ return
765
+ }
766
+ const frame: SocketPresenceFrame = {
767
+ kind: 'presence',
768
+ ...envelope.presence,
769
+ }
770
+ try {
771
+ connection.socket.send(JSON.stringify(frame))
772
+ } catch {
773
+ return
774
+ }
775
+ },
776
+ }
777
+ }
778
+
477
779
  /** One session's lane on the shared ws socket — created per `connect`
478
780
  * attempt, so `startAfter` is that attempt's resume frontier. */
479
781
  type WsChannel = {
@@ -501,15 +803,11 @@ const subscribeFrame = (
501
803
  return JSON.stringify(frame)
502
804
  }
503
805
 
504
- const wsTransport = (
806
+ const multiplexedWsTransport = (
505
807
  url: string,
506
808
  webSocketImpl: ClientWebSocketConstructor | undefined,
507
809
  presence: boolean,
508
810
  ): Transport => {
509
- type PendingAck = {
510
- resolve: (events: WireEvent[]) => void
511
- reject: (error: A2Error) => void
512
- }
513
811
  // ONE socket serves every session of this client (specs §13's
514
812
  // multiplexed superset): channels subscribe on it at their own
515
813
  // frontiers, down frames route back by sessionId, acks by req.
@@ -517,41 +815,12 @@ const wsTransport = (
517
815
  const pending = new Map<number, PendingAck>()
518
816
  /** Push retries parked in `awaitReady`, flushed per session on its
519
817
  * subscribe confirmation. */
520
- const readyWaiters = new Map<string, Set<() => void>>()
818
+ const ready = socketReadyWaiters()
521
819
  let socket: ClientWebSocket | null = null
522
820
  let socketOpen = false
523
821
  let watchdog: ReturnType<typeof setTimeout> | undefined
524
822
  let nextReq = 1
525
823
 
526
- const flushReadyWaiters = (sessionId: string): void => {
527
- const waiters = readyWaiters.get(sessionId)
528
- if (!waiters) return
529
- readyWaiters.delete(sessionId)
530
- for (const waiter of waiters) waiter()
531
- }
532
-
533
- const socketUrl = (): string => {
534
- let resolved: URL
535
- if (/^(https?|wss?):\/\//i.test(url)) {
536
- resolved = new URL(url)
537
- } else {
538
- // A relative url resolves against the page, exactly like fetch.
539
- // Outside a browser (SSR, tests) there is nothing to resolve
540
- // against — demand an absolute url there.
541
- const base = (globalThis as { location?: { href: string } }).location
542
- ?.href
543
- if (base === undefined) {
544
- throw new TypeError(
545
- `cannot resolve the relative ws url '${url}' without a browser location — pass an absolute ws:// or wss:// url`,
546
- )
547
- }
548
- resolved = new URL(url, base)
549
- }
550
- if (resolved.protocol === 'http:') resolved.protocol = 'ws:'
551
- else if (resolved.protocol === 'https:') resolved.protocol = 'wss:'
552
- return resolved.href
553
- }
554
-
555
824
  const trySend = (data: string): void => {
556
825
  if (socket === null || !socketOpen) return
557
826
  try {
@@ -583,14 +852,7 @@ const wsTransport = (
583
852
  socketOpen = false
584
853
  clearTimeout(watchdog)
585
854
  watchdog = undefined
586
- if (pending.size > 0) {
587
- const rejection = new A2Error(
588
- 'STORE_UNAVAILABLE',
589
- 'socket closed with the ack outstanding',
590
- )
591
- for (const waiter of pending.values()) waiter.reject(rejection)
592
- pending.clear()
593
- }
855
+ rejectPendingAcks(pending)
594
856
  return current
595
857
  }
596
858
 
@@ -661,7 +923,7 @@ const wsTransport = (
661
923
  if (channel === undefined || channel.ended || channel.live) return
662
924
  channel.live = true
663
925
  deliver(channel, { kind: 'ping' }) // the connected signal
664
- flushReadyWaiters(frame.sessionId)
926
+ ready.notify(frame.sessionId)
665
927
  return
666
928
  }
667
929
  case 'unsubscribed': {
@@ -722,7 +984,7 @@ const wsTransport = (
722
984
  'no WebSocket implementation available — pass one via createClient({ webSocket }) where the global is missing',
723
985
  )
724
986
  }
725
- const created = new webSocketImpl(socketUrl())
987
+ const created = new webSocketImpl(webSocketUrl(url).href)
726
988
  socket = created
727
989
  socketOpen = false
728
990
  armWatchdog(created)
@@ -880,29 +1142,7 @@ const wsTransport = (
880
1142
  ) {
881
1143
  return Promise.resolve()
882
1144
  }
883
- return new Promise((resolve) => {
884
- let waiters = readyWaiters.get(sessionId)
885
- if (!waiters) {
886
- waiters = new Set()
887
- readyWaiters.set(sessionId, waiters)
888
- }
889
- let settled = false
890
- const done = (): void => {
891
- if (settled) return
892
- settled = true
893
- clearTimeout(timer)
894
- waiters.delete(done)
895
- if (waiters.size === 0) readyWaiters.delete(sessionId)
896
- // oxlint-disable-next-line promise/no-multiple-resolved -- `settled` guards the two callers (timer, open flush); the rule cannot see through the flag
897
- resolve()
898
- }
899
- // The cap keeps a session nobody reconnects (write-only usage,
900
- // an unreachable server) from parking a push forever — the
901
- // attempt then fails fast and the loop moves on.
902
- const timer = setTimeout(done, maxWaitMs)
903
- ;(timer as { unref?: () => void }).unref?.()
904
- waiters.add(done)
905
- })
1145
+ return ready.wait(sessionId, maxWaitMs)
906
1146
  },
907
1147
  sendPresence(envelope) {
908
1148
  const channel = channels.get(envelope.sessionId)
@@ -925,7 +1165,7 @@ const transportFor = (
925
1165
  webSocketImpl: ClientWebSocketConstructor | undefined,
926
1166
  presence: boolean,
927
1167
  ): Transport => {
928
- if (typeof api === 'string') {
1168
+ if (typeof api === 'string' || typeof api === 'function') {
929
1169
  return httpTransport({ push: api, stream: api }, fetchImpl, presence)
930
1170
  }
931
1171
  if (api.type === 'http') {
@@ -935,7 +1175,13 @@ const transportFor = (
935
1175
  presence,
936
1176
  )
937
1177
  }
938
- return wsTransport(api.url, webSocketImpl, presence)
1178
+ if (api.multiplex === true) {
1179
+ if (typeof api.url !== 'string') {
1180
+ throw new TypeError('a multiplexed WebSocket URL must be a string')
1181
+ }
1182
+ return multiplexedWsTransport(api.url, webSocketImpl, presence)
1183
+ }
1184
+ return sessionWsTransport(api.url, webSocketImpl, presence)
939
1185
  }
940
1186
 
941
1187
  type Pending<D extends EventDefs> = {
@@ -1309,9 +1555,16 @@ export function createClient<
1309
1555
  const validated: Record<string, unknown> = nullProtoRecord()
1310
1556
  for (const [field, value] of Object.entries(values)) {
1311
1557
  if (value === undefined) continue
1558
+ // '*' is a catch-all vocabulary entry: any field name (bar
1559
+ // object-plumbing names) validates against it. The actor
1560
+ // module's follower reducer declares it — the browser cannot
1561
+ // know a server-defined vocabulary, so the server stays the
1562
+ // authority.
1312
1563
  const schema = Object.hasOwn(presenceDefs, field)
1313
1564
  ? presenceDefs[field]
1314
- : undefined
1565
+ : RESERVED_PARTICIPANT_IDS.has(field)
1566
+ ? undefined
1567
+ : presenceDefs['*']
1315
1568
  if (!schema) {
1316
1569
  throw new A2Error(
1317
1570
  'UNKNOWN_PRESENCE_FIELD',
package/src/errors.ts CHANGED
@@ -49,3 +49,18 @@ export function asStoreUnavailable(err: unknown): A2Error {
49
49
  cause: err,
50
50
  })
51
51
  }
52
+
53
+ /**
54
+ * Throw from a handler to reject retrying: the failure is adjudicated
55
+ * terminal immediately (dead-lettered on the first throw) instead of
56
+ * consuming the ten-failure retry budget. For deterministic failures —
57
+ * a guard that will refuse identically on every attempt — retrying is
58
+ * waste and dead-lettering is the honest outcome. Matched by
59
+ * `instanceof`, so subclasses carry domain meaning.
60
+ */
61
+ export class NonRetriableError extends Error {
62
+ constructor(message: string, options?: { cause?: unknown }) {
63
+ super(message, options)
64
+ this.name = 'NonRetriableError'
65
+ }
66
+ }
package/src/index.ts CHANGED
@@ -23,7 +23,7 @@ export type {
23
23
 
24
24
  export type { Reducer, ReducerBuilder } from './reducer.ts'
25
25
 
26
- export { A2Error } from './errors.ts'
26
+ export { A2Error, NonRetriableError } from './errors.ts'
27
27
  export type { A2ErrorCode } from './errors.ts'
28
28
 
29
29
  export type {
@@ -14,6 +14,7 @@ import {
14
14
  } from './push-envelope.ts'
15
15
  import type { A2Server } from './server.ts'
16
16
  import {
17
+ sessionSocket,
17
18
  sessionsSocket,
18
19
  type A2Socket,
19
20
  type SocketSession,
@@ -76,6 +77,8 @@ export type ServerFetchOptions<
76
77
  > = {
77
78
  authorize?: (operation: A2Operation<D, P>) => boolean | Promise<boolean>
78
79
  upgradeWebSocket?: UpgradeWebSocket
80
+ /** Allow one upgraded connection to select more than one session. */
81
+ multiplexWebSocket?: boolean
79
82
  }
80
83
 
81
84
  export type ServerIngressContext = {
@@ -212,7 +215,27 @@ export function createServerFetch<
212
215
  await serverFetchHooks.get(fetch)?.validateIngress?.(context)
213
216
  }
214
217
 
215
- const attach = (socket: A2Socket): void => {
218
+ const gateSocketPush = async (
219
+ sessionId: string,
220
+ events: ParsedPushEvent[],
221
+ pushedPresence?: ParsedPushPresence,
222
+ ): Promise<A2Error | null> => {
223
+ try {
224
+ await authorize(
225
+ freezePush<D, P>(sessionId, events, pushedPresence, 'websocket'),
226
+ )
227
+ await validateIngress({
228
+ sessionId,
229
+ events,
230
+ ...(pushedPresence === undefined ? {} : { presence: pushedPresence }),
231
+ })
232
+ return null
233
+ } catch (error) {
234
+ return asA2Error(error)
235
+ }
236
+ }
237
+
238
+ const attachMultiplexed = (socket: A2Socket): void => {
216
239
  const deadline = invocationDeadlineMs()
217
240
  sessionsSocket<D>(
218
241
  async (sessionId, startAfter) => {
@@ -230,28 +253,7 @@ export function createServerFetch<
230
253
  {
231
254
  presence,
232
255
  ...(deadline === null ? {} : { deadline }),
233
- gatePush: async (sessionId, events, pushedPresence) => {
234
- try {
235
- await authorize(
236
- freezePush<D, P>(
237
- sessionId,
238
- events,
239
- pushedPresence,
240
- 'websocket',
241
- ),
242
- )
243
- await validateIngress({
244
- sessionId,
245
- events,
246
- ...(pushedPresence === undefined
247
- ? {}
248
- : { presence: pushedPresence }),
249
- })
250
- return null
251
- } catch (error) {
252
- return asA2Error(error)
253
- }
254
- },
256
+ gatePush: gateSocketPush,
255
257
  },
256
258
  )
257
259
  }
@@ -265,6 +267,32 @@ export function createServerFetch<
265
267
  { status: 426 },
266
268
  )
267
269
  }
270
+ if (options.multiplexWebSocket === true) {
271
+ return await options.upgradeWebSocket(attachMultiplexed)
272
+ }
273
+
274
+ const { searchParams } = new URL(request.url)
275
+ const sessionId = searchParams.get('sessionId')
276
+ if (sessionId === null || sessionId.length === 0) {
277
+ throw new A2Error('INVALID_PAYLOAD', 'missing sessionId')
278
+ }
279
+ const startAfter = parseResumeIndex(searchParams.get('index'))
280
+ await authorize({
281
+ type: 'stream',
282
+ sessionId,
283
+ startAfter,
284
+ transport: 'websocket',
285
+ })
286
+ const target = server.session(sessionId)
287
+ const attach = (socket: A2Socket): void => {
288
+ const deadline = invocationDeadlineMs()
289
+ sessionSocket(sessionId, target, socket, {
290
+ startAfter,
291
+ presence,
292
+ ...(deadline === null ? {} : { deadline }),
293
+ gatePush: gateSocketPush,
294
+ })
295
+ }
268
296
  return await options.upgradeWebSocket(attach)
269
297
  }
270
298