@electric-sql/client
Version:
Postgres everywhere - your data, in sync, wherever you need it.
1 lines • 120 kB
Source Map (JSON)
{"version":3,"sources":["../../src/index.ts","../../src/error.ts","../../src/parser.ts","../../src/helpers.ts","../../src/constants.ts","../../src/fetch.ts","../../src/client.ts","../../src/expired-shapes-cache.ts","../../src/snapshot-tracker.ts","../../src/shape.ts"],"sourcesContent":["export * from './client'\nexport * from './shape'\nexport * from './types'\nexport {\n isChangeMessage,\n isControlMessage,\n isVisibleInSnapshot,\n} from './helpers'\nexport { FetchError } from './error'\nexport { type BackoffOptions, BackoffDefaults } from './fetch'\nexport { ELECTRIC_PROTOCOL_QUERY_PARAMS } from './constants'\n","export class FetchError extends Error {\n status: number\n text?: string\n json?: object\n headers: Record<string, string>\n\n constructor(\n status: number,\n text: string | undefined,\n json: object | undefined,\n headers: Record<string, string>,\n public url: string,\n message?: string\n ) {\n super(\n message ||\n `HTTP Error ${status} at ${url}: ${text ?? JSON.stringify(json)}`\n )\n this.name = `FetchError`\n this.status = status\n this.text = text\n this.json = json\n this.headers = headers\n }\n\n static async fromResponse(\n response: Response,\n url: string\n ): Promise<FetchError> {\n const status = response.status\n const headers = Object.fromEntries([...response.headers.entries()])\n let text: string | undefined = undefined\n let json: object | undefined = undefined\n\n const contentType = response.headers.get(`content-type`)\n if (!response.bodyUsed) {\n if (contentType && contentType.includes(`application/json`)) {\n json = (await response.json()) as object\n } else {\n text = await response.text()\n }\n }\n\n return new FetchError(status, text, json, headers, url)\n }\n}\n\nexport class FetchBackoffAbortError extends Error {\n constructor() {\n super(`Fetch with backoff aborted`)\n this.name = `FetchBackoffAbortError`\n }\n}\n\nexport class InvalidShapeOptionsError extends Error {\n constructor(message: string) {\n super(message)\n this.name = `InvalidShapeOptionsError`\n }\n}\n\nexport class MissingShapeUrlError extends Error {\n constructor() {\n super(`Invalid shape options: missing required url parameter`)\n this.name = `MissingShapeUrlError`\n }\n}\n\nexport class InvalidSignalError extends Error {\n constructor() {\n super(`Invalid signal option. It must be an instance of AbortSignal.`)\n this.name = `InvalidSignalError`\n }\n}\n\nexport class MissingShapeHandleError extends Error {\n constructor() {\n super(\n `shapeHandle is required if this isn't an initial fetch (i.e. offset > -1)`\n )\n this.name = `MissingShapeHandleError`\n }\n}\n\nexport class ReservedParamError extends Error {\n constructor(reservedParams: string[]) {\n super(\n `Cannot use reserved Electric parameter names in custom params: ${reservedParams.join(`, `)}`\n )\n this.name = `ReservedParamError`\n }\n}\n\nexport class ParserNullValueError extends Error {\n constructor(columnName: string) {\n super(`Column \"${columnName ?? `unknown`}\" does not allow NULL values`)\n this.name = `ParserNullValueError`\n }\n}\n\nexport class ShapeStreamAlreadyRunningError extends Error {\n constructor() {\n super(`ShapeStream is already running`)\n this.name = `ShapeStreamAlreadyRunningError`\n }\n}\n\nexport class MissingHeadersError extends Error {\n constructor(url: string, missingHeaders: Array<string>) {\n let msg = `The response for the shape request to ${url} didn't include the following required headers:\\n`\n missingHeaders.forEach((h) => {\n msg += `- ${h}\\n`\n })\n msg += `\\nThis is often due to a proxy not setting CORS correctly so that all Electric headers can be read by the client.`\n msg += `\\nFor more information visit the troubleshooting guide: /docs/guides/troubleshooting/missing-headers`\n super(msg)\n }\n}\n","import { ColumnInfo, GetExtensions, Row, Schema, Value } from './types'\nimport { ParserNullValueError } from './error'\n\ntype Token = string\ntype NullableToken = Token | null\nexport type ParseFunction<Extensions = never> = (\n value: Token,\n additionalInfo?: Omit<ColumnInfo, `type` | `dims`>\n) => Value<Extensions>\ntype NullableParseFunction<Extensions = never> = (\n value: NullableToken,\n additionalInfo?: Omit<ColumnInfo, `type` | `dims`>\n) => Value<Extensions>\n/**\n * @typeParam Extensions - Additional types that can be parsed by this parser beyond the standard SQL types.\n * Defaults to no additional types.\n */\nexport type Parser<Extensions = never> = {\n [key: string]: ParseFunction<Extensions>\n}\n\nexport type TransformFunction<Extensions = never> = (\n message: Row<Extensions>\n) => Row<Extensions>\n\nconst parseNumber = (value: string) => Number(value)\nconst parseBool = (value: string) => value === `true` || value === `t`\nconst parseBigInt = (value: string) => BigInt(value)\nconst parseJson = (value: string) => JSON.parse(value)\nconst identityParser: ParseFunction = (v: string) => v\n\nexport const defaultParser: Parser = {\n int2: parseNumber,\n int4: parseNumber,\n int8: parseBigInt,\n bool: parseBool,\n float4: parseNumber,\n float8: parseNumber,\n json: parseJson,\n jsonb: parseJson,\n}\n\n// Taken from: https://github.com/electric-sql/pglite/blob/main/packages/pglite/src/types.ts#L233-L279\nexport function pgArrayParser<Extensions>(\n value: Token,\n parser?: NullableParseFunction<Extensions>\n): Value<Extensions> {\n let i = 0\n let char = null\n let str = ``\n let quoted = false\n let last = 0\n let p: string | undefined = undefined\n\n function extractValue(x: Token, start: number, end: number) {\n let val: Token | null = x.slice(start, end)\n val = val === `NULL` ? null : val\n return parser ? parser(val) : val\n }\n\n function loop(x: string): Array<Value<Extensions>> {\n const xs = []\n for (; i < x.length; i++) {\n char = x[i]\n if (quoted) {\n if (char === `\\\\`) {\n str += x[++i]\n } else if (char === `\"`) {\n xs.push(parser ? parser(str) : str)\n str = ``\n quoted = x[i + 1] === `\"`\n last = i + 2\n } else {\n str += char\n }\n } else if (char === `\"`) {\n quoted = true\n } else if (char === `{`) {\n last = ++i\n xs.push(loop(x))\n } else if (char === `}`) {\n quoted = false\n last < i && xs.push(extractValue(x, last, i))\n last = i + 1\n break\n } else if (char === `,` && p !== `}` && p !== `\"`) {\n xs.push(extractValue(x, last, i))\n last = i + 1\n }\n p = char\n }\n last < i && xs.push(xs.push(extractValue(x, last, i + 1)))\n return xs\n }\n\n return loop(value)[0]\n}\n\nexport class MessageParser<T extends Row<unknown>> {\n private parser: Parser<GetExtensions<T>>\n private transformer?: TransformFunction<GetExtensions<T>>\n constructor(\n parser?: Parser<GetExtensions<T>>,\n transformer?: TransformFunction<GetExtensions<T>>\n ) {\n // Merge the provided parser with the default parser\n // to use the provided parser whenever defined\n // and otherwise fall back to the default parser\n this.parser = { ...defaultParser, ...parser }\n this.transformer = transformer\n }\n\n parse<Result>(messages: string, schema: Schema): Result {\n return JSON.parse(messages, (key, value) => {\n // typeof value === `object` && value !== null\n // is needed because there could be a column named `value`\n // and the value associated to that column will be a string or null.\n // But `typeof null === 'object'` so we need to make an explicit check.\n // We also parse the `old_value`, which appears on updates when `replica=full`.\n if (\n (key === `value` || key === `old_value`) &&\n typeof value === `object` &&\n value !== null\n ) {\n // Parse the row values\n const row = value as Record<string, Value<GetExtensions<T>>>\n Object.keys(row).forEach((key) => {\n row[key] = this.parseRow(key, row[key] as NullableToken, schema)\n })\n\n if (this.transformer) value = this.transformer(value)\n }\n return value\n }) as Result\n }\n\n // Parses the message values using the provided parser based on the schema information\n private parseRow(\n key: string,\n value: NullableToken,\n schema: Schema\n ): Value<GetExtensions<T>> {\n const columnInfo = schema[key]\n if (!columnInfo) {\n // We don't have information about the value\n // so we just return it\n return value\n }\n\n // Copy the object but don't include `dimensions` and `type`\n const { type: typ, dims: dimensions, ...additionalInfo } = columnInfo\n\n // Pick the right parser for the type\n // and support parsing null values if needed\n // if no parser is provided for the given type, just return the value as is\n const typeParser = this.parser[typ] ?? identityParser\n const parser = makeNullableParser(typeParser, columnInfo, key)\n\n if (dimensions && dimensions > 0) {\n // It's an array\n const nullablePgArrayParser = makeNullableParser(\n (value, _) => pgArrayParser(value, parser),\n columnInfo,\n key\n )\n return nullablePgArrayParser(value)\n }\n\n return parser(value, additionalInfo)\n }\n}\n\nfunction makeNullableParser<Extensions>(\n parser: ParseFunction<Extensions>,\n columnInfo: ColumnInfo,\n columnName?: string\n): NullableParseFunction<Extensions> {\n const isNullable = !(columnInfo.not_null ?? false)\n // The sync service contains `null` value for a column whose value is NULL\n // but if the column value is an array that contains a NULL value\n // then it will be included in the array string as `NULL`, e.g.: `\"{1,NULL,3}\"`\n return (value: NullableToken) => {\n if (value === null) {\n if (!isNullable) {\n throw new ParserNullValueError(columnName ?? `unknown`)\n }\n return null\n }\n return parser(value, columnInfo)\n }\n}\n","import {\n ChangeMessage,\n ControlMessage,\n Message,\n NormalizedPgSnapshot,\n Offset,\n PostgresSnapshot,\n Row,\n} from './types'\n\n/**\n * Type guard for checking {@link Message} is {@link ChangeMessage}.\n *\n * See [TS docs](https://www.typescriptlang.org/docs/handbook/advanced-types.html#user-defined-type-guards)\n * for information on how to use type guards.\n *\n * @param message - the message to check\n * @returns true if the message is a {@link ChangeMessage}\n *\n * @example\n * ```ts\n * if (isChangeMessage(message)) {\n * const msgChng: ChangeMessage = message // Ok\n * const msgCtrl: ControlMessage = message // Err, type mismatch\n * }\n * ```\n */\nexport function isChangeMessage<T extends Row<unknown> = Row>(\n message: Message<T>\n): message is ChangeMessage<T> {\n return `key` in message\n}\n\n/**\n * Type guard for checking {@link Message} is {@link ControlMessage}.\n *\n * See [TS docs](https://www.typescriptlang.org/docs/handbook/advanced-types.html#user-defined-type-guards)\n * for information on how to use type guards.\n *\n * @param message - the message to check\n * @returns true if the message is a {@link ControlMessage}\n *\n * * @example\n * ```ts\n * if (isControlMessage(message)) {\n * const msgChng: ChangeMessage = message // Err, type mismatch\n * const msgCtrl: ControlMessage = message // Ok\n * }\n * ```\n */\nexport function isControlMessage<T extends Row<unknown> = Row>(\n message: Message<T>\n): message is ControlMessage {\n return !isChangeMessage(message)\n}\n\nexport function isUpToDateMessage<T extends Row<unknown> = Row>(\n message: Message<T>\n): message is ControlMessage & { up_to_date: true } {\n return isControlMessage(message) && message.headers.control === `up-to-date`\n}\n\n/**\n * Parses the LSN from the up-to-date message and turns it into an offset.\n * The LSN is only present in the up-to-date control message when in SSE mode.\n * If we are not in SSE mode this function will return undefined.\n */\nexport function getOffset(message: ControlMessage): Offset | undefined {\n const lsn = message.headers.global_last_seen_lsn\n if (!lsn) {\n return\n }\n return `${lsn}_0` as Offset\n}\n\n/**\n * Checks if a transaction is visible in a snapshot.\n *\n * @param txid - the transaction id to check\n * @param snapshot - the information about the snapshot\n * @returns true if the transaction is visible in the snapshot\n */\nexport function isVisibleInSnapshot(\n txid: number | bigint | `${bigint}`,\n snapshot: PostgresSnapshot | NormalizedPgSnapshot\n): boolean {\n const xid = BigInt(txid)\n const xmin = BigInt(snapshot.xmin)\n const xmax = BigInt(snapshot.xmax)\n const xip = snapshot.xip_list.map(BigInt)\n\n // If the transaction id is less than the minimum transaction id, it is visible in the snapshot.\n // If the transaction id is less than the maximum transaction id and not in the list of active\n // transactions at the time of the snapshot, it has been committed before the snapshot was taken\n // and is therefore visible in the snapshot.\n // Otherwise, it is not visible in the snapshot.\n\n return xid < xmin || (xid < xmax && !xip.includes(xid))\n}\n","export const LIVE_CACHE_BUSTER_HEADER = `electric-cursor`\nexport const SHAPE_HANDLE_HEADER = `electric-handle`\nexport const CHUNK_LAST_OFFSET_HEADER = `electric-offset`\nexport const SHAPE_SCHEMA_HEADER = `electric-schema`\nexport const CHUNK_UP_TO_DATE_HEADER = `electric-up-to-date`\nexport const COLUMNS_QUERY_PARAM = `columns`\nexport const LIVE_CACHE_BUSTER_QUERY_PARAM = `cursor`\nexport const EXPIRED_HANDLE_QUERY_PARAM = `expired_handle`\nexport const SHAPE_HANDLE_QUERY_PARAM = `handle`\nexport const LIVE_QUERY_PARAM = `live`\nexport const OFFSET_QUERY_PARAM = `offset`\nexport const TABLE_QUERY_PARAM = `table`\nexport const WHERE_QUERY_PARAM = `where`\nexport const REPLICA_PARAM = `replica`\nexport const WHERE_PARAMS_PARAM = `params`\n/**\n * @deprecated Use {@link LIVE_SSE_QUERY_PARAM} instead.\n */\nexport const EXPERIMENTAL_LIVE_SSE_QUERY_PARAM = `experimental_live_sse`\nexport const LIVE_SSE_QUERY_PARAM = `live_sse`\nexport const FORCE_DISCONNECT_AND_REFRESH = `force-disconnect-and-refresh`\nexport const PAUSE_STREAM = `pause-stream`\nexport const LOG_MODE_QUERY_PARAM = `log`\nexport const SUBSET_PARAM_WHERE = `subset__where`\nexport const SUBSET_PARAM_LIMIT = `subset__limit`\nexport const SUBSET_PARAM_OFFSET = `subset__offset`\nexport const SUBSET_PARAM_ORDER_BY = `subset__order_by`\nexport const SUBSET_PARAM_WHERE_PARAMS = `subset__params`\n\n// Query parameters that should be passed through when proxying Electric requests\nexport const ELECTRIC_PROTOCOL_QUERY_PARAMS: Array<string> = [\n LIVE_QUERY_PARAM,\n LIVE_SSE_QUERY_PARAM,\n SHAPE_HANDLE_QUERY_PARAM,\n OFFSET_QUERY_PARAM,\n LIVE_CACHE_BUSTER_QUERY_PARAM,\n EXPIRED_HANDLE_QUERY_PARAM,\n LOG_MODE_QUERY_PARAM,\n SUBSET_PARAM_WHERE,\n SUBSET_PARAM_LIMIT,\n SUBSET_PARAM_OFFSET,\n SUBSET_PARAM_ORDER_BY,\n SUBSET_PARAM_WHERE_PARAMS,\n]\n","import {\n CHUNK_LAST_OFFSET_HEADER,\n CHUNK_UP_TO_DATE_HEADER,\n LIVE_QUERY_PARAM,\n OFFSET_QUERY_PARAM,\n SHAPE_HANDLE_HEADER,\n SHAPE_HANDLE_QUERY_PARAM,\n SUBSET_PARAM_LIMIT,\n SUBSET_PARAM_OFFSET,\n SUBSET_PARAM_ORDER_BY,\n SUBSET_PARAM_WHERE,\n SUBSET_PARAM_WHERE_PARAMS,\n} from './constants'\nimport {\n FetchError,\n FetchBackoffAbortError,\n MissingHeadersError,\n} from './error'\n\n// Some specific 4xx and 5xx HTTP status codes that we definitely\n// want to retry\nconst HTTP_RETRY_STATUS_CODES = [429]\n\nexport interface BackoffOptions {\n /**\n * Initial delay before retrying in milliseconds\n */\n initialDelay: number\n /**\n * Maximum retry delay in milliseconds\n * After reaching this, delay stays constant (e.g., retry every 60s)\n */\n maxDelay: number\n multiplier: number\n onFailedAttempt?: () => void\n debug?: boolean\n /**\n * Maximum number of retry attempts before giving up.\n * Set to Infinity (default) for indefinite retries - needed for offline scenarios\n * where clients may go offline and come back later.\n */\n maxRetries?: number\n}\n\nexport const BackoffDefaults = {\n initialDelay: 100,\n maxDelay: 60_000, // Cap at 60s - reasonable for long-lived connections\n multiplier: 1.3,\n maxRetries: Infinity, // Retry forever - clients may go offline and come back\n}\n\n/**\n * Parse Retry-After header value and return delay in milliseconds\n * Supports both delta-seconds format and HTTP-date format\n * Returns 0 if header is not present or invalid\n */\nexport function parseRetryAfterHeader(retryAfter: string | undefined): number {\n if (!retryAfter) return 0\n\n // Try parsing as seconds (delta-seconds format)\n const retryAfterSec = Number(retryAfter)\n if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) {\n return retryAfterSec * 1000\n }\n\n // Try parsing as HTTP-date\n const retryDate = Date.parse(retryAfter)\n if (!isNaN(retryDate)) {\n // Handle clock skew: clamp to non-negative, cap at reasonable max\n const deltaMs = retryDate - Date.now()\n return Math.max(0, Math.min(deltaMs, 3600_000)) // Cap at 1 hour\n }\n\n return 0\n}\n\nexport function createFetchWithBackoff(\n fetchClient: typeof fetch,\n backoffOptions: BackoffOptions = BackoffDefaults\n): typeof fetch {\n const {\n initialDelay,\n maxDelay,\n multiplier,\n debug = false,\n onFailedAttempt,\n maxRetries = Infinity,\n } = backoffOptions\n return async (...args: Parameters<typeof fetch>): Promise<Response> => {\n const url = args[0]\n const options = args[1]\n\n let delay = initialDelay\n let attempt = 0\n\n while (true) {\n try {\n const result = await fetchClient(...args)\n if (result.ok) {\n return result\n }\n\n const err = await FetchError.fromResponse(result, url.toString())\n\n throw err\n } catch (e) {\n onFailedAttempt?.()\n if (options?.signal?.aborted) {\n throw new FetchBackoffAbortError()\n } else if (\n e instanceof FetchError &&\n !HTTP_RETRY_STATUS_CODES.includes(e.status) &&\n e.status >= 400 &&\n e.status < 500\n ) {\n // Any client errors cannot be backed off on, leave it to the caller to handle.\n throw e\n } else {\n // Check max retries\n attempt++\n if (attempt > maxRetries) {\n if (debug) {\n console.log(\n `Max retries reached (${attempt}/${maxRetries}), giving up`\n )\n }\n throw e\n }\n\n // Calculate wait time honoring server-driven backoff as a floor\n // Precedence: max(serverMinimum, min(clientMaxDelay, backoffWithJitter))\n\n // 1. Parse server-provided Retry-After (if present)\n const serverMinimumMs =\n e instanceof FetchError && e.headers\n ? parseRetryAfterHeader(e.headers[`retry-after`])\n : 0\n\n // 2. Calculate client backoff with full jitter strategy\n // Full jitter: random_between(0, min(cap, exponential_backoff))\n // See: https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/\n const jitter = Math.random() * delay // random value between 0 and current delay\n const clientBackoffMs = Math.min(jitter, maxDelay) // cap at maxDelay\n\n // 3. Server minimum is the floor, client cap is the ceiling\n const waitMs = Math.max(serverMinimumMs, clientBackoffMs)\n\n if (debug) {\n const source = serverMinimumMs > 0 ? `server+client` : `client`\n console.log(\n `Retry attempt #${attempt} after ${waitMs}ms (${source}, serverMin=${serverMinimumMs}ms, clientBackoff=${clientBackoffMs}ms)`\n )\n }\n\n // Wait for the calculated duration\n await new Promise((resolve) => setTimeout(resolve, waitMs))\n\n // Increase the delay for the next attempt (capped at maxDelay)\n delay = Math.min(delay * multiplier, maxDelay)\n }\n }\n }\n }\n}\n\nconst NO_BODY_STATUS_CODES = [201, 204, 205]\n\n// Ensure body can actually be read in its entirety\nexport function createFetchWithConsumedMessages(fetchClient: typeof fetch) {\n return async (...args: Parameters<typeof fetch>): Promise<Response> => {\n const url = args[0]\n const res = await fetchClient(...args)\n try {\n if (res.status < 200 || NO_BODY_STATUS_CODES.includes(res.status)) {\n return res\n }\n\n const text = await res.text()\n return new Response(text, res)\n } catch (err) {\n if (args[1]?.signal?.aborted) {\n throw new FetchBackoffAbortError()\n }\n\n throw new FetchError(\n res.status,\n undefined,\n undefined,\n Object.fromEntries([...res.headers.entries()]),\n url.toString(),\n err instanceof Error\n ? err.message\n : typeof err === `string`\n ? err\n : `failed to read body`\n )\n }\n }\n}\n\ninterface ChunkPrefetchOptions {\n maxChunksToPrefetch: number\n}\n\nconst ChunkPrefetchDefaults = {\n maxChunksToPrefetch: 2,\n}\n\n/**\n * Creates a fetch client that prefetches subsequent log chunks for\n * consumption by the shape stream without waiting for the chunk bodies\n * themselves to be loaded.\n *\n * @param fetchClient the client to wrap\n * @param prefetchOptions options to configure prefetching\n * @returns wrapped client with prefetch capabilities\n */\nexport function createFetchWithChunkBuffer(\n fetchClient: typeof fetch,\n prefetchOptions: ChunkPrefetchOptions = ChunkPrefetchDefaults\n): typeof fetch {\n const { maxChunksToPrefetch } = prefetchOptions\n\n let prefetchQueue: PrefetchQueue\n\n const prefetchClient = async (...args: Parameters<typeof fetchClient>) => {\n const url = args[0].toString()\n\n // try to consume from the prefetch queue first, and if request is\n // not present abort the prefetch queue as it must no longer be valid\n const prefetchedRequest = prefetchQueue?.consume(...args)\n if (prefetchedRequest) {\n return prefetchedRequest\n }\n\n prefetchQueue?.abort()\n\n // perform request and fire off prefetch queue if request is eligible\n const response = await fetchClient(...args)\n const nextUrl = getNextChunkUrl(url, response)\n if (nextUrl) {\n prefetchQueue = new PrefetchQueue({\n fetchClient,\n maxPrefetchedRequests: maxChunksToPrefetch,\n url: nextUrl,\n requestInit: args[1],\n })\n }\n\n return response\n }\n\n return prefetchClient\n}\n\nexport const requiredElectricResponseHeaders = [\n `electric-offset`,\n `electric-handle`,\n]\n\nexport const requiredLiveResponseHeaders = [`electric-cursor`]\n\nexport const requiredNonLiveResponseHeaders = [`electric-schema`]\n\nexport function createFetchWithResponseHeadersCheck(\n fetchClient: typeof fetch\n): typeof fetch {\n return async (...args: Parameters<typeof fetchClient>) => {\n const response = await fetchClient(...args)\n\n if (response.ok) {\n // Check that the necessary Electric headers are present on the response\n const headers = response.headers\n const missingHeaders: Array<string> = []\n\n const addMissingHeaders = (requiredHeaders: Array<string>) =>\n missingHeaders.push(...requiredHeaders.filter((h) => !headers.has(h)))\n\n const input = args[0]\n const urlString = input.toString()\n const url = new URL(urlString)\n\n // Snapshot responses (subset params) return a JSON object and do not include Electric chunk headers\n const isSnapshotRequest = [\n SUBSET_PARAM_WHERE,\n SUBSET_PARAM_WHERE_PARAMS,\n SUBSET_PARAM_LIMIT,\n SUBSET_PARAM_OFFSET,\n SUBSET_PARAM_ORDER_BY,\n ].some((p) => url.searchParams.has(p))\n if (isSnapshotRequest) {\n return response\n }\n\n addMissingHeaders(requiredElectricResponseHeaders)\n if (url.searchParams.get(LIVE_QUERY_PARAM) === `true`) {\n addMissingHeaders(requiredLiveResponseHeaders)\n }\n\n if (\n !url.searchParams.has(LIVE_QUERY_PARAM) ||\n url.searchParams.get(LIVE_QUERY_PARAM) === `false`\n ) {\n addMissingHeaders(requiredNonLiveResponseHeaders)\n }\n\n if (missingHeaders.length > 0) {\n throw new MissingHeadersError(urlString, missingHeaders)\n }\n }\n\n return response\n }\n}\n\nclass PrefetchQueue {\n readonly #fetchClient: typeof fetch\n readonly #maxPrefetchedRequests: number\n readonly #prefetchQueue = new Map<\n string,\n [Promise<Response>, AbortController]\n >()\n #queueHeadUrl: string | void\n #queueTailUrl: string | void\n\n constructor(options: {\n url: Parameters<typeof fetch>[0]\n requestInit: Parameters<typeof fetch>[1]\n maxPrefetchedRequests: number\n fetchClient?: typeof fetch\n }) {\n this.#fetchClient =\n options.fetchClient ??\n ((...args: Parameters<typeof fetch>) => fetch(...args))\n this.#maxPrefetchedRequests = options.maxPrefetchedRequests\n this.#queueHeadUrl = options.url.toString()\n this.#queueTailUrl = this.#queueHeadUrl\n this.#prefetch(options.url, options.requestInit)\n }\n\n abort(): void {\n this.#prefetchQueue.forEach(([_, aborter]) => aborter.abort())\n }\n\n consume(...args: Parameters<typeof fetch>): Promise<Response> | void {\n const url = args[0].toString()\n\n const request = this.#prefetchQueue.get(url)?.[0]\n // only consume if request is in queue and is the queue \"head\"\n // if request is in the queue but not the head, the queue is being\n // consumed out of order and should be restarted\n if (!request || url !== this.#queueHeadUrl) return\n this.#prefetchQueue.delete(url)\n\n // fire off new prefetch since request has been consumed\n request\n .then((response) => {\n const nextUrl = getNextChunkUrl(url, response)\n this.#queueHeadUrl = nextUrl\n if (\n this.#queueTailUrl &&\n !this.#prefetchQueue.has(this.#queueTailUrl)\n ) {\n this.#prefetch(this.#queueTailUrl, args[1])\n }\n })\n .catch(() => {})\n\n return request\n }\n\n #prefetch(...args: Parameters<typeof fetch>): void {\n const url = args[0].toString()\n\n // only prefetch when queue is not full\n if (this.#prefetchQueue.size >= this.#maxPrefetchedRequests) return\n\n // initialize aborter per request, to avoid aborting consumed requests that\n // are still streaming their bodies to the consumer\n const aborter = new AbortController()\n\n try {\n const { signal, cleanup } = chainAborter(aborter, args[1]?.signal)\n const request = this.#fetchClient(url, { ...(args[1] ?? {}), signal })\n this.#prefetchQueue.set(url, [request, aborter])\n request\n .then((response) => {\n // only keep prefetching if response chain is uninterrupted\n if (!response.ok || aborter.signal.aborted) return\n\n const nextUrl = getNextChunkUrl(url, response)\n\n // only prefetch when there is a next URL\n if (!nextUrl || nextUrl === url) {\n this.#queueTailUrl = undefined\n return\n }\n\n this.#queueTailUrl = nextUrl\n return this.#prefetch(nextUrl, args[1])\n })\n .catch(() => {})\n .finally(cleanup)\n } catch (_) {\n // ignore prefetch errors\n }\n }\n}\n\n/**\n * Generate the next chunk's URL if the url and response are valid\n */\nfunction getNextChunkUrl(url: string, res: Response): string | void {\n const shapeHandle = res.headers.get(SHAPE_HANDLE_HEADER)\n const lastOffset = res.headers.get(CHUNK_LAST_OFFSET_HEADER)\n const isUpToDate = res.headers.has(CHUNK_UP_TO_DATE_HEADER)\n\n // only prefetch if shape handle and offset for next chunk are available, and\n // response is not already up-to-date\n if (!shapeHandle || !lastOffset || isUpToDate) return\n\n const nextUrl = new URL(url)\n\n // don't prefetch live requests, rushing them will only\n // potentially miss more recent data\n if (nextUrl.searchParams.has(LIVE_QUERY_PARAM)) return\n\n nextUrl.searchParams.set(SHAPE_HANDLE_QUERY_PARAM, shapeHandle)\n nextUrl.searchParams.set(OFFSET_QUERY_PARAM, lastOffset)\n nextUrl.searchParams.sort()\n return nextUrl.toString()\n}\n\n/**\n * Chains an abort controller on an optional source signal's\n * aborted state - if the source signal is aborted, the provided abort\n * controller will also abort\n */\nfunction chainAborter(\n aborter: AbortController,\n sourceSignal?: AbortSignal | null\n): {\n signal: AbortSignal\n cleanup: () => void\n} {\n let cleanup = noop\n if (!sourceSignal) {\n // no-op, nothing to chain to\n } else if (sourceSignal.aborted) {\n // source signal is already aborted, abort immediately\n aborter.abort()\n } else {\n // chain to source signal abort event, and add callback to unlink\n // the aborter to avoid memory leaks\n const abortParent = () => aborter.abort()\n sourceSignal.addEventListener(`abort`, abortParent, {\n once: true,\n signal: aborter.signal,\n })\n cleanup = () => sourceSignal.removeEventListener(`abort`, abortParent)\n }\n\n return {\n signal: aborter.signal,\n cleanup,\n }\n}\n\nfunction noop() {}\n","import {\n Message,\n Offset,\n Schema,\n Row,\n MaybePromise,\n GetExtensions,\n ChangeMessage,\n SnapshotMetadata,\n} from './types'\nimport { MessageParser, Parser, TransformFunction } from './parser'\nimport { getOffset, isUpToDateMessage, isChangeMessage } from './helpers'\nimport {\n FetchError,\n FetchBackoffAbortError,\n MissingShapeUrlError,\n InvalidSignalError,\n MissingShapeHandleError,\n ReservedParamError,\n} from './error'\nimport {\n BackoffDefaults,\n BackoffOptions,\n createFetchWithBackoff,\n createFetchWithChunkBuffer,\n createFetchWithConsumedMessages,\n createFetchWithResponseHeadersCheck,\n} from './fetch'\nimport {\n CHUNK_LAST_OFFSET_HEADER,\n LIVE_CACHE_BUSTER_HEADER,\n LIVE_CACHE_BUSTER_QUERY_PARAM,\n EXPIRED_HANDLE_QUERY_PARAM,\n COLUMNS_QUERY_PARAM,\n LIVE_QUERY_PARAM,\n OFFSET_QUERY_PARAM,\n SHAPE_HANDLE_HEADER,\n SHAPE_HANDLE_QUERY_PARAM,\n SHAPE_SCHEMA_HEADER,\n WHERE_QUERY_PARAM,\n WHERE_PARAMS_PARAM,\n TABLE_QUERY_PARAM,\n REPLICA_PARAM,\n FORCE_DISCONNECT_AND_REFRESH,\n PAUSE_STREAM,\n EXPERIMENTAL_LIVE_SSE_QUERY_PARAM,\n LIVE_SSE_QUERY_PARAM,\n ELECTRIC_PROTOCOL_QUERY_PARAMS,\n LOG_MODE_QUERY_PARAM,\n SUBSET_PARAM_WHERE,\n SUBSET_PARAM_WHERE_PARAMS,\n SUBSET_PARAM_LIMIT,\n SUBSET_PARAM_OFFSET,\n SUBSET_PARAM_ORDER_BY,\n} from './constants'\nimport {\n EventSourceMessage,\n fetchEventSource,\n} from '@microsoft/fetch-event-source'\nimport { expiredShapesCache } from './expired-shapes-cache'\nimport { SnapshotTracker } from './snapshot-tracker'\n\nconst RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([\n LIVE_CACHE_BUSTER_QUERY_PARAM,\n SHAPE_HANDLE_QUERY_PARAM,\n LIVE_QUERY_PARAM,\n OFFSET_QUERY_PARAM,\n])\n\ntype Replica = `full` | `default`\nexport type LogMode = `changes_only` | `full`\n\n/**\n * PostgreSQL-specific shape parameters that can be provided externally\n */\nexport interface PostgresParams<T extends Row<unknown> = Row> {\n /** The root table for the shape. Not required if you set the table in your proxy. */\n table?: string\n\n /**\n * The columns to include in the shape.\n * Must include primary keys, and can only include valid columns.\n * Defaults to all columns of the type `T`. If provided, must include primary keys, and can only include valid columns.\n\n */\n columns?: (keyof T)[]\n\n /** The where clauses for the shape */\n where?: string\n\n /**\n * Positional where clause paramater values. These will be passed to the server\n * and will substitute `$i` parameters in the where clause.\n *\n * It can be an array (note that positional arguments start at 1, the array will be mapped\n * accordingly), or an object with keys matching the used positional parameters in the where clause.\n *\n * If where clause is `id = $1 or id = $2`, params must have keys `\"1\"` and `\"2\"`, or be an array with length 2.\n */\n params?: Record<`${number}`, string> | string[]\n\n /**\n * If `replica` is `default` (the default) then Electric will only send the\n * changed columns in an update.\n *\n * If it's `full` Electric will send the entire row with both changed and\n * unchanged values. `old_value` will also be present on update messages,\n * containing the previous value for changed columns.\n *\n * Setting `replica` to `full` will result in higher bandwidth\n * usage and so is not generally recommended.\n */\n replica?: Replica\n}\ntype SerializableParamValue = string | string[] | Record<string, string>\ntype ParamValue =\n | SerializableParamValue\n | (() => SerializableParamValue | Promise<SerializableParamValue>)\n\n/**\n * External params type - what users provide.\n * Excludes reserved parameters to prevent dynamic variations that could cause stream shape changes.\n */\nexport type ExternalParamsRecord<T extends Row<unknown> = Row> = {\n [K in string]: ParamValue | undefined\n} & Partial<PostgresParams<T>> & { [K in ReservedParamKeys]?: never }\n\nexport type SubsetParams = {\n where?: string\n params?: Record<string, string>\n limit?: number\n offset?: number\n orderBy?: string\n}\n\ntype ReservedParamKeys =\n | typeof LIVE_CACHE_BUSTER_QUERY_PARAM\n | typeof SHAPE_HANDLE_QUERY_PARAM\n | typeof LIVE_QUERY_PARAM\n | typeof OFFSET_QUERY_PARAM\n | `subset__${string}`\n\n/**\n * External headers type - what users provide.\n * Allows string or function values for any header.\n */\nexport type ExternalHeadersRecord = {\n [key: string]: string | (() => string | Promise<string>)\n}\n\n/**\n * Internal params type - used within the library.\n * All values are converted to strings.\n */\ntype InternalParamsRecord = {\n [K in string as K extends ReservedParamKeys ? never : K]:\n | string\n | Record<string, string>\n}\n\n/**\n * Helper function to resolve a function or value to its final value\n */\nexport async function resolveValue<T>(\n value: T | (() => T | Promise<T>)\n): Promise<T> {\n if (typeof value === `function`) {\n return (value as () => T | Promise<T>)()\n }\n return value\n}\n\n/**\n * Helper function to convert external params to internal format\n */\nasync function toInternalParams(\n params: ExternalParamsRecord<Row>\n): Promise<InternalParamsRecord> {\n const entries = Object.entries(params)\n const resolvedEntries = await Promise.all(\n entries.map(async ([key, value]) => {\n if (value === undefined) return [key, undefined]\n const resolvedValue = await resolveValue(value)\n return [\n key,\n Array.isArray(resolvedValue) ? resolvedValue.join(`,`) : resolvedValue,\n ]\n })\n )\n\n return Object.fromEntries(\n resolvedEntries.filter(([_, value]) => value !== undefined)\n )\n}\n\n/**\n * Helper function to resolve headers\n */\nasync function resolveHeaders(\n headers?: ExternalHeadersRecord\n): Promise<Record<string, string>> {\n if (!headers) return {}\n\n const entries = Object.entries(headers)\n const resolvedEntries = await Promise.all(\n entries.map(async ([key, value]) => [key, await resolveValue(value)])\n )\n\n return Object.fromEntries(resolvedEntries)\n}\n\ntype RetryOpts = {\n params?: ExternalParamsRecord\n headers?: ExternalHeadersRecord\n}\n\ntype ShapeStreamErrorHandler = (\n error: Error\n) => void | RetryOpts | Promise<void | RetryOpts>\n\n/**\n * Options for constructing a ShapeStream.\n */\nexport interface ShapeStreamOptions<T = never> {\n /**\n * The full URL to where the Shape is served. This can either be the Electric server\n * directly or a proxy. E.g. for a local Electric instance, you might set `http://localhost:3000/v1/shape`\n */\n url: string\n\n /**\n * The \"offset\" on the shape log. This is typically not set as the ShapeStream\n * will handle this automatically. A common scenario where you might pass an offset\n * is if you're maintaining a local cache of the log. If you've gone offline\n * and are re-starting a ShapeStream to catch-up to the latest state of the Shape,\n * you'd pass in the last offset and shapeHandle you'd seen from the Electric server\n * so it knows at what point in the shape to catch you up from.\n */\n offset?: Offset\n\n /**\n * Similar to `offset`, this isn't typically used unless you're maintaining\n * a cache of the shape log.\n */\n handle?: string\n\n /**\n * HTTP headers to attach to requests made by the client.\n * Values can be strings or functions (sync or async) that return strings.\n * Function values are resolved in parallel when needed, making this useful\n * for authentication tokens or other dynamic headers.\n */\n headers?: ExternalHeadersRecord\n\n /**\n * Additional request parameters to attach to the URL.\n * Values can be strings, string arrays, or functions (sync or async) that return these types.\n * Function values are resolved in parallel when needed, making this useful\n * for user-specific parameters or dynamic filters.\n *\n * These will be merged with Electric's standard parameters.\n * Note: You cannot use Electric's reserved parameter names\n * (offset, handle, live, cursor).\n *\n * PostgreSQL-specific options like table, where, columns, and replica\n * should be specified here.\n */\n params?: ExternalParamsRecord\n\n /**\n * Automatically fetch updates to the Shape. If you just want to sync the current\n * shape and stop, pass false.\n */\n subscribe?: boolean\n\n /**\n * @deprecated No longer experimental, use {@link liveSse} instead.\n */\n experimentalLiveSse?: boolean\n\n /**\n * Use Server-Sent Events (SSE) for live updates.\n */\n liveSse?: boolean\n\n /**\n * Initial data loading mode\n */\n log?: LogMode\n\n signal?: AbortSignal\n fetchClient?: typeof fetch\n backoffOptions?: BackoffOptions\n parser?: Parser<T>\n transformer?: TransformFunction<T>\n\n /**\n * A function for handling shapestream errors.\n *\n * **Automatic retries**: The client automatically retries 5xx server errors, network\n * errors, and 429 rate limits with exponential backoff. The `onError` callback is\n * only invoked after these automatic retries are exhausted, or for non-retryable\n * errors like 4xx client errors.\n *\n * When not provided, non-retryable errors will be thrown and syncing will stop.\n *\n * **Return value behavior**:\n * - Return an **object** (RetryOpts or empty `{}`) to retry syncing:\n * - `{}` - Retry with the same params and headers\n * - `{ params }` - Retry with modified params\n * - `{ headers }` - Retry with modified headers (e.g., refreshed auth token)\n * - `{ params, headers }` - Retry with both modified\n * - Return **void** or **undefined** to stop the stream permanently\n *\n * **Important**: If you want syncing to continue after an error (e.g., to retry\n * on network failures), you MUST return at least an empty object `{}`. Simply\n * logging the error and returning nothing will stop syncing.\n *\n * Supports async functions that return `Promise<void | RetryOpts>`.\n *\n * @example\n * ```typescript\n * // Retry on network errors, stop on others\n * onError: (error) => {\n * console.error('Stream error:', error)\n * if (error instanceof FetchError && error.status >= 500) {\n * return {} // Retry with same params\n * }\n * // Return void to stop on other errors\n * }\n * ```\n *\n * @example\n * ```typescript\n * // Refresh auth token on 401\n * onError: async (error) => {\n * if (error instanceof FetchError && error.status === 401) {\n * const newToken = await refreshAuthToken()\n * return { headers: { Authorization: `Bearer ${newToken}` } }\n * }\n * return {} // Retry other errors\n * }\n * ```\n */\n onError?: ShapeStreamErrorHandler\n}\n\nexport interface ShapeStreamInterface<T extends Row<unknown> = Row> {\n subscribe(\n callback: (\n messages: Message<T>[]\n ) => MaybePromise<void> | { columns?: (keyof T)[] },\n onError?: (error: FetchError | Error) => void\n ): () => void\n unsubscribeAll(): void\n\n isLoading(): boolean\n lastSyncedAt(): number | undefined\n lastSynced(): number\n isConnected(): boolean\n hasStarted(): boolean\n\n isUpToDate: boolean\n lastOffset: Offset\n shapeHandle?: string\n error?: unknown\n mode: LogMode\n\n forceDisconnectAndRefresh(): Promise<void>\n\n requestSnapshot(params: {\n where?: string\n params?: Record<string, string>\n limit: number\n offset?: number\n orderBy: string\n }): Promise<{\n metadata: SnapshotMetadata\n data: Array<Message<T>>\n }>\n}\n\n/**\n * Creates a canonical shape key from a URL excluding only Electric protocol parameters\n */\nfunction canonicalShapeKey(url: URL): string {\n const cleanUrl = new URL(url.origin + url.pathname)\n\n // Copy all params except Electric protocol ones that vary between requests\n for (const [key, value] of url.searchParams) {\n if (!ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) {\n cleanUrl.searchParams.set(key, value)\n }\n }\n\n cleanUrl.searchParams.sort()\n return cleanUrl.toString()\n}\n\n/**\n * Reads updates to a shape from Electric using HTTP requests and long polling or\n * Server-Sent Events (SSE).\n * Notifies subscribers when new messages come in. Doesn't maintain any history of the\n * log but does keep track of the offset position and is the best way\n * to consume the HTTP `GET /v1/shape` api.\n *\n * @constructor\n * @param {ShapeStreamOptions} options - configure the shape stream\n * @example\n * Register a callback function to subscribe to the messages.\n * ```\n * const stream = new ShapeStream(options)\n * stream.subscribe(messages => {\n * // messages is 1 or more row updates\n * })\n * ```\n *\n * To use Server-Sent Events (SSE) for real-time updates:\n * ```\n * const stream = new ShapeStream({\n * url: `http://localhost:3000/v1/shape`,\n * liveSse: true\n * })\n * ```\n *\n * To abort the stream, abort the `signal`\n * passed in via the `ShapeStreamOptions`.\n * ```\n * const aborter = new AbortController()\n * const issueStream = new ShapeStream({\n * url: `${BASE_URL}/${table}`\n * subscribe: true,\n * signal: aborter.signal,\n * })\n * // Later...\n * aborter.abort()\n * ```\n */\n\nexport class ShapeStream<T extends Row<unknown> = Row>\n implements ShapeStreamInterface<T>\n{\n static readonly Replica = {\n FULL: `full` as Replica,\n DEFAULT: `default` as Replica,\n }\n\n readonly options: ShapeStreamOptions<GetExtensions<T>>\n #error: unknown = null\n\n readonly #fetchClient: typeof fetch\n readonly #sseFetchClient: typeof fetch\n readonly #messageParser: MessageParser<T>\n\n readonly #subscribers = new Map<\n number,\n [\n (messages: Message<T>[]) => MaybePromise<void>,\n ((error: Error) => void) | undefined,\n ]\n >()\n\n #started = false\n #state = `active` as `active` | `pause-requested` | `paused`\n #lastOffset: Offset\n #liveCacheBuster: string // Seconds since our Electric Epoch 😎\n #lastSyncedAt?: number // unix time\n #isUpToDate: boolean = false\n #isMidStream: boolean = true\n #connected: boolean = false\n #shapeHandle?: string\n #mode: LogMode\n #schema?: Schema\n #onError?: ShapeStreamErrorHandler\n #requestAbortController?: AbortController\n #isRefreshing = false\n #tickPromise?: Promise<void>\n #tickPromiseResolver?: () => void\n #tickPromiseRejecter?: (reason?: unknown) => void\n #messageChain = Promise.resolve<void[]>([]) // promise chain for incoming messages\n #snapshotTracker = new SnapshotTracker()\n #activeSnapshotRequests = 0 // counter for concurrent snapshot requests\n #midStreamPromise?: Promise<void>\n #midStreamPromiseResolver?: () => void\n #lastSseConnectionStartTime?: number\n #minSseConnectionDuration = 1000 // Minimum expected SSE connection duration (1 second)\n #consecutiveShortSseConnections = 0\n #maxShortSseConnections = 3 // Fall back to long polling after this many short connections\n #sseFallbackToLongPolling = false\n #sseBackoffBaseDelay = 100 // Base delay for exponential backoff (ms)\n #sseBackoffMaxDelay = 5000 // Maximum delay cap (ms)\n\n constructor(options: ShapeStreamOptions<GetExtensions<T>>) {\n this.options = { subscribe: true, ...options }\n validateOptions(this.options)\n this.#lastOffset = this.options.offset ?? `-1`\n this.#liveCacheBuster = ``\n this.#shapeHandle = this.options.handle\n this.#messageParser = new MessageParser<T>(\n options.parser,\n options.transformer\n )\n this.#onError = this.options.onError\n this.#mode = this.options.log ?? `full`\n\n const baseFetchClient =\n options.fetchClient ??\n ((...args: Parameters<typeof fetch>) => fetch(...args))\n\n const backOffOpts = {\n ...(options.backoffOptions ?? BackoffDefaults),\n onFailedAttempt: () => {\n this.#connected = false\n options.backoffOptions?.onFailedAttempt?.()\n },\n }\n const fetchWithBackoffClient = createFetchWithBackoff(\n baseFetchClient,\n backOffOpts\n )\n\n this.#sseFetchClient = createFetchWithResponseHeadersCheck(\n createFetchWithChunkBuffer(fetchWithBackoffClient)\n )\n\n this.#fetchClient = createFetchWithConsumedMessages(this.#sseFetchClient)\n\n this.#subscribeToVisibilityChanges()\n }\n\n get shapeHandle() {\n return this.#shapeHandle\n }\n\n get error() {\n return this.#error\n }\n\n get isUpToDate() {\n return this.#isUpToDate\n }\n\n get lastOffset() {\n return this.#lastOffset\n }\n\n get mode() {\n return this.#mode\n }\n\n async #start(): Promise<void> {\n this.#started = true\n\n try {\n await this.#requestShape()\n } catch (err) {\n this.#error = err\n\n // Check if onError handler wants to retry\n if (this.#onError) {\n const retryOpts = await this.#onError(err as Error)\n // Guard against null (typeof null === \"object\" in JavaScript)\n if (retryOpts && typeof retryOpts === `object`) {\n // Update params/headers but don't reset offset\n // We want to continue from where we left off, not refetch everything\n if (retryOpts.params) {\n // Merge new params with existing params to preserve other parameters\n this.options.params = {\n ...(this.options.params ?? {}),\n ...retryOpts.params,\n }\n }\n\n if (retryOpts.headers) {\n // Merge new headers with existing headers to preserve other headers\n this.options.headers = {\n ...(this.options.headers ?? {}),\n ...retryOpts.headers,\n }\n }\n\n // Clear the error since we're retrying\n this.#error = null\n\n // Restart from current offset\n this.#started = false\n await this.#start()\n return\n }\n // onError returned void, meaning it doesn't want to retry\n // This is an unrecoverable error, notify subscribers\n if (err instanceof Error) {\n this.#sendErrorToSubscribers(err)\n }\n this.#connected = false\n this.#tickPromiseRejecter?.()\n return\n }\n\n // No onError handler provided, this is an unrecoverable error\n // Notify subscribers and throw\n if (err instanceof Error) {\n this.#sendErrorToSubscribers(err)\n }\n this.#connected = false\n this.#tickPromiseRejecter?.()\n throw err\n }\n\n // Normal completion, clean up\n this.#connected = false\n this.#tickPromiseRejecter?.()\n }\n\n async #requestShape(): Promise<void> {\n if (this.#state === `pause-requested`) {\n this.#state = `paused`\n\n return\n }\n\n if (\n !this.options.subscribe &&\n (this.options.signal?.aborted || this.#isUpToDate)\n ) {\n return\n }\n\n const resumingFromPause = this.#state === `paused`\n this.#state = `active`\n\n const { url, signal } = this.options\n const { fetchUrl, requestHeaders } = await this.#constructUrl(\n url,\n resumingFromPause\n )\n const abortListener = await this.#createAbortListener(signal)\n const requestAbortController = this.#requestAbortController! // we know that it is not undefined because it is set by `this.#createAbortListener`\n\n try {\n await this.#fetchShape({\n fetchUrl,\n requestAbortController,\n headers: requestHeaders,\n resumingFromPause,\n })\n } catch (e) {\n // Handle abort error triggered by refresh\n if (\n (e instanceof FetchError || e instanceof FetchBackoffAbortError) &&\n requestAbortController.signal.aborted &&\n requestAbortController.signal.reason === FORCE_DISCONNECT_AND_REFRESH\n ) {\n // Start a new request\n return this.#requestShape()\n }\n\n if (e instanceof FetchBackoffAbortError) {\n if (\n requestAbortController.signal.abort