~james/core

core/web/src/lib/api/load.ts -rw-r--r-- 4.6 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
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;
		}
	};
};