import { browser } from "$app/env";
import { error, type RequestEvent } from "@sveltejs/kit";
import { ClientResponseError } from "#lib/api/client.ts";
export const httpStatusFor = (cause: unknown): number => {
if (cause instanceof ClientResponseError) {
if (cause.status >= 400 && cause.status <= 599) return cause.status;
switch (cause.error) {
case "RecordNotFound":
return 404;
case "InvalidRequest":
return 400;
case "UpstreamFailed":
case "UpstreamGone":
case "InvalidRecord":
return 502;
case "Overloaded":
return 503;
default:
return 500;
}
}
return 500;
};
export const errorMessage = (cause: unknown, fallbackMessage = "Request failed"): string =>
cause instanceof ClientResponseError ? (cause.description ?? cause.error) : fallbackMessage;
export const toHttpError = (cause: unknown, fallbackMessage = "Request failed"): never => {
throw error(httpStatusFor(cause), errorMessage(cause, fallbackMessage));
};
export const parallel = async <T extends Record<string, Promise<unknown>>>(
tasks: T
): Promise<{ [K in keyof T]: Awaited<T[K]> }> => {
const keys = Object.keys(tasks) as (keyof T)[];
const values = await Promise.all(keys.map((key) => tasks[key]));
const out = {} as { [K in keyof T]: Awaited<T[K]> };
keys.forEach((key, index) => {
out[key] = values[index] as Awaited<T[keyof T]>;
});
return out;
};
// unhandled rejections kill node if nothing on the page awaits the promise
export const stream = <T>(promise: Promise<T>): Promise<T> => {
promise.catch(() => {});
return promise;
};
export const orNull = <T>(promise: Promise<T>): Promise<T | null> => promise.catch(() => null);
// a skeleton that appears only to be replaced a frame later is worse than a slightly longer wait
const PATIENCE_MS = 200;
// all loads in one navigation share the window
let windowEnds: number | null = null;
export const endNavigation = (): void => {
windowEnds = null;
};
const patience = (): number => {
const now = performance.now();
if (windowEnds === null || now - windowEnds > PATIENCE_MS) windowEnds = now + PATIENCE_MS;
return Math.max(0, windowEnds - now);
};
type DataEvent = Pick<RequestEvent, "isDataRequest" | "request">;
const serverWindows = new WeakMap<Request, number>();
const serverPatience = ({ request }: DataEvent): number => {
const now = performance.now();
let ends = serverWindows.get(request);
if (ends === undefined || now - ends > PATIENCE_MS) {
ends = now + PATIENCE_MS;
serverWindows.set(request, ends);
}
return Math.max(0, ends - now);
};
type Held<T> = { value: T } | { promise: Promise<T> };
const within = <T>(promise: Promise<T>, left: number): Promise<Held<T>> =>
new Promise((resolve) => {
const timer = setTimeout(() => resolve({ promise }), left);
promise.then(
(value) => {
clearTimeout(timer);
resolve({ value });
},
// the rejection stays on the promise, whoever awaits it in the page owns it
() => {
clearTimeout(timer);
resolve({ promise });
}
);
});
export type Settled<T> = {
[K in keyof T]: Awaited<T[K]> | T[K];
};
export const hold = async <T extends Record<string, unknown>>(
data: T,
event?: DataEvent
): Promise<Settled<T>> => {
const left = event ? serverPatience(event) : patience();
const entries = await Promise.all(
Object.entries(data).map(async ([key, value]) => {
if (!(value instanceof Promise) || left <= 0) return [key, value] as const;
const held = await within(value, left);
return [key, "value" in held ? held.value : held.promise] as const;
})
);
return Object.fromEntries(entries) as Settled<T>;
};
// the navigation waits on these, so the page it is leaving stays until the next one can paint
export const settle = async <T extends Record<string, unknown>>(
data: T,
event?: DataEvent
): Promise<Settled<T>> => {
if (!browser && !event?.isDataRequest) return data as Settled<T>;
return hold(data, event);
};
export const early = async (event: DataEvent, promise: Promise<unknown>): Promise<void> => {
if (!event.isDataRequest) {
await promise;
return;
}
await Promise.race([
promise,
new Promise<void>((resolve) => setTimeout(resolve, serverPatience(event)))
]);
};
// per-request promise de-dupe cache.
export interface RequestCache {
run<T>(key: string, load: () => Promise<T>): Promise<T>;
}
export const createRequestCache = (): RequestCache => {
const entries = new Map<string, Promise<unknown>>();
return {
run<T>(key: string, load: () => Promise<T>): Promise<T> {
const existing = entries.get(key) as Promise<T> | undefined;
if (existing) return existing;
const pending = load();
entries.set(key, pending);
return pending;
}
};
};