@electric-sql/client 1.0.3 → 1.0.5

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
@@ -7,7 +7,7 @@ import {
7
7
  GetExtensions,
8
8
  } from './types'
9
9
  import { MessageParser, Parser } from './parser'
10
- import { isUpToDateMessage } from './helpers'
10
+ import { getOffset, isUpToDateMessage } from './helpers'
11
11
  import {
12
12
  FetchError,
13
13
  FetchBackoffAbortError,
@@ -39,7 +39,13 @@ import {
39
39
  TABLE_QUERY_PARAM,
40
40
  REPLICA_PARAM,
41
41
  FORCE_DISCONNECT_AND_REFRESH,
42
+ PAUSE_STREAM,
43
+ EXPERIMENTAL_LIVE_SSE_QUERY_PARAM,
42
44
  } from './constants'
45
+ import {
46
+ EventSourceMessage,
47
+ fetchEventSource,
48
+ } from '@microsoft/fetch-event-source'
43
49
 
44
50
  const RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([
45
51
  LIVE_CACHE_BUSTER_QUERY_PARAM,
@@ -53,15 +59,17 @@ type Replica = `full` | `default`
53
59
  /**
54
60
  * PostgreSQL-specific shape parameters that can be provided externally
55
61
  */
56
- export interface PostgresParams {
62
+ export interface PostgresParams<T extends Row<unknown> = Row> {
57
63
  /** The root table for the shape. Not required if you set the table in your proxy. */
58
64
  table?: string
59
65
 
60
66
  /**
61
67
  * The columns to include in the shape.
62
68
  * Must include primary keys, and can only include valid columns.
69
+ * Defaults to all columns of the type `T`. If provided, must include primary keys, and can only include valid columns.
70
+
63
71
  */
64
- columns?: string[]
72
+ columns?: (keyof T)[]
65
73
 
66
74
  /** The where clauses for the shape */
67
75
  where?: string
@@ -99,11 +107,9 @@ type ParamValue =
99
107
  * External params type - what users provide.
100
108
  * Excludes reserved parameters to prevent dynamic variations that could cause stream shape changes.
101
109
  */
102
- export type ExternalParamsRecord = {
103
- [K in string as K extends ReservedParamKeys ? never : K]:
104
- | ParamValue
105
- | undefined
106
- } & Partial<PostgresParams>
110
+ export type ExternalParamsRecord<T extends Row<unknown> = Row> = {
111
+ [K in string]: ParamValue | undefined
112
+ } & Partial<PostgresParams<T>> & { [K in ReservedParamKeys]?: never }
107
113
 
108
114
  type ReservedParamKeys =
109
115
  | typeof LIVE_CACHE_BUSTER_QUERY_PARAM
@@ -145,7 +151,7 @@ export async function resolveValue<T>(
145
151
  * Helper function to convert external params to internal format
146
152
  */
147
153
  async function toInternalParams(
148
- params: ExternalParamsRecord
154
+ params: ExternalParamsRecord<Row>
149
155
  ): Promise<InternalParamsRecord> {
150
156
  const entries = Object.entries(params)
151
157
  const resolvedEntries = await Promise.all(
@@ -244,6 +250,11 @@ export interface ShapeStreamOptions<T = never> {
244
250
  */
245
251
  subscribe?: boolean
246
252
 
253
+ /**
254
+ * Experimental support for Server-Sent Events (SSE) for live updates.
255
+ */
256
+ experimentalLiveSse?: boolean
257
+
247
258
  signal?: AbortSignal
248
259
  fetchClient?: typeof fetch
249
260
  backoffOptions?: BackoffOptions
@@ -261,7 +272,9 @@ export interface ShapeStreamOptions<T = never> {
261
272
 
262
273
  export interface ShapeStreamInterface<T extends Row<unknown> = Row> {
263
274
  subscribe(
264
- callback: (messages: Message<T>[]) => MaybePromise<void>,
275
+ callback: (
276
+ messages: Message<T>[]
277
+ ) => MaybePromise<void> | { columns?: (keyof T)[] },
265
278
  onError?: (error: FetchError | Error) => void
266
279
  ): () => void
267
280
  unsubscribeAll(): void
@@ -281,8 +294,9 @@ export interface ShapeStreamInterface<T extends Row<unknown> = Row> {
281
294
  }
282
295
 
283
296
  /**
284
- * Reads updates to a shape from Electric using HTTP requests and long polling. Notifies subscribers
285
- * when new messages come in. Doesn't maintain any history of the
297
+ * Reads updates to a shape from Electric using HTTP requests and long polling or
298
+ * Server-Sent Events (SSE).
299
+ * Notifies subscribers when new messages come in. Doesn't maintain any history of the
286
300
  * log but does keep track of the offset position and is the best way
287
301
  * to consume the HTTP `GET /v1/shape` api.
288
302
  *
@@ -297,6 +311,14 @@ export interface ShapeStreamInterface<T extends Row<unknown> = Row> {
297
311
  * })
298
312
  * ```
299
313
  *
314
+ * To use Server-Sent Events (SSE) for real-time updates:
315
+ * ```
316
+ * const stream = new ShapeStream({
317
+ * url: `http://localhost:3000/v1/shape`,
318
+ * experimentalLiveSse: true
319
+ * })
320
+ * ```
321
+ *
300
322
  * To abort the stream, abort the `signal`
301
323
  * passed in via the `ShapeStreamOptions`.
302
324
  * ```
@@ -323,6 +345,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
323
345
  #error: unknown = null
324
346
 
325
347
  readonly #fetchClient: typeof fetch
348
+ readonly #sseFetchClient: typeof fetch
326
349
  readonly #messageParser: MessageParser<T>
327
350
 
328
351
  readonly #subscribers = new Map<
@@ -334,6 +357,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
334
357
  >()
335
358
 
336
359
  #started = false
360
+ #state = `active` as `active` | `pause-requested` | `paused`
337
361
  #lastOffset: Offset
338
362
  #liveCacheBuster: string // Seconds since our Electric Epoch 😎
339
363
  #lastSyncedAt?: number // unix time
@@ -347,6 +371,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
347
371
  #tickPromise?: Promise<void>
348
372
  #tickPromiseResolver?: () => void
349
373
  #tickPromiseRejecter?: (reason?: unknown) => void
374
+ #messageChain = Promise.resolve<void[]>([]) // promise chain for incoming messages
350
375
 
351
376
  constructor(options: ShapeStreamOptions<GetExtensions<T>>) {
352
377
  this.options = { subscribe: true, ...options }
@@ -361,19 +386,35 @@ export class ShapeStream<T extends Row<unknown> = Row>
361
386
  options.fetchClient ??
362
387
  ((...args: Parameters<typeof fetch>) => fetch(...args))
363
388
 
364
- const fetchWithBackoffClient = createFetchWithBackoff(baseFetchClient, {
389
+ const backOffOpts = {
365
390
  ...(options.backoffOptions ?? BackoffDefaults),
366
391
  onFailedAttempt: () => {
367
392
  this.#connected = false
368
393
  options.backoffOptions?.onFailedAttempt?.()
369
394
  },
370
- })
395
+ }
396
+ const fetchWithBackoffClient = createFetchWithBackoff(
397
+ baseFetchClient,
398
+ backOffOpts
399
+ )
371
400
 
372
401
  this.#fetchClient = createFetchWithConsumedMessages(
373
402
  createFetchWithResponseHeadersCheck(
374
403
  createFetchWithChunkBuffer(fetchWithBackoffClient)
375
404
  )
376
405
  )
406
+
407
+ const sseFetchWithBackoffClient = createFetchWithBackoff(
408
+ baseFetchClient,
409
+ backOffOpts,
410
+ true
411
+ )
412
+
413
+ this.#sseFetchClient = createFetchWithResponseHeadersCheck(
414
+ createFetchWithChunkBuffer(sseFetchWithBackoffClient)
415
+ )
416
+
417
+ this.#subscribeToVisibilityChanges()
377
418
  }
378
419
 
379
420
  get shapeHandle() {
@@ -392,189 +433,11 @@ export class ShapeStream<T extends Row<unknown> = Row>
392
433
  return this.#lastOffset
393
434
  }
394
435
 
395
- async #start() {
396
- if (this.#started) throw new Error(`Cannot start stream twice`)
436
+ async #start(): Promise<void> {
397
437
  this.#started = true
398
438
 
399
439
  try {
400
- while (
401
- (!this.options.signal?.aborted && !this.#isUpToDate) ||
402
- this.options.subscribe
403
- ) {
404
- const { url, signal } = this.options
405
-
406
- // Resolve headers and params in parallel
407
- const [requestHeaders, params] = await Promise.all([
408
- resolveHeaders(this.options.headers),
409
- this.options.params
410
- ? toInternalParams(convertWhereParamsToObj(this.options.params))
411
- : undefined,
412
- ])
413
-
414
- // Validate params after resolution
415
- if (params) {
416
- validateParams(params)
417
- }
418
-
419
- const fetchUrl = new URL(url)
420
-
421
- // Add PostgreSQL-specific parameters
422
- if (params) {
423
- if (params.table)
424
- setQueryParam(fetchUrl, TABLE_QUERY_PARAM, params.table)
425
- if (params.where)
426
- setQueryParam(fetchUrl, WHERE_QUERY_PARAM, params.where)
427
- if (params.columns)
428
- setQueryParam(fetchUrl, COLUMNS_QUERY_PARAM, params.columns)
429
- if (params.replica)
430
- setQueryParam(fetchUrl, REPLICA_PARAM, params.replica)
431
- if (params.params)
432
- setQueryParam(fetchUrl, WHERE_PARAMS_PARAM, params.params)
433
-
434
- // Add any remaining custom parameters
435
- const customParams = { ...params }
436
- delete customParams.table
437
- delete customParams.where
438
- delete customParams.columns
439
- delete customParams.replica
440
- delete customParams.params
441
-
442
- for (const [key, value] of Object.entries(customParams)) {
443
- setQueryParam(fetchUrl, key, value)
444
- }
445
- }
446
-
447
- // Add Electric's internal parameters
448
- fetchUrl.searchParams.set(OFFSET_QUERY_PARAM, this.#lastOffset)
449
-
450
- if (this.#isUpToDate) {
451
- if (!this.#isRefreshing) {
452
- fetchUrl.searchParams.set(LIVE_QUERY_PARAM, `true`)
453
- }
454
- fetchUrl.searchParams.set(
455
- LIVE_CACHE_BUSTER_QUERY_PARAM,
456
- this.#liveCacheBuster
457
- )
458
- }
459
-
460
- if (this.#shapeHandle) {
461
- // This should probably be a header for better cache breaking?
462
- fetchUrl.searchParams.set(
463
- SHAPE_HANDLE_QUERY_PARAM,
464
- this.#shapeHandle!
465
- )
466
- }
467
-
468
- // sort query params in-place for stable URLs and improved cache hits
469
- fetchUrl.searchParams.sort()
470
-
471
- // Create a new AbortController for this request
472
- this.#requestAbortController = new AbortController()
473
-
474
- // If user provided a signal, listen to it and pass on the reason for the abort
475
- let abortListener: (() => void) | undefined
476
- if (signal) {
477
- abortListener = () => {
478
- this.#requestAbortController?.abort(signal.reason)
479
- }
480
- signal.addEventListener(`abort`, abortListener, { once: true })
481
- if (signal.aborted) {
482
- // If the signal is already aborted, abort the request immediately
483
- this.#requestAbortController?.abort(signal.reason)
484
- }
485
- }
486
-
487
- let response!: Response
488
- try {
489
- response = await this.#fetchClient(fetchUrl.toString(), {
490
- signal: this.#requestAbortController.signal,
491
- headers: requestHeaders,
492
- })
493
- this.#connected = true
494
- } catch (e) {
495
- // Handle abort error triggered by refresh
496
- if (
497
- (e instanceof FetchError || e instanceof FetchBackoffAbortError) &&
498
- this.#requestAbortController.signal.aborted &&
499
- this.#requestAbortController.signal.reason ===
500
- FORCE_DISCONNECT_AND_REFRESH
501
- ) {
502
- // Loop back to the top of the while loop to start a new request
503
- continue
504
- }
505
-
506
- if (e instanceof FetchBackoffAbortError) break // interrupted
507
- if (!(e instanceof FetchError)) throw e // should never happen
508
-
509
- if (e.status == 409) {
510
- // Upon receiving a 409, we should start from scratch
511
- // with the newly provided shape handle
512
- const newShapeHandle = e.headers[SHAPE_HANDLE_HEADER]
513
- this.#reset(newShapeHandle)
514
- await this.#publish(e.json as Message<T>[])
515
- continue
516
- } else {
517
- // Notify subscribers
518
- this.#sendErrorToSubscribers(e)
519
-
520
- // errors that have reached this point are not actionable without
521
- // additional user input, such as 400s or failures to read the
522
- // body of a response, so we exit the loop
523
- throw e
524
- }
525
- } finally {
526
- if (abortListener && signal) {
527
- signal.removeEventListener(`abort`, abortListener)
528
- }
529
- this.#requestAbortController = undefined
530
- }
531
-
532
- const { headers, status } = response
533
- const shapeHandle = headers.get(SHAPE_HANDLE_HEADER)
534
- if (shapeHandle) {
535
- this.#shapeHandle = shapeHandle
536
- }
537
-
538
- const lastOffset = headers.get(CHUNK_LAST_OFFSET_HEADER)
539
- if (lastOffset) {
540
- this.#lastOffset = lastOffset as Offset
541
- }
542
-
543
- const liveCacheBuster = headers.get(LIVE_CACHE_BUSTER_HEADER)
544
- if (liveCacheBuster) {
545
- this.#liveCacheBuster = liveCacheBuster
546
- }
547
-
548
- const getSchema = (): Schema => {
549
- const schemaHeader = headers.get(SHAPE_SCHEMA_HEADER)
550
- return schemaHeader ? JSON.parse(schemaHeader) : {}
551
- }
552
- this.#schema = this.#schema ?? getSchema()
553
-
554
- // NOTE: 204s are deprecated, the Electric server should not
555
- // send these in latest versions but this is here for backwards
556
- // compatibility
557
- if (status === 204) {
558
- // There's no content so we are live and up to date
559
- this.#lastSyncedAt = Date.now()
560
- }
561
-
562
- const messages = (await response.text()) || `[]`
563
- const batch = this.#messageParser.parse(messages, this.#schema)
564
-
565
- // Update isUpToDate
566
- if (batch.length > 0) {
567
- const lastMessage = batch[batch.length - 1]
568
- if (isUpToDateMessage(lastMessage)) {
569
- this.#lastSyncedAt = Date.now()
570
- this.#isUpToDate = true
571
- }
572
-
573
- await this.#publish(batch)
574
- }
575
-
576
- this.#tickPromiseResolver?.()
577
- }
440
+ await this.#requestShape()
578
441
  } catch (err) {
579
442
  this.#error = err
580
443
  if (this.#onError) {
@@ -605,6 +468,338 @@ export class ShapeStream<T extends Row<unknown> = Row>
605
468
  }
606
469
  }
607
470
 
471
+ async #requestShape(): Promise<void> {
472
+ if (this.#state === `pause-requested`) {
473
+ this.#state = `paused`
474
+
475
+ return
476
+ }
477
+
478
+ if (
479
+ !this.options.subscribe &&
480
+ (this.options.signal?.aborted || this.#isUpToDate)
481
+ ) {
482
+ return
483
+ }
484
+
485
+ const resumingFromPause = this.#state === `paused`
486
+ this.#state = `active`
487
+
488
+ const { url, signal } = this.options
489
+ const { fetchUrl, requestHeaders } = await this.#constructUrl(
490
+ url,
491
+ resumingFromPause
492
+ )
493
+ const abortListener = await this.#createAbortListener(signal)
494
+ const requestAbortController = this.#requestAbortController! // we know that it is not undefined because it is set by `this.#createAbortListener`
495
+
496
+ try {
497
+ await this.#fetchShape({
498
+ fetchUrl,
499
+ requestAbortController,
500
+ headers: requestHeaders,
501
+ resumingFromPause: true,
502
+ })
503
+ } catch (e) {
504
+ // Handle abort error triggered by refresh
505
+ if (
506
+ (e instanceof FetchError || e instanceof FetchBackoffAbortError) &&
507
+ requestAbortController.signal.aborted &&
508
+ requestAbortController.signal.reason === FORCE_DISCONNECT_AND_REFRESH
509
+ ) {
510
+ // Start a new request
511
+ return this.#requestShape()
512
+ }
513
+
514
+ if (e instanceof FetchBackoffAbortError) {
515
+ if (
516
+ requestAbortController.signal.aborted &&
517
+ requestAbortController.signal.reason === PAUSE_STREAM
518
+ ) {
519
+ this.#state = `paused`
520
+ }
521
+ return // interrupted
522
+ }
523
+ if (!(e instanceof FetchError)) throw e // should never happen
524
+
525
+ if (e.status == 409) {
526
+ // Upon receiving a 409, we should start from scratch
527
+ // with the newly provided shape handle
528
+ const newShapeHandle = e.headers[SHAPE_HANDLE_HEADER]
529
+ this.#reset(newShapeHandle)
530
+ await this.#publish(e.json as Message<T>[])
531
+ return this.#requestShape()
532
+ } else {
533
+ // Notify subscribers
534
+ this.#sendErrorToSubscribers(e)
535
+
536
+ // errors that have reached this point are not actionable without
537
+ // additional user input, such as 400s or failures to read the
538
+ // body of a response, so we exit the loop
539
+ throw e
540
+ }
541
+ } finally {
542
+ if (abortListener && signal) {
543
+ signal.removeEventListener(`abort`, abortListener)
544
+ }
545
+ this.#requestAbortController = undefined
546
+ }
547
+
548
+ this.#tickPromiseResolver?.()
549
+ return this.#requestShape()
550
+ }
551
+
552
+ async #constructUrl(url: string, resumingFromPause: boolean) {
553
+ // Resolve headers and params in parallel
554
+ const [requestHeaders, params] = await Promise.all([
555
+ resolveHeaders(this.options.headers),
556
+ this.options.params
557
+ ? toInternalParams(convertWhereParamsToObj(this.options.params))
558
+ : undefined,
559
+ ])
560
+
561
+ // Validate params after resolution
562
+ if (params) {
563
+ validateParams(params)
564
+ }
565
+
566
+ const fetchUrl = new URL(url)
567
+
568
+ // Add PostgreSQL-specific parameters
569
+ if (params) {
570
+ if (params.table) setQueryParam(fetchUrl, TABLE_QUERY_PARAM, params.table)
571
+ if (params.where) setQueryParam(fetchUrl, WHERE_QUERY_PARAM, params.where)
572
+ if (params.columns)
573
+ setQueryParam(fetchUrl, COLUMNS_QUERY_PARAM, params.columns)
574
+ if (params.replica) setQueryParam(fetchUrl, REPLICA_PARAM, params.replica)
575
+ if (params.params)
576
+ setQueryParam(fetchUrl, WHERE_PARAMS_PARAM, params.params)
577
+
578
+ // Add any remaining custom parameters
579
+ const customParams = { ...params }
580
+ delete customParams.table
581
+ delete customParams.where
582
+ delete customParams.columns
583
+ delete customParams.replica
584
+ delete customParams.params
585
+
586
+ for (const [key, value] of Object.entries(customParams)) {
587
+ setQueryParam(fetchUrl, key, value)
588
+ }
589
+ }
590
+
591
+ // Add Electric's internal parameters
592
+ fetchUrl.searchParams.set(OFFSET_QUERY_PARAM, this.#lastOffset)
593
+
594
+ if (this.#isUpToDate) {
595
+ // If we are resuming from a paused state, we don't want to perform a live request
596
+ // because it could be a long poll that holds for 20sec
597
+ // and during all that time `isConnected` will be false
598
+ if (!this.#isRefreshing && !resumingFromPause) {
599
+ fetchUrl.searchParams.set(LIVE_QUERY_PARAM, `true`)
600
+ }
601
+ fetchUrl.searchParams.set(
602
+ LIVE_CACHE_BUSTER_QUERY_PARAM,
603
+ this.#liveCacheBuster
604
+ )
605
+ }
606
+
607
+ if (this.#shapeHandle) {
608
+ // This should probably be a header for better cache breaking?
609
+ fetchUrl.searchParams.set(SHAPE_HANDLE_QUERY_PARAM, this.#shapeHandle!)
610
+ }
611
+
612
+ // sort query params in-place for stable URLs and improved cache hits
613
+ fetchUrl.searchParams.sort()
614
+
615
+ return {
616
+ fetchUrl,
617
+ requestHeaders,
618
+ }
619
+ }
620
+
621
+ async #createAbortListener(signal?: AbortSignal) {
622
+ // Create a new AbortController for this request
623
+ this.#requestAbortController = new AbortController()
624
+
625
+ // If user provided a signal, listen to it and pass on the reason for the abort
626
+ if (signal) {
627
+ const abortListener = () => {
628
+ this.#requestAbortController?.abort(signal.reason)
629
+ }
630
+
631
+ signal.addEventListener(`abort`, abortListener, { once: true })
632
+
633
+ if (signal.aborted) {
634
+ // If the signal is already aborted, abort the request immediately
635
+ this.#requestAbortController?.abort(signal.reason)
636
+ }
637
+
638
+ return abortListener
639
+ }
640
+ }
641
+
642
+ async #onInitialResponse(response: Response) {
643
+ const { headers, status } = response
644
+ const shapeHandle = headers.get(SHAPE_HANDLE_HEADER)
645
+ if (shapeHandle) {
646
+ this.#shapeHandle = shapeHandle
647
+ }
648
+
649
+ const lastOffset = headers.get(CHUNK_LAST_OFFSET_HEADER)
650
+ if (lastOffset) {
651
+ this.#lastOffset = lastOffset as Offset
652
+ }
653
+
654
+ const liveCacheBuster = headers.get(LIVE_CACHE_BUSTER_HEADER)
655
+ if (liveCacheBuster) {
656
+ this.#liveCacheBuster = liveCacheBuster
657
+ }
658
+
659
+ const getSchema = (): Schema => {
660
+ const schemaHeader = headers.get(SHAPE_SCHEMA_HEADER)
661
+ return schemaHeader ? JSON.parse(schemaHeader) : {}
662
+ }
663
+ this.#schema = this.#schema ?? getSchema()
664
+
665
+ // NOTE: 204s are deprecated, the Electric server should not
666
+ // send these in latest versions but this is here for backwards
667
+ // compatibility
668
+ if (status === 204) {
669
+ // There's no content so we are live and up to date
670
+ this.#lastSyncedAt = Date.now()
671
+ }
672
+ }
673
+
674
+ async #onMessages(messages: string, schema: Schema, isSseMessage = false) {
675
+ const batch = this.#messageParser.parse(messages, schema)
676
+
677
+ // Update isUpToDate
678
+ if (batch.length > 0) {
679
+ const lastMessage = batch[batch.length - 1]
680
+ if (isUpToDateMessage(lastMessage)) {
681
+ if (isSseMessage) {
682
+ // Only use the offset from the up-to-date message if this was an SSE message.
683
+ // If we would use this offset from a regular fetch, then it will be wrong
684
+ // and we will get an "offset is out of bounds for this shape" error
685
+ const offset = getOffset(lastMessage)
686
+ if (offset) {
687
+ this.#lastOffset = offset
688
+ }
689
+ }
690
+ this.#lastSyncedAt = Date.now()
691
+ this.#isUpToDate = true
692
+ }
693
+
694
+ await this.#publish(batch)
695
+ }
696
+ }
697
+
698
+ /**
699
+ * Fetches the shape from the server using either long polling or SSE.
700
+ * Upon receiving a successfull response, the #onInitialResponse method is called.
701
+ * Afterwards, the #onMessages method is called for all the incoming updates.
702
+ * @param opts - The options for the request.
703
+ * @returns A promise that resolves when the request is complete (i.e. the long poll receives a response or the SSE connection is closed).
704
+ */
705
+ async #fetchShape(opts: {
706
+ fetchUrl: URL
707
+ requestAbortController: AbortController
708
+ headers: Record<string, string>
709
+ resumingFromPause?: boolean
710
+ }): Promise<void> {
711
+ if (
712
+ this.#isUpToDate &&
713
+ this.options.experimentalLiveSse &&
714
+ !this.#isRefreshing &&
715
+ !opts.resumingFromPause
716
+ ) {
717
+ opts.fetchUrl.searchParams.set(EXPERIMENTAL_LIVE_SSE_QUERY_PARAM, `true`)
718
+ return this.#requestShapeSSE(opts)
719
+ }
720
+
721
+ return this.#requestShapeLongPoll(opts)
722
+ }
723
+
724
+ async #requestShapeLongPoll(opts: {
725
+ fetchUrl: URL
726
+ requestAbortController: AbortController
727
+ headers: Record<string, string>
728
+ }): Promise<void> {
729
+ const { fetchUrl, requestAbortController, headers } = opts
730
+ const response = await this.#fetchClient(fetchUrl.toString(), {
731
+ signal: requestAbortController.signal,
732
+ headers,
733
+ })
734
+
735
+ this.#connected = true
736
+ await this.#onInitialResponse(response)
737
+
738
+ const schema = this.#schema! // we know that it is not undefined because it is set by `this.#onInitialResponse`
739
+ const res = await response.text()
740
+ const messages = res || `[]`
741
+
742
+ await this.#onMessages(messages, schema)
743
+ }
744
+
745
+ async #requestShapeSSE(opts: {
746
+ fetchUrl: URL
747
+ requestAbortController: AbortController
748
+ headers: Record<string, string>
749
+ }): Promise<void> {
750
+ const { fetchUrl, requestAbortController, headers } = opts
751
+ const fetch = this.#sseFetchClient
752
+ try {
753
+ await fetchEventSource(fetchUrl.toString(), {
754
+ headers,
755
+ fetch,
756
+ onopen: async (response: Response) => {
757
+ this.#connected = true
758
+ await this.#onInitialResponse(response)
759
+ },
760
+ onmessage: (event: EventSourceMessage) => {
761
+ if (event.data) {
762
+ // Process the SSE message
763
+ // The event.data is a single JSON object, so we wrap it in an array
764
+ const messages = `[${event.data}]`
765
+ const schema = this.#schema! // we know that it is not undefined because it is set in onopen when we call this.#onInitialResponse
766
+ this.#onMessages(messages, schema, true)
767
+ }
768
+ },
769
+ onerror: (error: Error) => {
770
+ // rethrow to close the SSE connection
771
+ throw error
772
+ },
773
+ signal: requestAbortController.signal,
774
+ })
775
+ } catch (error) {
776
+ if (requestAbortController.signal.aborted) {
777
+ // During an SSE request, the fetch might have succeeded
778
+ // and we are parsing the incoming stream.
779
+ // If the abort happens while we're parsing the stream,
780
+ // then it won't be caught by our `createFetchWithBackoff` wrapper
781
+ // and instead we will get a raw AbortError here
782
+ // which we need to turn into a `FetchBackoffAbortError`
783
+ // such that #start handles it correctly.`
784
+ throw new FetchBackoffAbortError()
785
+ }
786
+ throw error
787
+ }
788
+ }
789
+
790
+ #pause() {
791
+ if (this.#started && this.#state === `active`) {
792
+ this.#state = `pause-requested`
793
+ this.#requestAbortController?.abort(PAUSE_STREAM)
794
+ }
795
+ }
796
+
797
+ #resume() {
798
+ if (this.#started && this.#state === `paused`) {
799
+ this.#start()
800
+ }
801
+ }
802
+
608
803
  subscribe(
609
804
  callback: (messages: Message<T>[]) => MaybePromise<void>,
610
805
  onError: (error: Error) => void = () => {}
@@ -648,6 +843,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
648
843
  return this.#started
649
844
  }
650
845
 
846
+ isPaused(): boolean {
847
+ return this.#state === `paused`
848
+ }
849
+
651
850
  /** Await the next tick of the request loop */
652
851
  async #nextTick() {
653
852
  if (this.#tickPromise) {
@@ -682,18 +881,26 @@ export class ShapeStream<T extends Row<unknown> = Row>
682
881
  this.#isRefreshing = false
683
882
  }
684
883
 
685
- async #publish(messages: Message<T>[]): Promise<void> {
686
- await Promise.all(
687
- Array.from(this.#subscribers.values()).map(async ([callback, __]) => {
688
- try {
689
- await callback(messages)
690
- } catch (err) {
691
- queueMicrotask(() => {
692
- throw err
693
- })
694
- }
695
- })
884
+ async #publish(messages: Message<T>[]): Promise<void[]> {
885
+ // We process messages asynchronously
886
+ // but SSE's `onmessage` handler is synchronous.
887
+ // We use a promise chain to ensure that the handlers
888
+ // execute sequentially in the order the messages were received.
889
+ this.#messageChain = this.#messageChain.then(() =>
890
+ Promise.all(
891
+ Array.from(this.#subscribers.values()).map(async ([callback, __]) => {
892
+ try {
893
+ await callback(messages)
894
+ } catch (err) {
895
+ queueMicrotask(() => {
896
+ throw err
897
+ })
898
+ }
899
+ })
900
+ )
696
901
  )
