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.
- package/CHANGELOG.md +42 -0
- package/dist/actor-client.d.ts +46 -0
- package/dist/actor-client.d.ts.map +1 -0
- package/dist/actor-client.js +54 -0
- package/dist/actor-client.js.map +1 -0
- package/dist/actor-react.d.ts +54 -0
- package/dist/actor-react.d.ts.map +1 -0
- package/dist/actor-react.js +79 -0
- package/dist/actor-react.js.map +1 -0
- package/dist/actor-shared-DI7J5upy.js +127 -0
- package/dist/actor-shared-DI7J5upy.js.map +1 -0
- package/dist/actor-shared-USo5MyuF.d.ts +136 -0
- package/dist/actor-shared-USo5MyuF.d.ts.map +1 -0
- package/dist/actor.browser.d.ts +1 -0
- package/dist/actor.browser.js +13 -0
- package/dist/actor.browser.js.map +1 -0
- package/dist/actor.d.ts +176 -0
- package/dist/actor.d.ts.map +1 -0
- package/dist/actor.js +437 -0
- package/dist/actor.js.map +1 -0
- package/dist/ai-server.d.ts +2 -2
- package/dist/ai-server.js +2 -2
- package/dist/ai.d.ts +2 -2
- package/dist/client.d.ts +14 -8
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +238 -58
- package/dist/client.js.map +1 -1
- package/dist/{errors-BQuJpe82.js → errors-DCk6ch5n.js} +16 -2
- package/dist/{errors-BQuJpe82.js.map → errors-DCk6ch5n.js.map} +1 -1
- package/dist/{idempotent-replay-DuqEkYA7.js → idempotent-replay-DVOlyYbx.js} +2 -2
- package/dist/{idempotent-replay-DuqEkYA7.js.map → idempotent-replay-DVOlyYbx.js.map} +1 -1
- package/dist/index.d.ts +16 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/react.d.ts +1 -1
- package/dist/{contract-jIfaR085.d.ts → reducer-DJKWm3cp.d.ts} +39 -39
- package/dist/reducer-DJKWm3cp.d.ts.map +1 -0
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +2 -2
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +1 -1
- package/dist/{server-DjPhHnbI.d.ts → server-DgCrSuhB.d.ts} +5 -3
- package/dist/server-DgCrSuhB.d.ts.map +1 -0
- package/dist/{server-B2XNevQA.js → server-DlLyvaSH.js} +140 -81
- package/dist/server-DlLyvaSH.js.map +1 -0
- package/dist/server.d.ts +3 -3
- package/dist/server.js +1 -1
- package/dist/{store-RJO35BMj.d.ts → store-DGHeBtIQ.d.ts} +2 -2
- package/dist/{store-RJO35BMj.d.ts.map → store-DGHeBtIQ.d.ts.map} +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.js +2 -2
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.js +2 -2
- package/dist/{store-redis-core-DT01r4GZ.js → store-redis-core-z-ykbyMg.js} +3 -3
- package/dist/{store-redis-core-DT01r4GZ.js.map → store-redis-core-z-ykbyMg.js.map} +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +2 -2
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +2 -2
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.js +2 -2
- package/dist/{wire-B6te_wns.js → wire--yji6mO3.js} +2 -2
- package/dist/{wire-B6te_wns.js.map → wire--yji6mO3.js.map} +1 -1
- package/docs/actors/01-introduction.mdx +189 -0
- package/docs/actors/02-concurrency.mdx +154 -0
- package/docs/actors/03-timers.mdx +120 -0
- package/docs/actors/04-routes.mdx +352 -0
- package/docs/actors/meta.ts +1 -0
- package/docs/concepts/meta.ts +1 -0
- package/docs/guides/10-transports.mdx +72 -22
- package/docs/guides/meta.ts +1 -0
- package/docs/index.mdx +3 -0
- package/docs/reference/01-api.mdx +30 -12
- package/docs/reference/02-errors.mdx +33 -0
- package/docs/reference/meta.ts +1 -0
- package/examples/playground/app/page.tsx +10 -1
- package/examples/playground/app/vault/[vaultId]/route.ts +19 -0
- package/examples/playground/app/vault/page.tsx +12 -0
- package/examples/playground/app/vault/server.ts +9 -0
- package/examples/playground/app/vault/vault-client.tsx +124 -0
- package/examples/playground/app/vault/vault.test.ts +147 -0
- package/examples/playground/app/vault/vault.ts +119 -0
- package/examples/playground/package.json +1 -1
- package/package.json +7 -1
- package/src/actor-client.ts +132 -0
- package/src/actor-react.ts +143 -0
- package/src/actor-shared.ts +356 -0
- package/src/actor.browser.ts +12 -0
- package/src/actor.ts +914 -0
- package/src/client.ts +341 -88
- package/src/errors.ts +15 -0
- package/src/index.ts +1 -1
- package/src/server-fetch.ts +51 -23
- package/src/server.ts +13 -3
- package/src/session-socket.ts +216 -81
- package/dist/contract-jIfaR085.d.ts.map +0 -1
- package/dist/server-B2XNevQA.js.map +0 -1
- 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
|
|
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
|
|
223
|
-
*
|
|
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
|
-
|
|
|
227
|
-
| { type: 'http'; push:
|
|
228
|
-
|
|
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:
|
|
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
|
|
368
|
+
const streamUrl = sessionUrl(routes.stream, sessionId)
|
|
369
|
+
const sep = streamUrl.includes('?') ? '&' : '?'
|
|
355
370
|
const res = await fetchImpl(
|
|
356
|
-
`${
|
|
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
|
|
452
|
+
const streamUrl = sessionUrl(routes.stream, sessionId)
|
|
453
|
+
const sep = streamUrl.includes('?') ? '&' : '?'
|
|
438
454
|
const res = await fetchImpl(
|
|
439
|
-
`${
|
|
455
|
+
`${streamUrl}${sep}sessionId=${encodeURIComponent(sessionId)}>e=${gte}<e=${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(
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
:
|
|
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
package/src/server-fetch.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|