UNPKG

@durable-streams/client

Version:

TypeScript client for the Durable Streams protocol

234 lines (205 loc) 6.46 kB
/** * Shared utility functions for the Durable Streams client. */ import { STREAM_CLOSED_HEADER, STREAM_OFFSET_HEADER } from "./constants" import { DurableStreamError, StreamClosedError } from "./error" import type { HeadersRecord, MaybePromise } from "./types" /** * Resolve headers from HeadersRecord (supports async functions). * Unified implementation used by both stream() and DurableStream. */ export async function resolveHeaders( headers?: HeadersRecord ): Promise<Record<string, string>> { const resolved: Record<string, string> = {} if (!headers) { return resolved } for (const [key, value] of Object.entries(headers)) { if (typeof value === `function`) { resolved[key] = await value() } else { resolved[key] = value } } return resolved } /** * Handle error responses from the server. * Throws appropriate DurableStreamError based on status code. */ export async function handleErrorResponse( response: Response, url: string, context?: { operation?: string } ): Promise<never> { const status = response.status if (status === 404) { throw new DurableStreamError(`Stream not found: ${url}`, `NOT_FOUND`, 404) } if (status === 409) { // Check if this is a stream closed error const streamClosedHeader = response.headers.get(STREAM_CLOSED_HEADER) if (streamClosedHeader?.toLowerCase() === `true`) { const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? undefined throw new StreamClosedError(url, finalOffset) } // Context-specific 409 messages const message = context?.operation === `create` ? `Stream already exists: ${url}` : `Sequence conflict: seq is lower than last appended` const code = context?.operation === `create` ? `CONFLICT_EXISTS` : `CONFLICT_SEQ` throw new DurableStreamError(message, code, 409) } if (status === 400) { throw new DurableStreamError( `Bad request (possibly content-type mismatch)`, `BAD_REQUEST`, 400 ) } throw await DurableStreamError.fromResponse(response, url) } /** * Resolve params from ParamsRecord (supports async functions). */ export async function resolveParams( params?: Record<string, string | (() => MaybePromise<string>) | undefined> ): Promise<Record<string, string>> { const resolved: Record<string, string> = {} if (!params) { return resolved } for (const [key, value] of Object.entries(params)) { if (value !== undefined) { if (typeof value === `function`) { resolved[key] = await value() } else { resolved[key] = value } } } return resolved } /** * Resolve a value that may be a function returning a promise. */ export async function resolveValue<T>( value: T | (() => MaybePromise<T>) ): Promise<T> { if (typeof value === `function`) { return (value as () => MaybePromise<T>)() } return value } // Module-level Set to track origins we've already warned about (prevents log spam) const warnedOrigins = new Set<string>() /** * Safely read NODE_ENV without triggering "process is not defined" errors. * Works in both browser and Node.js environments. */ function getNodeEnvSafely(): string | undefined { if (typeof process === `undefined`) return undefined // Use optional chaining for process.env in case it's undefined (e.g., in some bundler environments) // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition return process.env?.NODE_ENV } /** * Check if we're in a browser environment. */ function isBrowserEnvironment(): boolean { return typeof globalThis.window !== `undefined` } /** * Get window.location.href safely, returning undefined if not available. */ function getWindowLocationHref(): string | undefined { if ( typeof globalThis.window !== `undefined` && typeof globalThis.window.location !== `undefined` ) { return globalThis.window.location.href } return undefined } /** * Resolve a URL string, handling relative URLs in browser environments. * Returns undefined if the URL cannot be parsed. */ function resolveUrlMaybe(urlString: string): URL | undefined { try { // First try parsing as an absolute URL return new URL(urlString) } catch { // If that fails and we're in a browser, try resolving as relative URL const base = getWindowLocationHref() if (base) { try { return new URL(urlString, base) } catch { return undefined } } return undefined } } /** * Warn if using HTTP (not HTTPS) URL in a browser environment. * HTTP typically limits browsers to ~6 concurrent connections per origin under HTTP/1.1, * which can cause slow streams and app freezes with multiple active streams. * * Features: * - Warns only once per origin to prevent log spam * - Handles relative URLs by resolving against window.location.href * - Safe to call in Node.js environments (no-op) * - Skips warning during tests (NODE_ENV=test) */ export function warnIfUsingHttpInBrowser( url: string | URL, warnOnHttp?: boolean ): void { // Skip warning if explicitly disabled if (warnOnHttp === false) return // Skip warning during tests const nodeEnv = getNodeEnvSafely() if (nodeEnv === `test`) { return } // Only warn in browser environments if ( !isBrowserEnvironment() || typeof console === `undefined` || typeof console.warn !== `function` ) { return } // Parse the URL (handles both absolute and relative URLs) const urlStr = url instanceof URL ? url.toString() : url const parsedUrl = resolveUrlMaybe(urlStr) if (!parsedUrl) { // Could not parse URL - silently skip return } // Check if URL uses HTTP protocol if (parsedUrl.protocol === `http:`) { // Only warn once per origin if (!warnedOrigins.has(parsedUrl.origin)) { warnedOrigins.add(parsedUrl.origin) console.warn( `[DurableStream] Using HTTP (not HTTPS) typically limits browsers to ~6 concurrent connections per origin under HTTP/1.1. ` + `This can cause slow streams and app freezes with multiple active streams. ` + `Use HTTPS for HTTP/2 support. See https://electric-sql.com/r/electric-http2 for more information.` ) } } } /** * Reset the HTTP warning state. Only exported for testing purposes. * @internal */ export function _resetHttpWarningForTesting(): void { warnedOrigins.clear() }