902
+
903
+ return this.#messageChain
697
904
  }
698
905
 
699
906
  #sendErrorToSubscribers(error: Error) {
@@ -702,6 +909,24 @@ export class ShapeStream<T extends Row<unknown> = Row>
702
909
  })
703
910
  }
704
911
 
912
+ #subscribeToVisibilityChanges() {
913
+ if (
914
+ typeof document === `object` &&
915
+ typeof document.hidden === `boolean` &&
916
+ typeof document.addEventListener === `function`
917
+ ) {
918
+ const visibilityHandler = () => {
919
+ if (document.hidden) {
920
+ this.#pause()
921
+ } else {
922
+ this.#resume()
923
+ }
924
+ }
925
+
926
+ document.addEventListener(`visibilitychange`, visibilityHandler)
927
+ }
928
+ }
929
+
705
930
  /**
706
931
  * Resets the state of the stream, optionally with a provided
707
932
  * shape handle
@@ -772,8 +997,8 @@ function setQueryParam(
772
997
  }
773
998
 
774
999
  function convertWhereParamsToObj(
775
- allPgParams: ExternalParamsRecord
776
- ): ExternalParamsRecord {
1000
+ allPgParams: ExternalParamsRecord<Row>
1001
+ ): ExternalParamsRecord<Row> {
777
1002
  if (Array.isArray(allPgParams.params)) {
778
1003
  return {
779
1004
  ...allPgParams,