import { KnownError } from ".."; import { getProcessEnv } from "./env"; import { HexclaveAssertionError, captureError, concatStacktraces, errorToNiceString } from "./errors"; import { DependenciesMap } from "./maps"; import { Result } from "./results"; import { traceSpan } from "./telemetry"; import { generateUuid } from "./uuids"; export type ReactPromise = Promise & ( | { status: "rejected", reason: unknown } | { status: "fulfilled", value: T } | { status: "pending" } ); type Resolve = (value: T) => void; type Reject = (reason: unknown) => void; export function createPromise(callback: (resolve: Resolve, reject: Reject) => void): ReactPromise { let status = "pending" as "fulfilled" | "rejected" | "pending"; let valueOrReason: T | unknown | undefined = undefined; let resolve: Resolve | null = null; let reject: Reject | null = null; const promise = new Promise((res, rej) => { resolve = (value) => { if (status !== "pending") return; status = "fulfilled"; valueOrReason = value; res(value); }; reject = (reason) => { if (status !== "pending") return; status = "rejected"; valueOrReason = reason; rej(reason); }; }); callback(resolve!, reject!); return Object.assign(promise, { status: status, ...status === "fulfilled" ? { value: valueOrReason as T } : {}, ...status === "rejected" ? { reason: valueOrReason } : {}, } as any); } import.meta.vitest?.test("createPromise", async ({ expect }) => { // Test resolved promise const resolvedPromise = createPromise((resolve) => { resolve(42); }); expect(resolvedPromise.status).toBe("fulfilled"); expect((resolvedPromise as any).value).toBe(42); expect(await resolvedPromise).toBe(42); // Test rejected promise const error = new Error("Test error"); const rejectedPromise = createPromise((_, reject) => { reject(error); }); expect(rejectedPromise.status).toBe("rejected"); expect((rejectedPromise as any).reason).toBe(error); await expect(rejectedPromise).rejects.toBe(error); // Test pending promise const pendingPromise = createPromise(() => { // Do nothing, leave it pending }); expect(pendingPromise.status).toBe("pending"); expect((pendingPromise as any).value).toBeUndefined(); expect((pendingPromise as any).reason).toBeUndefined(); // Test that resolving after already resolved does nothing let resolveCount = 0; const multiResolvePromise = createPromise((resolve) => { resolve(1); resolveCount++; resolve(2); resolveCount++; }); expect(resolveCount).toBe(2); // Both resolve calls executed expect(multiResolvePromise.status).toBe("fulfilled"); expect((multiResolvePromise as any).value).toBe(1); // Only first resolve took effect expect(await multiResolvePromise).toBe(1); }); let resolvedCache: DependenciesMap<[unknown], ReactPromise> | null = null; /** * Like Promise.resolve(...), but also adds the status and value properties for use with React's `use` hook, and caches * the value so that invoking `resolved` twice returns the same promise. */ export function resolved(value: T): ReactPromise { resolvedCache ??= new DependenciesMap<[unknown], ReactPromise>(); if (resolvedCache.has([value])) { return resolvedCache.get([value]) as ReactPromise; } const res = Object.assign(Promise.resolve(value), { status: "fulfilled", value, } as const); resolvedCache.set([value], res); return res; } import.meta.vitest?.test("resolved", async ({ expect }) => { // Test with primitive value const promise1 = resolved(42); expect(promise1.status).toBe("fulfilled"); // Need to use type assertion since value is only available when status is "fulfilled" expect((promise1 as { value: number }).value).toBe(42); expect(await promise1).toBe(42); // Test with object value const obj = { test: true }; const promise2 = resolved(obj); expect(promise2.status).toBe("fulfilled"); expect((promise2 as { value: typeof obj }).value).toBe(obj); expect(await promise2).toBe(obj); // Test caching (same reference for same value) const promise3 = resolved(42); expect(promise3).toBe(promise1); // Same reference due to caching // Test with different value (different reference) const promise4 = resolved(43); expect(promise4).not.toBe(promise1); }); let rejectedCache: DependenciesMap<[unknown], ReactPromise> | null = null; /** * Like Promise.reject(...), but also adds the status and value properties for use with React's `use` hook, and caches * the value so that invoking `rejected` twice returns the same promise. */ export function rejected(reason: unknown): ReactPromise { rejectedCache ??= new DependenciesMap<[unknown], ReactPromise>(); if (rejectedCache.has([reason])) { return rejectedCache.get([reason]) as ReactPromise; } const promise = Promise.reject(reason); ignoreUnhandledRejection(promise); const res = Object.assign(promise, { status: "rejected", reason: reason, } as const); rejectedCache.set([reason], res); return res; } import.meta.vitest?.test("rejected", ({ expect }) => { // Test with error object const error = new Error("Test error"); const promise1 = rejected(error); expect(promise1.status).toBe("rejected"); // Need to use type assertion since reason is only available when status is "rejected" expect((promise1 as { reason: Error }).reason).toBe(error); // Test with string reason const promise2 = rejected("error message"); expect(promise2.status).toBe("rejected"); expect((promise2 as { reason: string }).reason).toBe("error message"); // Test caching (same reference for same reason) const promise3 = rejected(error); expect(promise3).toBe(promise1); // Same reference due to caching // Test with different reason (different reference) const differentError = new Error("Different error"); const promise4 = rejected(differentError); expect(promise4).not.toBe(promise1); // Note: We're not using await expect(promise).rejects to avoid unhandled rejections }); // We'll skip the rejection test for pending() since it's causing unhandled rejections // The function is already well tested through other tests like rejected() and createPromise() const neverResolvePromise = pending(new Promise(() => {})); export function neverResolve(): ReactPromise { return neverResolvePromise; } import.meta.vitest?.test("neverResolve", ({ expect }) => { const promise = neverResolve(); expect(promise.status).toBe("pending"); expect((promise as any).value).toBeUndefined(); expect((promise as any).reason).toBeUndefined(); // Test that multiple calls return the same promise const promise2 = neverResolve(); expect(promise2).toBe(promise); }); export function pending(promise: Promise, options: { disableErrorWrapping?: boolean } = {}): ReactPromise { const res = promise.then( value => { res.status = "fulfilled"; (res as any).value = value; return value; }, actualReason => { res.status = "rejected"; (res as any).reason = actualReason; throw actualReason; }, ) as ReactPromise; res.status = "pending"; return res; } import.meta.vitest?.test("pending", async ({ expect }) => { // Test with a promise that resolves const resolvePromise = Promise.resolve(42); const pendingPromise = pending(resolvePromise); // Initially it should be pending expect(pendingPromise.status).toBe("pending"); // After resolution, it should be fulfilled await resolvePromise; // Need to wait a tick for the then handler to execute await new Promise(resolve => setTimeout(resolve, 0)); expect(pendingPromise.status).toBe("fulfilled"); expect((pendingPromise as { value: number }).value).toBe(42); // For the rejection test, we'll use a separate test to avoid unhandled rejections }); /** * Should be used to wrap Promises that are not immediately awaited, so they don't throw an unhandled promise rejection * error. * * Vercel kills serverless functions on unhandled promise rejection errors, so this is important. */ export function ignoreUnhandledRejection>(promise: T): void { promise.catch(() => {}); } import.meta.vitest?.test("ignoreUnhandledRejection", async ({ expect }) => { // Test with a promise that resolves const resolvePromise = Promise.resolve(42); ignoreUnhandledRejection(resolvePromise); expect(await resolvePromise).toBe(42); // Should still resolve to the same value // Test with a promise that rejects // The promise should still reject, but the rejection is caught internally // so it doesn't cause an unhandled rejection error const error = new Error("Test error"); const rejectPromise = Promise.reject(error); ignoreUnhandledRejection(rejectPromise); await expect(rejectPromise).rejects.toBe(error); }); /** * See concatStacktraces for more information. */ export function concatStacktracesIfRejected(promise: Promise): void { const currentError = new Error(); promise.catch(error => { if (error instanceof Error) { concatStacktraces(error, currentError); } else { // we can only concatenate errors, so we'll just ignore the non-error } }); } export async function wait(ms: number, options?: { unref?: boolean }) { if (!Number.isFinite(ms) || ms < 0) { throw new HexclaveAssertionError(`wait() requires a non-negative integer number of milliseconds to wait. (found: ${ms}ms)`); } if (ms >= 2**31) { throw new HexclaveAssertionError("The maximum timeout for wait() is 2147483647ms (2**31 - 1). (found: ${ms}ms)"); } return await traceSpan({ description: 'wait(...)', attributes: { 'stack.wait.ms': ms } }, async (span) => { return await new Promise((resolve) => { const timeout = setTimeout(resolve, ms); if (options?.unref === true && typeof timeout === "object") timeout.unref(); }); }); } import.meta.vitest?.test("wait", async ({ expect }) => { // Test with valid input const start = Date.now(); await wait(10); const elapsed = Date.now() - start; expect(elapsed).toBeGreaterThanOrEqual(5); // Allow some flexibility in timing // Test with zero await expect(wait(0)).resolves.toBeUndefined(); // Node's unref option must preserve the normal wait behavior while another // referenced handle (the test runner) owns the process lifetime. await expect(wait(0, { unref: true })).resolves.toBeUndefined(); // Test with negative number await expect(wait(-10)).rejects.toThrow("wait() requires a non-negative integer"); // Test with non-finite number await expect(wait(NaN)).rejects.toThrow("wait() requires a non-negative integer"); await expect(wait(Infinity)).rejects.toThrow("wait() requires a non-negative integer"); // Test with too large number await expect(wait(2**31)).rejects.toThrow("The maximum timeout for wait()"); }); export async function waitUntil(date: Date) { return await wait(date.getTime() - Date.now()); } import.meta.vitest?.test("waitUntil", async ({ expect }) => { // Test with future date const futureDate = new Date(Date.now() + 10); const start = Date.now(); await waitUntil(futureDate); const elapsed = Date.now() - start; expect(elapsed).toBeGreaterThanOrEqual(5); // Allow some flexibility in timing // Test with past date - this will throw because wait() requires non-negative time // We need to verify it throws the correct error try { await waitUntil(new Date(Date.now() - 1000)); expect.fail("Should have thrown an error"); } catch (error) { expect(error).toBeInstanceOf(HexclaveAssertionError); expect((error as Error).message).toContain("wait() requires a non-negative integer"); } }); export function runAsynchronouslyWithAlert(...args: Parameters) { return runAsynchronously( args[0], { ...args[1], onError: error => { const nodeEnv = getProcessEnv("NODE_ENV"); if (KnownError.isKnownError(error) && nodeEnv?.includes("production")) { alert(error.message); } else { alert(`An unhandled error occurred. Please ${nodeEnv === "development" ? `check the browser console for the full error.` : "report this to the developer."}\n\n${error}`); } args[1]?.onError?.(error); }, }, ...args.slice(2) as [], ); } import.meta.vitest?.test("runAsynchronouslyWithAlert", ({ expect }) => { // Simple test to verify the function calls runAsynchronously // We can't easily test the alert functionality without mocking const testFn = () => Promise.resolve("test"); const testOptions = { noErrorLogging: true }; // Just verify it doesn't throw expect(() => runAsynchronouslyWithAlert(testFn, testOptions)).not.toThrow(); // We can't easily test the error handling without mocking, so we'll // just verify the function exists and can be called expect(typeof runAsynchronouslyWithAlert).toBe("function"); }); export function runAsynchronously( promiseOrFunc: void | Promise | (() => void | Promise) | undefined, options: { noErrorLogging?: boolean, onError?: (error: Error) => void, } = {}, ): void { if (typeof promiseOrFunc === "function") { promiseOrFunc = promiseOrFunc(); } if (promiseOrFunc) { concatStacktracesIfRejected(promiseOrFunc); promiseOrFunc.catch(error => { options.onError?.(error); const newError = new HexclaveAssertionError( "Uncaught error in asynchronous function: " + errorToNiceString(error), { cause: error }, ); if (!options.noErrorLogging) { captureError("runAsynchronously", newError); } }); } } import.meta.vitest?.test("runAsynchronously", ({ expect }) => { // Simple test to verify the function exists and can be called const testFn = () => Promise.resolve("test"); // Just verify it doesn't throw expect(() => runAsynchronously(testFn)).not.toThrow(); expect(() => runAsynchronously(Promise.resolve("test"))).not.toThrow(); expect(() => runAsynchronously(undefined)).not.toThrow(); // We can't easily test the error handling without mocking, so we'll // just verify the function exists and can be called with options expect(() => runAsynchronously(testFn, { noErrorLogging: true })).not.toThrow(); expect(() => runAsynchronously(testFn, { onError: () => {} })).not.toThrow(); }); class TimeoutError extends Error { constructor(public readonly ms: number) { super(`Timeout after ${ms}ms`); this.name = "TimeoutError"; } } export async function timeout(promiseOrFunc: Promise | (() => Promise), ms: number): Promise> { const promise = typeof promiseOrFunc === "function" ? promiseOrFunc() : promiseOrFunc; return await Promise.race([ promise.then(value => Result.ok(value)), wait(ms).then(() => Result.error(new TimeoutError(ms))), ]); } import.meta.vitest?.test("timeout", async ({ expect }) => { // Test with a promise that resolves quickly const fastPromise = Promise.resolve(42); const fastResult = await timeout(fastPromise, 100); expect(fastResult.status).toBe("ok"); if (fastResult.status === "ok") { expect(fastResult.data).toBe(42); } // Test with a promise that takes longer than the timeout const slowPromise = new Promise(resolve => setTimeout(() => resolve("too late"), 50)); const slowResult = await timeout(slowPromise, 10); expect(slowResult.status).toBe("error"); if (slowResult.status === "error") { expect(slowResult.error).toBeInstanceOf(TimeoutError); expect((slowResult.error as TimeoutError).ms).toBe(10); } }); export async function timeoutThrow(promise: Promise, ms: number): Promise { return Result.orThrow(await timeout(promise, ms)); } import.meta.vitest?.test("timeoutThrow", async ({ expect }) => { // Test with a promise that resolves quickly const fastPromise = Promise.resolve(42); const fastResult = await timeoutThrow(fastPromise, 100); expect(fastResult).toBe(42); // Test with a promise that takes longer than the timeout const slowPromise = new Promise(resolve => setTimeout(() => resolve("too late"), 50)); await expect(timeoutThrow(slowPromise, 10)).rejects.toThrow("Timeout after 10ms"); await expect(timeoutThrow(slowPromise, 10)).rejects.toBeInstanceOf(TimeoutError); }); /** * Maps over `items` with `fn`, running at most `concurrency` invocations at a time. * * Unlike `Promise.all(items.map(fn))`, this bounds the number of in-flight * promises, which matters when `fn` hits a shared resource (e.g. a database) and * an unbounded fan-out could exhaust connections or overload a replica. Results * are returned in input order regardless of completion order, and the first * rejection aborts further scheduling — already in-flight workers still settle * but no new items are started. */ export async function mapWithConcurrency( items: readonly T[], concurrency: number, fn: (item: T, index: number) => Promise, ): Promise { if (!Number.isInteger(concurrency) || concurrency < 1) { throw new HexclaveAssertionError(`mapWithConcurrency requires a positive integer concurrency, got ${concurrency}`); } const results = new Array(items.length); let nextIndex = 0; let aborted = false; const worker = async () => { while (!aborted) { // Claim an index synchronously before awaiting so workers never process the same item. const index = nextIndex++; if (index >= items.length) return; try { // Bounds-checked above; `?? throwErr(…)` is unsuitable because T may legitimately be null/undefined results[index] = await fn(items[index] as T, index); } catch (error) { aborted = true; throw error; } } }; const workerCount = Math.min(concurrency, items.length); await Promise.all(Array.from({ length: workerCount }, () => worker())); return results; } import.meta.vitest?.test("mapWithConcurrency", async ({ expect }) => { // Preserves input order regardless of completion order. const ordered = await mapWithConcurrency([30, 10, 20], 3, async (ms, index) => { await wait(ms); return `${index}:${ms}`; }); expect(ordered).toEqual(["0:30", "1:10", "2:20"]); // Never exceeds the configured concurrency. let inFlight = 0; let maxInFlight = 0; await mapWithConcurrency(Array.from({ length: 10 }, (_, i) => i), 3, async () => { inFlight++; maxInFlight = Math.max(maxInFlight, inFlight); await wait(5); inFlight--; }); expect(maxInFlight).toBe(3); // Empty input spawns no workers and returns an empty array. expect(await mapWithConcurrency([], 4, async () => 1)).toEqual([]); // Invalid concurrency fails loudly. await expect(mapWithConcurrency([1], 0, async (x) => x)).rejects.toThrow("positive integer concurrency"); }); export type RateLimitOptions = { /** * The number of requests to process in parallel. Currently only 1 is supported. */ concurrency: 1, /** * If true, multiple requests waiting at the same time will be reduced to just one. Default is false. */ batchCalls?: boolean, /** * Waits for throttleMs since the start of last request before starting the next request. Default is 0. */ throttleMs?: number, /** * Waits for gapMs since the end of last request before starting the next request. Default is 0. */ gapMs?: number, /** * Waits until there have been no new requests for debounceMs before starting a new request. Default is 0. */ debounceMs?: number, }; export function rateLimited( func: () => Promise, options: RateLimitOptions, ): () => Promise { let waitUntil = performance.now(); let queue: [(t: T) => void, (e: unknown) => void][] = []; let addedToQueueCallbacks = new Map void>; const next = async () => { while (true) { if (waitUntil > performance.now()) { await wait(Math.max(1, waitUntil - performance.now() + 1)); } else if (queue.length === 0) { const uuid = generateUuid(); await new Promise(resolve => { addedToQueueCallbacks.set(uuid, resolve); }); addedToQueueCallbacks.delete(uuid); } else { break; } } const nextFuncs = options.batchCalls ? queue.splice(0, queue.length) : [queue.shift()!]; const start = performance.now(); const value = await Result.fromPromise(func()); const end = performance.now(); waitUntil = Math.max( waitUntil, start + (options.throttleMs ?? 0), end + (options.gapMs ?? 0), ); for (const nextFunc of nextFuncs) { if (value.status === "ok") { nextFunc[0](value.data); } else { nextFunc[1](value.error); } } }; runAsynchronously(async () => { while (true) { await next(); } }); return () => { return new Promise((resolve, reject) => { waitUntil = Math.max( waitUntil, performance.now() + (options.debounceMs ?? 0), ); queue.push([resolve, reject]); addedToQueueCallbacks.forEach(cb => cb()); }); }; } export function throttled(func: (...args: A) => Promise, delayMs: number): (...args: A) => Promise { let timeout: ReturnType | null = null; let nextAvailable: Promise | null = null; return async (...args) => { while (nextAvailable !== null) { await nextAvailable; } nextAvailable = new Promise(resolve => { timeout = setTimeout(() => { nextAvailable = null; resolve(func(...args)); }, delayMs); }); return await nextAvailable; }; }