mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
535 lines
18 KiB
TypeScript
535 lines
18 KiB
TypeScript
import { KnownError } from "..";
|
|
import { StackAssertionError, captureError, concatStacktraces, errorToNiceString } from "./errors";
|
|
import { DependenciesMap } from "./maps";
|
|
import { Result } from "./results";
|
|
import { generateUuid } from "./uuids";
|
|
|
|
export type ReactPromise<T> = Promise<T> & (
|
|
| { status: "rejected", reason: unknown }
|
|
| { status: "fulfilled", value: T }
|
|
| { status: "pending" }
|
|
);
|
|
|
|
type Resolve<T> = (value: T) => void;
|
|
type Reject = (reason: unknown) => void;
|
|
export function createPromise<T>(callback: (resolve: Resolve<T>, reject: Reject) => void): ReactPromise<T> {
|
|
let status = "pending" as "fulfilled" | "rejected" | "pending";
|
|
let valueOrReason: T | unknown | undefined = undefined;
|
|
let resolve: Resolve<T> | null = null;
|
|
let reject: Reject | null = null;
|
|
const promise = new Promise<T>((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<number>((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<number>((_, 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<number>(() => {
|
|
// 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<number>((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<unknown>> | 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<T>(value: T): ReactPromise<T> {
|
|
resolvedCache ??= new DependenciesMap<[unknown], ReactPromise<unknown>>();
|
|
if (resolvedCache.has([value])) {
|
|
return resolvedCache.get([value]) as ReactPromise<T>;
|
|
}
|
|
|
|
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<unknown>> | 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<T>(reason: unknown): ReactPromise<T> {
|
|
rejectedCache ??= new DependenciesMap<[unknown], ReactPromise<unknown>>();
|
|
if (rejectedCache.has([reason])) {
|
|
return rejectedCache.get([reason]) as ReactPromise<T>;
|
|
}
|
|
|
|
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<number>(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<string>("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<number>(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<number>(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<never>(() => {}));
|
|
export function neverResolve(): ReactPromise<never> {
|
|
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<T>(promise: Promise<T>, options: { disableErrorWrapping?: boolean } = {}): ReactPromise<T> {
|
|
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<T>;
|
|
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<T extends Promise<any>>(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<T>(promise: Promise<T>): 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) {
|
|
if (!Number.isFinite(ms) || ms < 0) {
|
|
throw new StackAssertionError(`wait() requires a non-negative integer number of milliseconds to wait. (found: ${ms}ms)`);
|
|
}
|
|
if (ms >= 2**31) {
|
|
throw new StackAssertionError("The maximum timeout for wait() is 2147483647ms (2**31 - 1). (found: ${ms}ms)");
|
|
}
|
|
return await new Promise<void>(resolve => setTimeout(resolve, ms));
|
|
}
|
|
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();
|
|
|
|
// 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(StackAssertionError);
|
|
expect((error as Error).message).toContain("wait() requires a non-negative integer");
|
|
}
|
|
});
|
|
|
|
export function runAsynchronouslyWithAlert(...args: Parameters<typeof runAsynchronously>) {
|
|
return runAsynchronously(
|
|
args[0],
|
|
{
|
|
...args[1],
|
|
onError: error => {
|
|
if (KnownError.isKnownError(error) && typeof process !== "undefined" && (process.env.NODE_ENV as any)?.includes("production")) {
|
|
alert(error.message);
|
|
} else {
|
|
alert(`An unhandled error occurred. Please ${process.env.NODE_ENV === "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<unknown> | (() => void | Promise<unknown>) | 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 StackAssertionError(
|
|
"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<T>(promise: Promise<T>, ms: number): Promise<Result<T, TimeoutError>> {
|
|
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<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
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);
|
|
});
|
|
|
|
|
|
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<T>(
|
|
func: () => Promise<T>,
|
|
options: RateLimitOptions,
|
|
): () => Promise<T> {
|
|
let waitUntil = performance.now();
|
|
let queue: [(t: T) => void, (e: unknown) => void][] = [];
|
|
let addedToQueueCallbacks = new Map<string, () => 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<void>(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<T>((resolve, reject) => {
|
|
waitUntil = Math.max(
|
|
waitUntil,
|
|
performance.now() + (options.debounceMs ?? 0),
|
|
);
|
|
queue.push([resolve, reject]);
|
|
addedToQueueCallbacks.forEach(cb => cb());
|
|
});
|
|
};
|
|
}
|
|
|
|
export function throttled<T, A extends any[]>(func: (...args: A) => Promise<T>, delayMs: number): (...args: A) => Promise<T> {
|
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
|
let nextAvailable: Promise<T> | null = null;
|
|
return async (...args) => {
|
|
while (nextAvailable !== null) {
|
|
await nextAvailable;
|
|
}
|
|
nextAvailable = new Promise<T>(resolve => {
|
|
timeout = setTimeout(() => {
|
|
nextAvailable = null;
|
|
resolve(func(...args));
|
|
}, delayMs);
|
|
});
|
|
return await nextAvailable;
|
|
};
|
|
}
|