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/src/client.ts CHANGED
@@ -217,16 +217,21 @@ export type A2Client<
217
217
  }
218
218
 
219
219
  /**
220
- * 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
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 — every session of this client — over one
224
- * 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.
225
225
  */
226
+ export type SessionUrl = string | ((sessionId: string) => string)
227
+
226
228
  export type ClientApi =
227
- | string // one route: GET SSE stream + POST push
228
- | { type: 'http'; push: string; stream: string } // split routes
229
- | { 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 }
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: string; stream: string },
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 sep = routes.stream.includes('?') ? '&' : '?'
368
+ const streamUrl = sessionUrl(routes.stream, sessionId)
369
+ const sep = streamUrl.includes('?') ? '&' : '?'
356
370
  const res = await fetchImpl(
357
- `${routes.stream}${sep}sessionId=${encodeURIComponent(sessionId)}&index=${startAfter}`,
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 sep = routes.stream.includes('?') ? '&' : '?'
452
+ const streamUrl = sessionUrl(routes.stream, sessionId)
453
+ const sep = streamUrl.includes('?') ? '&' : '?'
439
454
  const res = await fetchImpl(
440
- `${routes.stream}${sep}sessionId=${encodeURIComponent(sessionId)}&gte=${gte}&lte=${lte}`,
455
+ `${streamUrl}${sep}sessionId=${encodeURIComponent(sessionId)}&gte=${gte}&lte=${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(routes.push, {
463
- method: 'POST',
464
- headers: { 'content-type': 'application/json' },
465
- body: JSON.stringify(envelope),
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 wsTransport = (
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 readyWaiters = new Map<string, Set<() => void>>()
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
- if (pending.size > 0) {
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
- flushReadyWaiters(frame.sessionId)
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(socketUrl())
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 new Promise((resolve) => {
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
- 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)
940
1185
  }
941
1186
 
942
1187
  type Pending<D extends EventDefs> = {
@@ -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