~james/core

core/web/src/lib/api/client.ts -rw-r--r-- 4.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
import { Client, simpleFetchHandler } from "@atcute/client";
import type { OAuthUserAgent } from "@atcute/oauth-browser-client";
import { isNsid } from "@atcute/lexicons/syntax";
import { mintServiceAuth, serviceDidForHost } from "#lib/auth/agent.ts";
import { didOf, nsidOf, type Nsid } from "#lib/api/syntax.ts";
import { normalizeHandle } from "#lib/handle.ts";
import type { Fetch } from "#lib/fetch.ts";

export { ClientResponseError, isXRPCErrorPayload, ok } from "@atcute/client";
export type { XRPCErrorPayload } from "@atcute/client";

// generated lexicons register ambient types; don't import the barrel at runtime.

export interface BobbinContext {
	readonly xrpc: Client;
	readonly serviceUrl: string;
	readonly fetch: Fetch;
}

export interface CreateBobbinOptions {
	serviceUrl: string;
	fetch?: Fetch;
	/** when set, every xrpc request carries a freshly minted service-auth jwt */
	agent?: OAuthUserAgent;
	/** off for bodies with no handles that must keep streaming, see withNormalizedHandles */
	normalizeHandles?: boolean;
}

export const nsidFromUrl = (input: RequestInfo | URL): Nsid | null => {
	const href = input instanceof Request ? input.url : String(input);
	let pathname: string;
	try {
		pathname = new URL(href).pathname;
	} catch {
		return null;
	}
	const [, prefix, nsid, ...rest] = pathname.split("/");
	return prefix === "xrpc" && nsid && rest.length === 0 && isNsid(nsid) ? nsidOf(nsid) : null;
};

// pocket keys stored preferences by the token's `aud`
export const PREFERENCES_AUD = didOf("did:web:tangled.org");
export const PREFERENCES_LXMS: readonly Nsid[] = [
	nsidOf("com.bad-example.pocket.getPreferences"),
	nsidOf("com.bad-example.pocket.putPreferences")
];

export const withServiceAuth = (base: Fetch, agent: OAuthUserAgent, origin: string): Fetch => {
	return async (input, init) => {
		const lxm = nsidFromUrl(input);
		if (!lxm) return base(input, init);

		const aud = PREFERENCES_LXMS.includes(lxm)
			? PREFERENCES_AUD
			: serviceDidForHost(new URL(origin).host);
		const token = await mintServiceAuth(agent, { aud, lxm });
		const headers = new Headers(init?.headers);
		headers.set("authorization", `Bearer ${token}`);
		return base(input, { ...init, headers });
	};
};

// the handles the ui shows come out of bobbin as a `handle` field somewhere in a json
// body, so normalizing them here covers the views at once. it buffers the body, so
// non-json (patches, archives, blobs) passes through, and git reads opt out
export const withNormalizedHandles = (base: Fetch): Fetch => {
	return async (input, init) => {
		const response = await base(input, init);
		if (!response.headers.get("content-type")?.includes("application/json")) return response;
		const text = await response.text();
		if (!text.includes('"handle"')) return new Response(text, response);
		const body = JSON.stringify(
			JSON.parse(text, (key, value: unknown) =>
				key === "handle" && typeof value === "string" ? normalizeHandle(value) : value
			)
		);
		// the body is decoded and rewritten, so the wire's encoding and length no longer apply
		const headers = new Headers(response.headers);
		headers.delete("content-encoding");
		headers.delete("content-length");
		return new Response(body, {
			status: response.status,
			statusText: response.statusText,
			headers
		});
	};
};

export const createBobbinClient = ({
	serviceUrl,
	fetch,
	agent,
	normalizeHandles = true
}: CreateBobbinOptions): BobbinContext => {
	const origin = serviceUrl.replace(/\/+$/, "");
	const rawFetch = fetch ?? globalThis.fetch;
	const baseFetch = normalizeHandles ? withNormalizedHandles(rawFetch) : rawFetch;
	const boundFetch = agent ? withServiceAuth(baseFetch, agent, origin) : baseFetch;
	const xrpc = new Client({
		handler: simpleFetchHandler({
			service: origin,
			fetch: boundFetch as typeof globalThis.fetch
		})
	});
	return { xrpc, serviceUrl: origin, fetch: boundFetch };
};

export type QueryValue = string | number | boolean | readonly (string | number)[] | undefined;

export interface XrpcRequestInit {
	signal?: AbortSignal;
	headers?: HeadersInit;
}