~james/core

core/web/src/lib/api/_request.ts -rw-r--r-- 5.0 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
import { ClientResponseError, isXRPCErrorPayload, type XRPCErrorPayload } from "@atcute/client";
import type { OAuthUserAgent } from "@atcute/oauth-browser-client";
import {
	createBobbinClient,
	type BobbinContext,
	type QueryValue,
	type XrpcRequestInit
} from "#lib/api/client.ts";
import { hostForServiceDid } from "#lib/auth/agent.ts";
import { xrpcPathOf, type XrpcPath } from "#lib/api/syntax.ts";

export const SCHEME_PREFIX = /^[a-z][a-z0-9+.-]*:\/\//i;

export const serviceUrlFor = (host: string): string =>
	SCHEME_PREFIX.test(host) ? host.replace(/\/+$/, "") : `https://${host}`;

export const serviceClient = (agent: OAuthUserAgent, host: string): BobbinContext =>
	createBobbinClient({ serviceUrl: serviceUrlFor(host), agent });

const BARE_HOST_PATTERN =
	/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?::[0-9]{1,5})?$/i;

// a last label of digits or 0x.. is an ipv4 literal to the url parser (2130706433, 0x7f.1, 017700000001)
const NUMERIC_LABEL = /^(?:[0-9]+|0x[0-9a-f]*)$/i;
const INTERNAL_SUFFIXES = [".localhost", ".local", ".internal"];

// web fetches these hosts server side, so anything that names this machine or its network is out.
// dev allows them because the local stack runs on loopback
const isPublicName = (name: string): boolean => {
	const lower = name.toLowerCase();
	const labels = lower.split(".");
	if (labels.length < 2 || NUMERIC_LABEL.test(labels[labels.length - 1])) return false;
	return !INTERNAL_SUFFIXES.some((suffix) => lower.endsWith(suffix));
};

const isBareHost = (value: string): boolean => {
	if (!value || !BARE_HOST_PATTERN.test(value)) return false;
	const colon = value.indexOf(":");
	if (colon !== -1) {
		const port = Number(value.slice(colon + 1));
		if (port < 1 || port > 65535) return false;
	}
	return import.meta.env.DEV || isPublicName(colon === -1 ? value : value.slice(0, colon));
};

export const hostForKnot = (knot: string): string => {
	if (knot.startsWith("did:web:")) {
		if (knot.slice("did:web:".length).includes(":")) {
			throw new Error(`invalid knot identifier: ${knot}`);
		}
		const host = hostForServiceDid(knot);
		if (host && isBareHost(host)) return host;
		throw new Error(`invalid knot identifier: ${knot}`);
	}
	if (isBareHost(knot)) return knot;
	throw new Error(`invalid knot identifier: ${knot}`);
};

// manifest values are untrusted, so junk returns undefined instead of throwing
export const tryHostForKnot = (knot: string | undefined): string | undefined => {
	if (!knot) return undefined;
	try {
		return hostForKnot(knot);
	} catch {
		return undefined;
	}
};

export const buildUrl = (
	origin: string,
	path: XrpcPath,
	params?: Record<string, QueryValue>
): URL => {
	const url = new URL(`/xrpc/${path}`, `${origin}/`);
	if (params) {
		for (const [key, value] of Object.entries(params)) {
			if (value === undefined) continue;
			if (Array.isArray(value)) {
				for (const item of value) url.searchParams.append(key, String(item));
			} else {
				url.searchParams.set(key, String(value));
			}
		}
	}
	return url;
};

export const toResponseError = async (response: Response): Promise<ClientResponseError> => {
	let data: XRPCErrorPayload = { error: "XRPCError", message: response.statusText };
	try {
		const body: unknown = await response.json();
		if (isXRPCErrorPayload(body)) data = body;
	} catch {
		// keeps the status-line fallback for non-json bodies
	}
	return new ClientResponseError({ status: response.status, headers: response.headers, data });
};

export const jsonGet = async <T>(
	ctx: BobbinContext,
	nsid: string,
	params?: Record<string, QueryValue>,
	init?: XrpcRequestInit
): Promise<T> => {
	const response = await ctx.fetch(buildUrl(ctx.serviceUrl, xrpcPathOf(nsid), params), {
		headers: { accept: "application/json", ...init?.headers },
		signal: init?.signal
	});
	if (!response.ok) throw await toResponseError(response);
	if (typeof response.text === "function") {
		const text = await response.text();
		return (text.length > 0 ? JSON.parse(text) : undefined) as T;
	}
	return (await response.json()) as T;
};

export const jsonPost = async <T>(
	ctx: BobbinContext,
	nsid: string,
	body: unknown,
	init?: XrpcRequestInit
): Promise<T> => {
	const response = await ctx.fetch(buildUrl(ctx.serviceUrl, xrpcPathOf(nsid)), {
		method: "POST",
		headers: {
			...(body !== undefined ? { "content-type": "application/json" } : {}),
			accept: "application/json",
			...init?.headers
		},
		body: body !== undefined ? JSON.stringify(body) : undefined,
		signal: init?.signal
	});
	if (!response.ok) throw await toResponseError(response);
	if (typeof response.text === "function") {
		const text = await response.text();
		return (text.length > 0 ? JSON.parse(text) : undefined) as T;
	}
	return (await response.json()) as T;
};

export const rawGet = async (
	ctx: BobbinContext,
	nsid: string,
	params?: Record<string, QueryValue>,
	init?: XrpcRequestInit
): Promise<Response> => {
	const response = await ctx.fetch(buildUrl(ctx.serviceUrl, xrpcPathOf(nsid), params), {
		headers: init?.headers,
		signal: init?.signal
	});
	if (!response.ok) throw await toResponseError(response);
	return response;
};