diff --git a/.changeset/wicked-papayas-tease.md b/.changeset/wicked-papayas-tease.md new file mode 100644 index 0000000000..633eb97513 --- /dev/null +++ b/.changeset/wicked-papayas-tease.md @@ -0,0 +1,26 @@ +--- +"@electric-sql/client": patch +"@electric-sql/docs": patch +--- + +This PR adds support for function-based options in the TypeScript client's params and headers. Functions can be either synchronous or asynchronous and are resolved in parallel when needed. + +```typescript +const stream = new ShapeStream({ + url: 'http://localhost:3000/v1/shape', + params: { + table: 'items', + userId: () => getCurrentUserId(), + filter: async () => await getUserPreferences() + }, + headers: { + 'Authorization': async () => `Bearer ${await getAccessToken()}` + } +}) +``` + +## Common Use Cases +- Authentication tokens that need to be refreshed +- User-specific parameters that may change +- Dynamic filtering based on current state +- Multi-tenant applications where context determines the request diff --git a/packages/typescript-client/README.md b/packages/typescript-client/README.md index 3181d12168..e02f9fb15a 100644 --- a/packages/typescript-client/README.md +++ b/packages/typescript-client/README.md @@ -29,7 +29,7 @@ Real-time Postgres sync for modern apps. Electric provides an [HTTP interface](https://electric-sql.com/docs/api/http) to Postgres to enable a massive number of clients to query and get real-time updates to subsets of the database, called [Shapes](https://electric-sql.com//docs/guides/shapes). In this way, Electric turns Postgres into a real-time database. -The TypeScript client helps ease reading Shapes from the HTTP API in the browser and other JavaScript environments, such as edge functions and server-side Node/Bun/Deno applications. It supports both fine-grained and coarse-grained reactivity patterns — you can subscribe to see every row that changes, or you can just subscribe to get the whole shape whenever it changes. +The TypeScript client helps ease reading Shapes from the HTTP API in the browser and other JavaScript environments, such as edge functions and server-side Node/Bun/Deno applications. It supports both fine-grained and coarse-grained reactivity patterns — you can subscribe to see every row that changes, or you can just subscribe to get the whole shape whenever it changes. The client also supports dynamic options through function-based params and headers, making it easy to handle auth tokens, user context, and other runtime values. ## Install diff --git a/packages/typescript-client/src/client.ts b/packages/typescript-client/src/client.ts index 90f8eed36c..fd558e3109 100644 --- a/packages/typescript-client/src/client.ts +++ b/packages/typescript-client/src/client.ts @@ -38,7 +38,7 @@ import { REPLICA_PARAM, } from './constants' -const RESERVED_PARAMS = new Set([ +const RESERVED_PARAMS: Set = new Set([ LIVE_CACHE_BUSTER_QUERY_PARAM, SHAPE_HANDLE_QUERY_PARAM, LIVE_QUERY_PARAM, @@ -50,7 +50,7 @@ type Replica = `full` | `default` /** * PostgreSQL-specific shape parameters that can be provided externally */ -type PostgresParams = { +export interface PostgresParams { /** The root table for the shape. Not required if you set the table in your proxy. */ table?: string @@ -76,22 +76,33 @@ type PostgresParams = { replica?: Replica } +type ParamValue = + | string + | string[] + | (() => string | string[] | Promise) + +/** + * External params type - what users provide. + * Excludes reserved parameters to prevent dynamic variations that could cause stream shape changes. + */ +export type ExternalParamsRecord = { + [K in string as K extends ReservedParamKeys ? never : K]: + | ParamValue + | undefined +} & Partial + type ReservedParamKeys = - | typeof COLUMNS_QUERY_PARAM | typeof LIVE_CACHE_BUSTER_QUERY_PARAM | typeof SHAPE_HANDLE_QUERY_PARAM | typeof LIVE_QUERY_PARAM | typeof OFFSET_QUERY_PARAM - | typeof TABLE_QUERY_PARAM - | typeof WHERE_QUERY_PARAM - | typeof REPLICA_PARAM /** - * External params type - what users provide. - * Includes documented PostgreSQL params and allows string or string[] values for any additional params. + * External headers type - what users provide. + * Allows string or function values for any header. */ -type ExternalParamsRecord = Partial & { - [K in string as K extends ReservedParamKeys ? never : K]: string | string[] +export type ExternalHeadersRecord = { + [key: string]: string | (() => string | Promise) } /** @@ -103,19 +114,59 @@ type InternalParamsRecord = { } /** - * Helper function to convert external params to internal format + * Helper function to resolve a function or value to its final value */ -function toInternalParams(params: ExternalParamsRecord): InternalParamsRecord { - const result: InternalParamsRecord = {} - for (const [key, value] of Object.entries(params)) { - result[key] = Array.isArray(value) ? value.join(`,`) : value +export async function resolveValue( + value: T | (() => T | Promise) +): Promise { + if (typeof value === `function`) { + return (value as () => T | Promise)() } - return result + return value +} + +/** + * Helper function to convert external params to internal format + */ +async function toInternalParams( + params: ExternalParamsRecord +): Promise { + const entries = Object.entries(params) + const resolvedEntries = await Promise.all( + entries.map(async ([key, value]) => { + if (value === undefined) return [key, undefined] + const resolvedValue = await resolveValue(value) + return [ + key, + Array.isArray(resolvedValue) ? resolvedValue.join(`,`) : resolvedValue, + ] + }) + ) + + return Object.fromEntries( + resolvedEntries.filter(([_, value]) => value !== undefined) + ) +} + +/** + * Helper function to resolve headers + */ +async function resolveHeaders( + headers?: ExternalHeadersRecord +): Promise> { + if (!headers) return {} + + const entries = Object.entries(headers) + const resolvedEntries = await Promise.all( + entries.map(async ([key, value]) => [key, await resolveValue(value)]) + ) + + return Object.fromEntries(resolvedEntries) } type RetryOpts = { params?: ExternalParamsRecord - headers?: Record + headers?: ExternalHeadersRecord } type ShapeStreamErrorHandler = ( @@ -150,12 +201,18 @@ export interface ShapeStreamOptions { /** * HTTP headers to attach to requests made by the client. - * Can be used for adding authentication headers. + * Values can be strings or functions (sync or async) that return strings. + * Function values are resolved in parallel when needed, making this useful + * for authentication tokens or other dynamic headers. */ - headers?: Record + headers?: ExternalHeadersRecord /** * Additional request parameters to attach to the URL. + * Values can be strings, string arrays, or functions (sync or async) that return these types. + * Function values are resolved in parallel when needed, making this useful + * for user-specific parameters or dynamic filters. + * * These will be merged with Electric's standard parameters. * Note: You cannot use Electric's reserved parameter names * (offset, handle, live, cursor). @@ -320,22 +377,23 @@ export class ShapeStream = Row> ) { const { url, signal } = this.options - const fetchUrl = new URL(url) + // Resolve headers and params in parallel + const [requestHeaders, params] = await Promise.all([ + resolveHeaders(this.options.headers), + this.options.params + ? toInternalParams(this.options.params) + : undefined, + ]) + + // Validate params after resolution + if (params) { + validateParams(params) + } - // Add any custom parameters first - if (this.options.params) { - // Check for reserved parameter names - const reservedParams = Object.keys(this.options.params).filter( - (key) => RESERVED_PARAMS.has(key) - ) - if (reservedParams.length > 0) { - throw new Error( - `Cannot use reserved Electric parameter names in custom params: ${reservedParams.join(`, `)}` - ) - } + const fetchUrl = new URL(url) - // Add PostgreSQL-specific parameters from params - const params = toInternalParams(this.options.params) + // Add PostgreSQL-specific parameters + if (params) { if (params.table) fetchUrl.searchParams.set(TABLE_QUERY_PARAM, params.table) if (params.where) @@ -383,7 +441,7 @@ export class ShapeStream = Row> try { response = await this.#fetchClient(fetchUrl.toString(), { signal, - headers: this.options.headers, + headers: requestHeaders, }) this.#connected = true } catch (e) { @@ -550,6 +608,21 @@ export class ShapeStream = Row> } } +/** + * Validates that no reserved parameter names are used in the provided params object + * @throws {ReservedParamError} if any reserved parameter names are found + */ +function validateParams(params: Record | undefined): void { + if (!params) return + + const reservedParams = Object.keys(params).filter((key) => + RESERVED_PARAMS.has(key as ReservedParamKeys) + ) + if (reservedParams.length > 0) { + throw new ReservedParamError(reservedParams) + } +} + function validateOptions(options: Partial>): void { if (!options.url) { throw new MissingShapeUrlError() @@ -566,14 +639,7 @@ function validateOptions(options: Partial>): void { throw new MissingShapeHandleError() } - // Check for reserved parameter names - if (options.params) { - const reservedParams = Object.keys(options.params).filter((key) => - RESERVED_PARAMS.has(key) - ) - if (reservedParams.length > 0) { - throw new ReservedParamError(reservedParams) - } - } + validateParams(options.params) + return } diff --git a/packages/typescript-client/test/client.test-d.ts b/packages/typescript-client/test/client.test-d.ts index 615071e9e9..a0a91e9f5d 100644 --- a/packages/typescript-client/test/client.test-d.ts +++ b/packages/typescript-client/test/client.test-d.ts @@ -6,7 +6,15 @@ import { Message, isChangeMessage, ShapeData, + ExternalParamsRecord, } from '../src' +import { + COLUMNS_QUERY_PARAM, + LIVE_CACHE_BUSTER_QUERY_PARAM, + SHAPE_HANDLE_QUERY_PARAM, + LIVE_QUERY_PARAM, + OFFSET_QUERY_PARAM, +} from '../src/constants' type CustomRow = { foo: number @@ -51,6 +59,41 @@ describe(`client`, () => { } }) }) + + describe(`params validation`, () => { + it(`should allow valid params`, () => { + const validParams: ExternalParamsRecord = { + // PostgreSQL params + table: `users`, + columns: [`id`, `name`], + where: `id > 0`, + replica: `full`, + + // Custom params + customParam: `value`, + customArrayParam: [`value1`, `value2`], + customFunctionParam: () => `value`, + customAsyncFunctionParam: async () => [`value1`, `value2`], + } + expectTypeOf(validParams).toEqualTypeOf() + }) + + it(`should not allow reserved params`, () => { + // Test that reserved parameters are not allowed in ExternalParamsRecord + type WithReservedParam1 = { [COLUMNS_QUERY_PARAM]: string[] } + type WithReservedParam2 = { [LIVE_CACHE_BUSTER_QUERY_PARAM]: string } + type WithReservedParam3 = { [SHAPE_HANDLE_QUERY_PARAM]: string } + type WithReservedParam4 = { [LIVE_QUERY_PARAM]: string } + type WithReservedParam5 = { [OFFSET_QUERY_PARAM]: string } + + // These should all not be equal to ExternalParamsRecord (not assignable) + expectTypeOf().not.toEqualTypeOf() + expectTypeOf().not.toEqualTypeOf() + expectTypeOf().not.toEqualTypeOf() + expectTypeOf().not.toEqualTypeOf() + expectTypeOf().not.toEqualTypeOf() + }) + }) }) describe(`Shape`, () => { diff --git a/packages/typescript-client/test/client.test.ts b/packages/typescript-client/test/client.test.ts index 1f85d35027..bdb55a11ec 100644 --- a/packages/typescript-client/test/client.test.ts +++ b/packages/typescript-client/test/client.test.ts @@ -5,6 +5,7 @@ import { testWithIssuesTable as it } from './support/test-context' import { ShapeStream, Shape, FetchError } from '../src' import { Message, Row, ChangeMessage } from '../src/types' import { MissingHeadersError } from '../src/error' +import { resolveValue } from '../src' const BASE_URL = inject(`baseUrl`) @@ -188,7 +189,6 @@ describe(`Shape`, () => { fetchClient: fetchWrapper, }) const shape = new Shape(shapeStream) - let dataUpdateCount = 0 await new Promise((resolve, reject) => { setTimeout(() => reject(`Timed out waiting for data changes`), 1000) @@ -671,6 +671,53 @@ describe(`Shape`, () => { await clearIssuesShape(shapeStream.shapeHandle) } }) + + it(`should support function-based params and headers`, async ({ + issuesTableUrl, + }) => { + const mockParamFn = vi.fn().mockReturnValue(`test-value`) + const mockAsyncParamFn = vi.fn().mockResolvedValue(`test-value`) + const mockHeaderFn = vi.fn().mockReturnValue(`test-value`) + const mockAsyncHeaderFn = vi.fn().mockResolvedValue(`test-value`) + + // Test with synchronous functions + const shapeStream1 = new ShapeStream({ + url: `${BASE_URL}/v1/shape`, + params: { + table: issuesTableUrl, + customParam: mockParamFn, + }, + headers: { + 'X-Custom-Header': mockHeaderFn, + }, + }) + const shape1 = new Shape(shapeStream1) + await shape1.value + + expect(mockParamFn).toHaveBeenCalled() + expect(mockHeaderFn).toHaveBeenCalled() + + // Test with async functions + const shapeStream2 = new ShapeStream({ + url: `${BASE_URL}/v1/shape`, + params: { + table: issuesTableUrl, + customParam: mockAsyncParamFn, + }, + headers: { + 'X-Custom-Header': mockAsyncHeaderFn, + }, + }) + const shape2 = new Shape(shapeStream2) + await shape2.value + + expect(mockAsyncParamFn).toHaveBeenCalled() + expect(mockAsyncHeaderFn).toHaveBeenCalled() + + // Verify the resolved values + expect(await resolveValue(mockParamFn())).toBe(`test-value`) + expect(await resolveValue(mockAsyncParamFn())).toBe(`test-value`) + }) }) function waitForFetch(stream: ShapeStream): Promise { diff --git a/website/docs/api/clients/typescript.md b/website/docs/api/clients/typescript.md index 51d7e79cdc..76988dae59 100644 --- a/website/docs/api/clients/typescript.md +++ b/website/docs/api/clients/typescript.md @@ -233,6 +233,31 @@ const stream = new ShapeStream({ }) ``` +#### Dynamic Options + +Both `params` and `headers` support function options that are resolved when needed. These functions can be synchronous or asynchronous: + +```typescript +const stream = new ShapeStream({ + url: 'http://localhost:3000/v1/shape', + params: { + table: 'items', + userId: () => getCurrentUserId(), + filter: async () => await getUserPreferences() + }, + headers: { + 'Authorization': async () => `Bearer ${await getAccessToken()}`, + 'X-Tenant-Id': () => getCurrentTenant() + } +}) +``` + +Function options are resolved in parallel, making this pattern efficient for multiple async operations like fetching auth tokens and user context. Common use cases include: +- Authentication tokens that need to be refreshed +- User-specific parameters that may change +- Dynamic filtering based on current state +- Multi-tenant applications where context determines the request + #### Messages A `ShapeStream` consumes and emits a stream of messages. These messages can either be a `ChangeMessage` representing a change to the shape data: @@ -300,10 +325,9 @@ const stream = new ShapeStream({ This is less efficient and will use more bandwidth for the same shape (especially for tables with large static column values). Note also that shapes with different `replica` settings are distinct, even for the same table and where clause combination. -#### Custom error handler +#### Authentication with Dynamic Tokens -You can provide a custom error handler to recover from 4xx HTTP errors. -Using a custom error handler we can for instance refresh the authorization token when a request is rejected with a `401 Unauthorized` status code because the token expired: +When working with authentication tokens that need to be refreshed, the recommended approach is to use a function-based header: ```ts const stream = new ShapeStream({ @@ -311,19 +335,16 @@ const stream = new ShapeStream({ params: { table: 'items' }, - // Add authentication header headers: { - 'Authorization': 'Bearer token' + 'Authorization': async () => `Bearer ${await getToken()}` }, - // Add custom URL parameters onError: async (error) => { if (error instanceof FetchError && error.status === 401) { - const token = await getToken() - return { - headers: { - Authorization: `Bearer ${token}`, - }, - } + // Force token refresh + await refreshToken() + // Return empty object to trigger a retry with the new token + // that will be fetched by our function-based header + return {} } // Rethrow errors we can't handle throw error @@ -331,6 +352,8 @@ const stream = new ShapeStream({ }) ``` +This approach automatically handles token refresh as the function is called each time a request is made. You can also combine this with an error handler for more complex scenarios. + ### Shape The [`Shape`](https://github.com/electric-sql/electric/blob/main/packages/typescript-client/src/shape.ts) is the main primitive for working with synced data. diff --git a/website/docs/guides/auth.md b/website/docs/guides/auth.md index c361950c1a..5253acf228 100644 --- a/website/docs/guides/auth.md +++ b/website/docs/guides/auth.md @@ -226,6 +226,28 @@ See the [./client](https://github.com/electric-sql/electric/tree/main/examples/g <<< @../../examples/gatekeeper-auth/client/index.ts{typescript} +### Dynamic Auth Options + +The TypeScript client supports function-based options for headers and params, making it easy to handle dynamic auth tokens: + +```typescript +const stream = new ShapeStream({ + url: 'http://localhost:3000/v1/shape', + headers: { + // Token will be refreshed on each request + 'Authorization': async () => `Bearer ${await getAccessToken()}` + } +}) +``` + +This pattern is particularly useful when: +- Your auth tokens need periodic refreshing +- You're using session-based authentication +- You need to fetch tokens from a secure storage +- You want to handle token rotation automatically + +The function is called when needed and its value is resolved in parallel with other dynamic options, making it efficient for real-world auth scenarios. + ## Notes ### External services