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/client.ts
CHANGED
|
@@ -217,16 +217,21 @@ export type A2Client<
|
|
|
217
217
|
}
|
|
218
218
|
|
|
219
219
|
/**
|
|
220
|
-
* The wire the client rides. The
|
|
220
|
+
* The wire the client rides. The first form is one route serving GET
|
|
221
221
|
* (SSE stream) + POST (push). `http` splits the verbs across two
|
|
222
222
|
* routes, for when platform duration limits differ per verb. `ws`
|
|
223
|
-
* rides everything
|
|
224
|
-
*
|
|
223
|
+
* rides everything over WebSocket, one socket per session by default.
|
|
224
|
+
* Multiplexing every session of the client is an explicit opt-in.
|
|
225
225
|
*/
|
|
226
|
+
export type SessionUrl = string | ((sessionId: string) => string)
|
|
227
|
+
|
|
226
228
|
export type ClientApi =
|
|
227
|
-
|
|
|
228
|
-
| { type: 'http'; push:
|
|
229
|
-
|
|
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 }
|
|
230
235
|
|
|
231
236
|
/**
|
|
232
237
|
* The socket surface the `ws` transport drives — satisfied structurally
|
|
@@ -344,17 +349,26 @@ type Transport = {
|
|
|
344
349
|
awaitReady?(sessionId: string, maxWaitMs: number): Promise<void>
|
|
345
350
|
}
|
|
346
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
|
+
|
|
347
360
|
const httpTransport = (
|
|
348
|
-
routes: { push:
|
|
361
|
+
routes: { push: SessionUrl; stream: SessionUrl },
|
|
349
362
|
fetchImpl: typeof globalThis.fetch,
|
|
350
363
|
// Contracts that declare no presence skip presence frames unparsed,
|
|
351
364
|
// exactly like unknown named frames.
|
|
352
365
|
presence: boolean,
|
|
353
366
|
): Transport => ({
|
|
354
367
|
async *connect({ sessionId, startAfter, signal }) {
|
|
355
|
-
const
|
|
368
|
+
const streamUrl = sessionUrl(routes.stream, sessionId)
|
|
369
|
+
const sep = streamUrl.includes('?') ? '&' : '?'
|
|
356
370
|
const res = await fetchImpl(
|
|
357
|
-
`${
|
|
371
|
+
`${streamUrl}${sep}sessionId=${encodeURIComponent(sessionId)}&index=${startAfter}`,
|
|
358
372
|
{ headers: { accept: 'text/event-stream' }, signal },
|
|
359
373
|
)
|
|
360
374
|
if (!res.ok || !res.body) {
|
|
@@ -413,7 +427,7 @@ const httpTransport = (
|
|
|
413
427
|
},
|
|
414
428
|
|
|
415
429
|
async push(body) {
|
|
416
|
-
const res = await fetchImpl(routes.push, {
|
|
430
|
+
const res = await fetchImpl(sessionUrl(routes.push, body.sessionId), {
|
|
417
431
|
method: 'POST',
|
|
418
432
|
headers: { 'content-type': 'application/json' },
|
|
419
433
|
body: JSON.stringify(body),
|
|
@@ -435,9 +449,10 @@ const httpTransport = (
|
|
|
435
449
|
},
|
|
436
450
|
|
|
437
451
|
async history({ sessionId, gte, lte }) {
|
|
438
|
-
const
|
|
452
|
+
const streamUrl = sessionUrl(routes.stream, sessionId)
|
|
453
|
+
const sep = streamUrl.includes('?') ? '&' : '?'
|
|
439
454
|
const res = await fetchImpl(
|
|
440
|
-
`${
|
|
455
|
+
`${streamUrl}${sep}sessionId=${encodeURIComponent(sessionId)}>e=${gte}<e=${lte}`,
|
|
441
456
|
{ headers: { accept: 'application/json' } },
|
|
442
457
|
)
|
|
443
458
|
if (!res.ok) {
|
|
@@ -459,11 +474,14 @@ const httpTransport = (
|
|
|
459
474
|
sendPresence(envelope) {
|
|
460
475
|
void (async () => {
|
|
461
476
|
try {
|
|
462
|
-
const res = await fetchImpl(
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
+
)
|
|
467
485
|
await res.arrayBuffer()
|
|
468
486
|
} catch {
|
|
469
487
|
// Dropped by design — nothing was ever true.
|
|
@@ -475,6 +493,289 @@ const httpTransport = (
|
|
|
475
493
|
/** `WebSocket.OPEN` — fixed by the spec, identical in every runtime. */
|
|
476
494
|
const WS_OPEN = 1
|
|
477
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
|
+
|
|
478
779
|
/** One session's lane on the shared ws socket — created per `connect`
|
|
479
780
|
* attempt, so `startAfter` is that attempt's resume frontier. */
|
|
480
781
|
type WsChannel = {
|
|
@@ -502,15 +803,11 @@ const subscribeFrame = (
|
|
|
502
803
|
return JSON.stringify(frame)
|
|
503
804
|
}
|
|
504
805
|
|
|
505
|
-
const
|
|
806
|
+
const multiplexedWsTransport = (
|
|
506
807
|
url: string,
|
|
507
808
|
webSocketImpl: ClientWebSocketConstructor | undefined,
|
|
508
809
|
presence: boolean,
|
|
509
810
|
): Transport => {
|
|
510
|
-
type PendingAck = {
|
|
511
|
-
resolve: (events: WireEvent[]) => void
|
|
512
|
-
reject: (error: A2Error) => void
|
|
513
|
-
}
|
|
514
811
|
// ONE socket serves every session of this client (specs §13's
|
|
515
812
|
// multiplexed superset): channels subscribe on it at their own
|
|
516
813
|
// frontiers, down frames route back by sessionId, acks by req.
|
|
@@ -518,41 +815,12 @@ const wsTransport = (
|
|
|
518
815
|
const pending = new Map<number, PendingAck>()
|
|
519
816
|
/** Push retries parked in `awaitReady`, flushed per session on its
|
|
520
817
|
* subscribe confirmation. */
|
|
521
|
-
const
|
|
818
|
+
const ready = socketReadyWaiters()
|
|
522
819
|
let socket: ClientWebSocket | null = null
|
|
523
820
|
let socketOpen = false
|
|
524
821
|
let watchdog: ReturnType<typeof setTimeout> | undefined
|
|
525
822
|
let nextReq = 1
|
|
526
823
|
|
|
527
|
-
const flushReadyWaiters = (sessionId: string): void => {
|
|
528
|
-
const waiters = readyWaiters.get(sessionId)
|
|
529
|
-
if (!waiters) return
|
|
530
|
-
readyWaiters.delete(sessionId)
|
|
531
|
-
for (const waiter of waiters) waiter()
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
const socketUrl = (): string => {
|
|
535
|
-
let resolved: URL
|
|
536
|
-
if (/^(https?|wss?):\/\//i.test(url)) {
|
|
537
|
-
resolved = new URL(url)
|
|
538
|
-
} else {
|
|
539
|
-
// A relative url resolves against the page, exactly like fetch.
|
|
540
|
-
// Outside a browser (SSR, tests) there is nothing to resolve
|
|
541
|
-
// against — demand an absolute url there.
|
|
542
|
-
const base = (globalThis as { location?: { href: string } }).location
|
|
543
|
-
?.href
|
|
544
|
-
if (base === undefined) {
|
|
545
|
-
throw new TypeError(
|
|
546
|
-
`cannot resolve the relative ws url '${url}' without a browser location — pass an absolute ws:// or wss:// url`,
|
|
547
|
-
)
|
|
548
|
-
}
|
|
549
|
-
resolved = new URL(url, base)
|
|
550
|
-
}
|
|
551
|
-
if (resolved.protocol === 'http:') resolved.protocol = 'ws:'
|
|
552
|
-
else if (resolved.protocol === 'https:') resolved.protocol = 'wss:'
|
|
553
|
-
return resolved.href
|
|
554
|
-
}
|
|
555
|
-
|
|
556
824
|
const trySend = (data: string): void => {
|
|
557
825
|
if (socket === null || !socketOpen) return
|
|
558
826
|
try {
|
|
@@ -584,14 +852,7 @@ const wsTransport = (
|
|
|
584
852
|
socketOpen = false
|
|
585
853
|
clearTimeout(watchdog)
|
|
586
854
|
watchdog = undefined
|
|
587
|
-
|
|
588
|
-
const rejection = new A2Error(
|
|
589
|
-
'STORE_UNAVAILABLE',
|
|
590
|
-
'socket closed with the ack outstanding',
|
|
591
|
-
)
|
|
592
|
-
for (const waiter of pending.values()) waiter.reject(rejection)
|
|
593
|
-
pending.clear()
|
|
594
|
-
}
|
|
855
|
+
rejectPendingAcks(pending)
|
|
595
856
|
return current
|
|
596
857
|
}
|
|
597
858
|
|
|
@@ -662,7 +923,7 @@ const wsTransport = (
|
|
|
662
923
|
if (channel === undefined || channel.ended || channel.live) return
|
|
663
924
|
channel.live = true
|
|
664
925
|
deliver(channel, { kind: 'ping' }) // the connected signal
|
|
665
|
-
|
|
926
|
+
ready.notify(frame.sessionId)
|
|
666
927
|
return
|
|
667
928
|
}
|
|
668
929
|
case 'unsubscribed': {
|
|
@@ -723,7 +984,7 @@ const wsTransport = (
|
|
|
723
984
|
'no WebSocket implementation available — pass one via createClient({ webSocket }) where the global is missing',
|
|
724
985
|
)
|
|
725
986
|
}
|
|
726
|
-
const created = new webSocketImpl(
|
|
987
|
+
const created = new webSocketImpl(webSocketUrl(url).href)
|
|
727
988
|
socket = created
|
|
728
989
|
socketOpen = false
|
|
729
990
|
armWatchdog(created)
|
|
@@ -881,29 +1142,7 @@ const wsTransport = (
|
|
|
881
1142
|
) {
|
|
882
1143
|
return Promise.resolve()
|
|
883
1144
|
}
|
|
884
|
-
return
|
|
885
|
-
let waiters = readyWaiters.get(sessionId)
|
|
886
|
-
if (!waiters) {
|
|
887
|
-
waiters = new Set()
|
|
888
|
-
readyWaiters.set(sessionId, waiters)
|
|
889
|
-
}
|
|
890
|
-
let settled = false
|
|
891
|
-
const done = (): void => {
|
|
892
|
-
if (settled) return
|
|
893
|
-
settled = true
|
|
894
|
-
clearTimeout(timer)
|
|
895
|
-
waiters.delete(done)
|
|
896
|
-
if (waiters.size === 0) readyWaiters.delete(sessionId)
|
|
897
|
-
// oxlint-disable-next-line promise/no-multiple-resolved -- `settled` guards the two callers (timer, open flush); the rule cannot see through the flag
|
|
898
|
-
resolve()
|
|
899
|
-
}
|
|
900
|
-
// The cap keeps a session nobody reconnects (write-only usage,
|
|
901
|
-
// an unreachable server) from parking a push forever — the
|
|
902
|
-
// attempt then fails fast and the loop moves on.
|
|
903
|
-
const timer = setTimeout(done, maxWaitMs)
|
|
904
|
-
;(timer as { unref?: () => void }).unref?.()
|
|
905
|
-
waiters.add(done)
|
|
906
|
-
})
|
|
1145
|
+
return ready.wait(sessionId, maxWaitMs)
|
|
907
1146
|
},
|
|
908
1147
|
sendPresence(envelope) {
|
|
909
1148
|
const channel = channels.get(envelope.sessionId)
|
|
@@ -926,7 +1165,7 @@ const transportFor = (
|
|
|
926
1165
|
webSocketImpl: ClientWebSocketConstructor | undefined,
|
|
927
1166
|
presence: boolean,
|
|
928
1167
|
): Transport => {
|
|
929
|
-
if (typeof api === 'string') {
|
|
1168
|
+
if (typeof api === 'string' || typeof api === 'function') {
|
|
930
1169
|
return httpTransport({ push: api, stream: api }, fetchImpl, presence)
|
|
931
1170
|
}
|
|
932
1171
|
if (api.type === 'http') {
|
|
@@ -936,7 +1175,13 @@ const transportFor = (
|
|
|
936
1175
|
presence,
|
|
937
1176
|
)
|
|
938
1177
|
}
|
|
939
|
-
|
|
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)
|
|
940
1185
|
}
|
|
941
1186
|
|
|
942
1187
|
type Pending<D extends EventDefs> = {
|
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
|
|