UNPKG

micro-ftch

Version:

Wrappers for built-in fetch() enabling killswitch, logging, concurrency limit and other features

604 lines (583 loc) 21.5 kB
/** * Wrappers for {@link https://developer.mozilla.org/en-US/docs/Web/API/fetch | built-in fetch()} * enabling killswitch, logging, concurrency limit, and other features. Fetch is great, but its * usage in secure environments is complicated. The library makes it simple. * @module * @example * Wrap fetch once, then compose JSON-RPC batching and replay support on top. * ```js * import { ftch, jsonrpc, replayable } from 'micro-ftch'; * * let enabled = true; * const events = []; * const net = ftch(fetch, { * isValidRequest: () => enabled, * log: (url, options) => events.push({ url, method: options.method }), * timeout: 5000, * concurrencyLimit: 10, * }); * const res = await net('https://example.com'); * * const rpc = jsonrpc(net, 'http://rpc_node/', { * headers: {}, * batchSize: 20, * }); * const res1 = await rpc.call('method', 'arg0', 'arg1'); * const res2 = await rpc.callNamed('method', { arg0: '0', arg1: '1' }); * * const replayNet = replayable(net); * const replayRpc = jsonrpc(replayNet, 'http://rpc_node/', { * headers: {}, * batchSize: 20, * }); * const replayRes = await replayRpc.call('method', 'arg0', 'arg1'); * * await net('https://user:pwd@httpbin.org/basic-auth/user/pwd'); * ``` */ // Utils // Awaiting for promise is equal to node nextTick const nextTick = async () => {}; // Small internal primitive to limit concurrency function limit(concurrencyLimit: number): <T>(fn: () => Promise<T>) => Promise<T> { // Non-positive limits cannot start queued work and would leave callers pending. if (concurrencyLimit <= 0) throw new Error(`expected concurrencyLimit > 0, got ${concurrencyLimit}`); let currentlyProcessing = 0; const queue: ((value?: unknown) => void)[] = []; const next = () => { if (!queue.length) return; if (currentlyProcessing >= concurrencyLimit) return; currentlyProcessing++; const first = queue.shift(); if (!first) throw new Error('empty queue'); // should not happen first(); }; return <T>(fn: () => Promise<T>): Promise<T> => new Promise<T>((resolve, reject) => { queue.push(() => Promise.resolve() .then(fn) .then(resolve) .catch(reject) .finally(() => { currentlyProcessing--; next(); }) ); next(); }); } /** Arguments for built-in fetch, with added timeout support. */ export type FetchOpts = RequestInit & { /** Abort the request after this many milliseconds. */ timeout?: number; }; /** * Built-in fetch, or function conforming to its interface. * Shared by `ftch`, `jsonrpc`, and `replayable`. */ export type FetchFn = ( url: string, opts?: FetchOpts ) => Promise<{ headers: Headers; ok: boolean; redirected: boolean; status: number; statusText: string; type: ResponseType; url: string; json: () => Promise<any>; text: () => Promise<string>; arrayBuffer: () => Promise<ArrayBuffer>; }>; /** Options for `ftch`. */ export type FtchOpts = { /** * Returns `false` to block a request before or after it runs. * @param url - Request URL about to be fetched. * @returns `true` when the request should be allowed. */ isValidRequest?: (url?: string) => boolean; /** * Alias for `isValidRequest`. * @param url - Request URL about to be fetched. * @returns `true` when the request should be allowed. */ killswitch?: (url?: string) => boolean; /** Maximum number of wrapped requests allowed to run at once. */ concurrencyLimit?: number; /** Default timeout in milliseconds for wrapped requests. */ timeout?: number; /** * Observes every request before it is sent. * @param url - Request URL. * @param opts - Request options passed to the wrapped fetch. See {@link FetchOpts}. */ log?: (url: string, opts: FetchOpts) => void; }; type UnPromise<T> = T extends Promise<infer U> ? U : T; // NOTE: we don't expose actual request to make sure there is no way to trigger actual network code // from wrapped function const getRequestInfo = (req: UnPromise<ReturnType<FetchFn>>) => ({ headers: req.headers, ok: req.ok, redirected: req.redirected, status: req.status, statusText: req.statusText, type: req.type, url: req.url, }); /** * Small wrapper over fetch function * @param fetchFunction - Fetch implementation to wrap. * @param opts - Wrapper configuration like timeout, killswitch, and logging. See {@link FtchOpts}. * @returns Wrapped fetch function with timeout, auth parsing, and optional request gating. * @throws If the killswitch hook is invalid or a wrapped request is blocked by the network policy. {@link Error} * @example * Add a simple network killswitch around an existing fetch implementation. * ```js * import { ftch } from 'micro-ftch'; * let enabled = true; * const net = ftch(fetch, { isValidRequest: () => enabled }); * await net('https://example.com'); * enabled = false; * ``` * @example * Force wrapped requests to run one at a time. * ```js * import { ftch } from 'micro-ftch'; * const net = ftch(fetch, { concurrencyLimit: 1 }); * await Promise.all([net('https://example.com/1'), net('https://example.com/2')]); * ``` * @example * Apply the same timeout to every request made through the wrapper. * ```js * import { ftch } from 'micro-ftch'; * const net = ftch(fetch, { timeout: 1000 }); * await net('https://example.com'); * ``` * @example * Capture a structured request log without changing the call sites. * ```js * import { ftch } from 'micro-ftch'; * const events = []; * const net = ftch(fetch, { * log: (url, options) => events.push({ url, method: options.method }), * }); * await net('https://example.com'); * ``` * @example * User info in the URL becomes the Authorization header automatically. * ```js * import { ftch } from 'micro-ftch'; * const net = ftch(fetch); * await net('https://user:pwd@example.com/private'); * ``` */ export function ftch(fetchFunction: FetchFn, opts: FtchOpts = {}): FetchFn { const ks = opts.isValidRequest || opts.killswitch; if (ks && typeof ks !== 'function') throw new Error('opts.isValidRequest must be a function'); const noNetwork = (url: string) => ks && !ks(url); const wrappedFetch: FetchFn = async (url, reqOpts = {}) => { const abort = new AbortController(); const callerSignal = reqOpts.signal; let cleanupCallerSignal = () => {}; if (callerSignal) { // Keep one internal signal for timeout and late killswitch aborts, while preserving caller aborts. const abortCaller = () => abort.abort(callerSignal.reason); if (callerSignal.aborted) abortCaller(); else { callerSignal.addEventListener('abort', abortCaller, { once: true }); cleanupCallerSignal = () => callerSignal.removeEventListener('abort', abortCaller); } } let timeout = undefined; if (opts.timeout !== undefined || reqOpts.timeout !== undefined) { const ms = reqOpts.timeout !== undefined ? reqOpts.timeout : opts.timeout; timeout = setTimeout(() => abort.abort(), ms); } const headers = new Headers(); // We cannot re-use object from user since we may modify it const parsed = new URL(url); if (parsed.username || parsed.password) { // RFC 7617 §2 builds `user-pass` as user-id ":" password; RFC 3986 §3.2.1 deprecates user:password in URI userinfo, so strip it after converting. const auth = btoa(`${parsed.username}:${parsed.password}`); headers.set('Authorization', `Basic ${auth}`); parsed.username = ''; parsed.password = ''; url = '' + parsed; } if (reqOpts.headers) { const h = reqOpts.headers instanceof Headers ? reqOpts.headers : new Headers(reqOpts.headers); h.forEach((v, k) => headers.set(k, v)); } if (noNetwork(url)) throw new Error('network disabled'); if (opts.log) opts.log(url, reqOpts); try { const res = await fetchFunction(url, { referrerPolicy: 'no-referrer', // avoid sending referrer by default ...reqOpts, headers, signal: abort.signal, }); if (noNetwork(url)) { abort.abort('network disabled'); throw new Error('network disabled'); } const body = new Uint8Array(await res.arrayBuffer()); return { ...getRequestInfo(res), // NOTE: this disables streaming parser and fetches whole body on request (instead of headers only as done in fetch) // But this allows to intercept and disable request if killswitch enabled. Also required for concurrency limit, // since actual request is not finished json: async () => JSON.parse(new TextDecoder().decode(body)), text: async () => new TextDecoder().decode(body), arrayBuffer: async () => body.buffer, }; } finally { if (timeout !== undefined) clearTimeout(timeout); cleanupCallerSignal(); } }; if (opts.concurrencyLimit !== undefined) { const curLimit = limit(opts.concurrencyLimit!); return (url, reqOpts) => curLimit(() => wrappedFetch(url, reqOpts)); } return wrappedFetch; } // Jsonrpc type PromiseCb<T> = { resolve: (value: T | PromiseLike<T>) => void; reject: (reason?: any) => void; }; /** Minimal JSON-RPC client interface. */ export type JsonrpcInterface = { /** * Calls a JSON-RPC method with positional parameters. * @param method - JSON-RPC method name. * @param args - Positional JSON-RPC params. * @returns Decoded JSON-RPC result. */ call: (method: string, ...args: any[]) => Promise<any>; /** * Calls a JSON-RPC method with named parameters. * @param method - JSON-RPC method name. * @param args - Named JSON-RPC params. * @returns Decoded JSON-RPC result. */ callNamed: (method: string, args: Record<string, any>) => Promise<any>; }; type NetworkOpts = { batchSize?: number; headers?: Record<string, string>; }; type RpcParams = any[] | Record<string, any>; type RpcErrorResponse = { code: number; message: string }; /** * JSON-RPC server error wrapper. * @param error - JSON-RPC error payload. * @example * Inspect the JSON-RPC error code and message from a failed response. * ```js * import { RpcError } from 'micro-ftch'; * const err = new RpcError({ code: -32000, message: 'oops' }); * console.log(err.code, err.message); * ``` */ export class RpcError extends Error { readonly code: number; constructor(error: RpcErrorResponse) { super(`FetchProvider(${error.code}): ${error.message || error}`); this.code = error.code; this.name = 'RpcError'; } } /** * Small utility class for Jsonrpc * @param fetchFunction - Fetch implementation used for transport. * @param rpcUrl - JSON-RPC endpoint URL. * @param options - Batching and header configuration. See {@link NetworkOpts}. * @example * Create a batched JSON-RPC client and call it with positional and named params. * ```js * import { JsonrpcProvider } from 'micro-ftch'; * const rpc = new JsonrpcProvider(fetch, 'http://rpc_node/', { * headers: {}, * batchSize: 20, * }); * const res = await rpc.call('method', 'arg0', 'arg1'); * const res2 = await rpc.callNamed('method', { arg0: '0', arg1: '1' }); * ``` */ export class JsonrpcProvider implements JsonrpcInterface { private batchSize: number; private headers: Record<string, string>; private queue: ({ method: string; params: RpcParams } & PromiseCb<any>)[] = []; private fetchFunction: FetchFn; readonly rpcUrl: string; constructor(fetchFunction: FetchFn, rpcUrl: string, options: NetworkOpts = {}) { if (typeof fetchFunction !== 'function') throw new Error('fetchFunction is required'); if (typeof rpcUrl !== 'string') throw new Error('rpcUrl is required'); this.fetchFunction = fetchFunction; this.rpcUrl = rpcUrl; this.batchSize = options.batchSize === undefined ? 1 : options.batchSize; this.headers = options.headers || {}; if (typeof this.headers !== 'object') throw new Error('invalid headers: expected object'); } private async fetchJson(body: unknown) { const res = await this.fetchFunction(this.rpcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.headers }, body: JSON.stringify(body), }); return await res.json(); } private jsonError(error: RpcErrorResponse) { return new RpcError(error); } private async batchProcess() { await nextTick(); // this allows to collect as much requests as we can in single tick const curr = this.queue.splice(0, this.batchSize); if (!curr.length) return; // Transport failures must reject every queued request; otherwise the batch leaks pending callers. let json; try { json = await this.fetchJson( curr.map((i, j) => ({ jsonrpc: '2.0', id: j, method: i.method, params: i.params, })) ); } catch (err) { curr.forEach((req) => req.reject(err)); return; } if (!Array.isArray(json)) { const hasMsg = json.code && json.message; curr.forEach((req, index) => { const err = hasMsg ? this.jsonError(json) : new Error('invalid response in batch request ' + index); req.reject(err); }); return; } const processed = new Set(); for (const res of json) { // Server sent broken ids. We cannot throw error here, since we will have unresolved promises // Also, this will break app state. if (!Number.isSafeInteger(res.id) || res.id < 0 || res.id >= curr.length) continue; if (processed.has(res.id)) continue; // multiple responses for same id const { reject, resolve } = curr[res.id]; processed.add(res.id); if (res && res.error) reject(this.jsonError(res.error)); else resolve(res.result); } for (let i = 0; i < curr.length; i++) { if (!processed.has(i)) curr[i].reject(new Error(`response missing in batch request ` + i)); } } private rpcBatch(method: string, params: RpcParams) { return new Promise((resolve, reject) => { this.queue.push({ method, params, resolve, reject }); this.batchProcess(); // this processed in parallel }); } private async rpc(method: string, params: RpcParams): Promise<any> { if (typeof method !== 'string') throw new Error('rpc method name must be a string'); if (this.batchSize > 1) return this.rpcBatch(method, params); const json = await this.fetchJson({ jsonrpc: '2.0', id: 0, method, params, }); if (json && json.error) throw this.jsonError(json.error); return json.result; } call(method: string, ...args: any[]): Promise<any> { return this.rpc(method, args); } callNamed(method: string, params: Record<string, any>): Promise<any> { return this.rpc(method, params); } } /** * Batched JSON-RPC functionality. * @param fetchFunction - Fetch implementation used for transport. * @param rpcUrl - JSON-RPC endpoint URL. * @param options - Batching and header configuration. See {@link NetworkOpts}. * @returns Configured JSON-RPC provider. * @example * Create a batched JSON-RPC helper. * ```js * import { jsonrpc } from 'micro-ftch'; * const rpc = jsonrpc(fetch, 'http://rpc_node/', { * headers: {}, * batchSize: 20, * }); * const res = await rpc.call('method', 'arg0', 'arg1'); * const res2 = await rpc.callNamed('method', { arg0: '0', arg1: '1' }); * ``` */ export function jsonrpc( fetchFunction: FetchFn, rpcUrl: string, options: NetworkOpts = {} ): JsonrpcProvider { return new JsonrpcProvider(fetchFunction, rpcUrl, options); } /** * Builds a replay bucket key from the request URL and fetch options. * @param url - Request URL. * @param opt - Fetch options used for the request. * @returns Stable string key used for capture and replay. */ type GetKeyFn = (url: string, opt: FetchOpts) => string; const defaultGetKey: GetKeyFn = (url, opt) => JSON.stringify({ url, opt }); /** Options for replayable(). */ export type ReplayOpts = { /** Throw instead of using the wrapped fetch when a request is missing from the log. */ offline?: boolean; /** Custom request-key function used for capture and replay. */ getKey?: GetKeyFn; }; /** replayable() return function, with additional logging helpers. */ export type ReplayFn = FetchFn & { /** Captured request/response payloads keyed by the replay fingerprint. */ logs: Record<string, any>; /** Keys that have been read or written through this replay wrapper. */ accessed: Set<string>; /** * Exports only the log entries touched through this wrapper. * @returns JSON string that can seed another `replayable()` instance. */ export: () => string; }; function normalizeHeader(header: string): string { return header .split('-') .map((i) => i.charAt(0).toUpperCase() + i.slice(1).toLowerCase()) .join('-'); } const getKey = (url: string, opts: FetchOpts, fn = defaultGetKey) => { // RFC 9110 §5.1: field names are case-insensitive, so replay keys need canonicalized header names. const headers: Record<string, string> = {}; // Headers accepts every HeadersInit shape and normalizes duplicate handling like fetch. new Headers(opts.headers).forEach((v, k) => { headers[normalizeHeader(k)] = v; }); return fn(url, { method: opts.method, headers, body: opts.body }); }; /** * Log & replay network requests without actually calling network code. * @param fetchFunction - Wrapped fetch implementation used to capture new responses. * @param logs - Captured request/response map, usually from `JSON.parse(replay.export())`. * @param opts - Replay configuration such as offline mode or custom keying. See {@link ReplayOpts}. * @returns Fetch-compatible wrapper with log export helpers. * @example * Record live responses once, then export the captured log. * ```js * import { ftch as createFtch, replayable } from 'micro-ftch'; * const ftch = createFtch(fetch); * const replayCapture = replayable(ftch); * await replayCapture('https://example.com/1'); * await replayCapture('https://example.com/2'); * const logs = replayCapture.export(); * ``` * @example * Replay cached responses from a previously exported log snapshot. * ```js * import { ftch as createFtch, replayable } from 'micro-ftch'; * const ftch = createFtch(fetch); * const logs = { '{"method":"GET"}': '{"ok":true}' }; * const replay = replayable(ftch, logs, { * offline: true, * getKey: (_url, opt = {}) => JSON.stringify({ method: opt.method || 'GET' }), * }); * await replay('https://example.com/1'); * ``` * @example * Offline mode throws instead of making a new request. * ```js * import { ftch as createFtch, replayable } from 'micro-ftch'; * const ftch = createFtch(fetch); * const logs = { '{"url":"https://example.com/1","opt":{"headers":{}}}': '{"ok":true}' }; * const replayTestOffline = replayable(ftch, logs, { offline: true }); * await replayTestOffline('https://example.com/1'); * ``` * @example * Collapse multiple URLs into one replay bucket when the HTTP method is what matters. * ```ts * import { ftch as createFtch, replayable, type FetchOpts } from 'micro-ftch'; * const ftch = createFtch(fetch); * const getKey = (_url: string, opt: FetchOpts = {}) => * JSON.stringify({ method: opt.method || 'GET' }); * const replay = replayable( * ftch, * { '{"method":"GET"}': '{"ok":true}' }, * { getKey, offline: true } * ); * await replay('https://example.com/1', { method: 'GET' }); * ``` */ export function replayable( fetchFunction: FetchFn, logs: Record<string, string> = {}, opts: ReplayOpts = {} ): ReplayFn { const accessed: Set<string> = new Set(); const wrapped = async (url: string, reqOpts: FetchOpts = {}) => { const key = getKey(url, reqOpts, opts.getKey); accessed.add(key); // Empty-string payloads are valid captures; missing entries must be checked by key presence, not truthiness. if (!(key in logs)) { if (opts.offline) throw new Error(`fetchReplay: unknown request=${key}`); const req = await fetchFunction(url, reqOpts); // TODO: save this too? const info = getRequestInfo(req); return { ...info, json: async () => { const json = await req.json(); logs[key] = JSON.stringify(json); return json; }, text: async () => (logs[key] = await req.text()), arrayBuffer: async () => { // TODO: add opt-in binary-safe replay; default logs stay readable text for existing fixtures. const buffer = await req.arrayBuffer(); logs[key] = new TextDecoder().decode(new Uint8Array(buffer)); return buffer; }, }; } return { // Some default values (we don't store this info for now) headers: new Headers(), ok: true, redirected: false, status: 200, statusText: 'OK', type: 'basic' as ResponseType, url: url, text: async () => logs[key], json: async () => JSON.parse(logs[key]), arrayBuffer: async () => new TextEncoder().encode(logs[key]).buffer, }; }; wrapped.logs = logs; wrapped.accessed = accessed; wrapped.export = () => JSON.stringify(Object.fromEntries(Object.entries(logs).filter(([k, _]) => accessed.has(k)))); return wrapped; } /** Internal methods for test purposes only. */ export const _TEST: { limit: typeof limit; } = /* @__PURE__ */ Object.freeze({ limit, });