@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/dist/cjs/index.cjs +301 -140
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +29 -8
- package/dist/index.browser.mjs +5 -5
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.ts +29 -8
- package/dist/index.legacy-esm.js +287 -140
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +303 -140
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -3
- package/src/client.ts +432 -207
- package/src/constants.ts +2 -0
- package/src/fetch.ts +12 -2
- package/src/helpers.ts +13 -1
- package/src/types.ts +4 -1
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?:
|
|
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
|
|
104
|
-
|
|
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: (
|
|
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
|
|
285
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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,
|