@zerolith/client 0.0.0-canary.20261102.g8292c77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,280 @@
1
+ /**
2
+ * @zerolith/client: a typed HTTP client over a neutral contract. The route
3
+ * table and the types come from `@zerolith/client/generate`, which reads an
4
+ * OpenAPI document; the client itself knows nothing about the server beyond
5
+ * its paths. Failures are RFC 9457 problems, thrown as `ProblemError`.
6
+ */
7
+ import { PROBLEM_MEDIA_TYPE, type Problem, type Resource, isProblem, resource } from "zerolith";
8
+
9
+ /**
10
+ * The HTTP method of an operation, in lowercase: one OpenAPI names, `query`
11
+ * being the HTTP QUERY method, or any other method token, as an OpenAPI 3.2
12
+ * `additionalOperations` entry such as `copy` gives.
13
+ */
14
+ export type Method = "get" | "put" | "post" | "delete" | "options" | "head" | "patch" | "query" | (string & {});
15
+
16
+ /** One operation as the generated `Api` interface describes it. */
17
+ export interface Operation
18
+ {
19
+ /** The HTTP method in lowercase, such as `get`; the request sends it uppercased. */
20
+ readonly method: Method;
21
+ /** The path template, such as `/notes/{id}`, whose placeholders the client fills from `params` with URI-encoded values. */
22
+ readonly path: string;
23
+ /** Path parameters, by the name inside the braces. */
24
+ readonly params?: Record<string, string | number | boolean>;
25
+ /** Query parameters; arrays repeat the key, objects are JSON. */
26
+ readonly query?: Record<string, unknown>;
27
+ /** The request body; JSON unless it is a `FormData`, `URLSearchParams`, `Blob`, `ArrayBuffer`, `ReadableStream`, or string, which are sent as they are. */
28
+ readonly body?: unknown;
29
+ /** What a successful call resolves to. */
30
+ readonly output: unknown;
31
+ }
32
+
33
+ /** What a generated `Api` interface satisfies: every member is an operation. */
34
+ export type Contract<A> = { readonly [K in keyof A]: Operation };
35
+
36
+ /** The runtime half of a contract: the method and path of every operation. */
37
+ export type RouteTable<A extends Contract<A>> = { readonly [K in keyof A]: { readonly method: A[K]["method"]; readonly path: A[K]["path"] } };
38
+
39
+ /** What a call takes: the operation's `params`, `query`, and `body`, as optional as the contract makes them. */
40
+ export type Input<Op extends Operation> = Omit<Op, "method" | "path" | "output">;
41
+
42
+ /** What a call resolves to. */
43
+ export type Output<Op extends Operation> = Op["output"];
44
+
45
+ /** Per-call options. */
46
+ export interface CallOptions
47
+ {
48
+ /** Becomes the request's `signal`, so aborting it rejects the call under `fetch` or `portTransport`; `query` replaces it with the resource's own signal. */
49
+ signal?: AbortSignal;
50
+ /** Headers for this call, set over the client's `headers` so that a name in both takes this value. */
51
+ headers?: HeadersInit;
52
+ }
53
+
54
+ type InputArgs<Op extends Operation> = Record<never, never> extends Input<Op> ? [input?: Input<Op>, options?: CallOptions] : [input: Input<Op>, options?: CallOptions];
55
+
56
+ /** Sends a request. The default is `fetch`; a test or an Electron bridge can answer in process. */
57
+ export type Transport = (request: Request) => Promise<Response>;
58
+
59
+ /** Options for `createClient`. */
60
+ export interface ClientOptions
61
+ {
62
+ /** Prepended to every path. Defaults to "", the page's origin in a browser; pass an absolute URL elsewhere. */
63
+ baseUrl?: string;
64
+ /** Sends every request the client builds and resolves with the response. Defaults to the global `fetch`, looked up on each call. */
65
+ transport?: Transport;
66
+ /** Headers sent with every request, or a function returning them, for values such as a bearer token. */
67
+ headers?: HeadersInit | (() => HeadersInit);
68
+ }
69
+
70
+ /** Thrown for a response outside 2xx, carrying the problem the server sent or one built from the status line. */
71
+ export class ProblemError extends Error
72
+ {
73
+ /** The problem the server sent, with `status` filled from the response when the body omits it; a body that is not a JSON problem yields `status` and a `title` from the status line. */
74
+ readonly problem: Problem;
75
+ /** The response outside 2xx, for its status and headers; its body has already been read when the content type is JSON. */
76
+ readonly response: Response;
77
+
78
+ /** Wraps `problem` and the `response` it came from; the message is the problem's `title`, or `HTTP <status>` when it has none. */
79
+ constructor(problem: Problem, response: Response)
80
+ {
81
+ super(problem.title ?? `HTTP ${response.status}`);
82
+ this.name = "ProblemError";
83
+ this.problem = problem;
84
+ this.response = response;
85
+ }
86
+ }
87
+
88
+ /** The client for one contract. */
89
+ export interface Client<A extends Contract<A>>
90
+ {
91
+ /** Calls an operation and resolves with its output, or rejects with a `ProblemError` for a response outside 2xx. */
92
+ call<K extends keyof A>(name: K, ...args: InputArgs<A[K]>): Promise<Output<A[K]>>;
93
+ /** A resource over an operation: it calls again when the signals read by `input` change, and is idle while `input` returns undefined. */
94
+ query<K extends keyof A>(name: K, input: () => Input<A[K]> | undefined, options?: CallOptions): Resource<Output<A[K]>>;
95
+ /** The `Request` a call would send, for tests and custom transports. */
96
+ request<K extends keyof A>(name: K, ...args: InputArgs<A[K]>): Request;
97
+ }
98
+
99
+ interface LooseInput
100
+ {
101
+ params?: Record<string, string | number | boolean>;
102
+ query?: Record<string, unknown>;
103
+ body?: unknown;
104
+ }
105
+
106
+ function isRawBody(body: unknown): body is BodyInit
107
+ {
108
+ return typeof body === "string" || body instanceof FormData || body instanceof URLSearchParams || body instanceof Blob || body instanceof ArrayBuffer || ArrayBuffer.isView(body) || body instanceof ReadableStream;
109
+ }
110
+
111
+ function searchOf(query: Record<string, unknown>): string
112
+ {
113
+ const search = new URLSearchParams();
114
+ for (const [key, value] of Object.entries(query))
115
+ {
116
+ if (value === undefined || value === null)
117
+ {
118
+ continue;
119
+ }
120
+ for (const item of Array.isArray(value) ? value : [value])
121
+ {
122
+ search.append(key, typeof item === "object" && item !== null ? JSON.stringify(item) : String(item));
123
+ }
124
+ }
125
+ return search.size > 0 ? `?${search.toString()}` : "";
126
+ }
127
+
128
+ function buildRequest(route: { method: Method; path: string }, input: LooseInput, options: CallOptions, client: ClientOptions): Request
129
+ {
130
+ const path = route.path.replace(/\{([^{}]+)\}/g, (_match, name: string) =>
131
+ {
132
+ const value = input.params?.[name];
133
+ if (value === undefined)
134
+ {
135
+ throw new TypeError(`zerolith/client: ${route.method.toUpperCase()} ${route.path} needs the path parameter "${name}"`);
136
+ }
137
+ const segment = encodeURIComponent(String(value));
138
+ if (segment === "." || segment === "..")
139
+ {
140
+ throw new TypeError(`zerolith/client: ${route.method.toUpperCase()} ${route.path} got "${segment}" for the path parameter "${name}", which would move the request to another path`);
141
+ }
142
+ return segment;
143
+ });
144
+ const base = (client.baseUrl ?? "").replace(/\/$/, "");
145
+ const headers = new Headers(typeof client.headers === "function" ? client.headers() : client.headers);
146
+ new Headers(options.headers).forEach((value, key) => headers.set(key, value));
147
+ if (!headers.has("accept"))
148
+ {
149
+ headers.set("accept", `application/json, ${PROBLEM_MEDIA_TYPE}`);
150
+ }
151
+ const init: RequestInit = { method: route.method.toUpperCase(), headers };
152
+ if (options.signal !== undefined)
153
+ {
154
+ init.signal = options.signal;
155
+ }
156
+ if (input.body !== undefined)
157
+ {
158
+ if (isRawBody(input.body))
159
+ {
160
+ init.body = input.body;
161
+ if (input.body instanceof ReadableStream)
162
+ {
163
+ (init as RequestInit & { duplex: "half" }).duplex = "half";
164
+ }
165
+ }
166
+ else
167
+ {
168
+ init.body = JSON.stringify(input.body);
169
+ if (!headers.has("content-type"))
170
+ {
171
+ headers.set("content-type", "application/json");
172
+ }
173
+ }
174
+ }
175
+ return new Request(`${base}${path}${searchOf(input.query ?? {})}`, init);
176
+ }
177
+
178
+ /** A successful response's body: JSON parsed, text for `text/*` or no type, a `Blob` for any other type, such as an image, and `undefined` when it is empty. */
179
+ async function readBody(response: Response): Promise<unknown>
180
+ {
181
+ if (response.status === 204 || response.status === 205)
182
+ {
183
+ return undefined;
184
+ }
185
+ const type = (response.headers.get("content-type") ?? "").toLowerCase();
186
+ if (type.includes("json"))
187
+ {
188
+ return response.json();
189
+ }
190
+ if (type === "" || type.startsWith("text/"))
191
+ {
192
+ const text = await response.text();
193
+ return text === "" ? undefined : text;
194
+ }
195
+ return response.blob();
196
+ }
197
+
198
+ /** Calls `run` with a signal that aborts when either `own` or `caller` does, and stops listening to the caller once the call settles. */
199
+ async function withSignals<T>(own: AbortSignal, caller: AbortSignal | undefined, run: (signal: AbortSignal) => Promise<T>): Promise<T>
200
+ {
201
+ if (caller === undefined)
202
+ {
203
+ return run(own);
204
+ }
205
+ const combined = new AbortController();
206
+ const abort = (): void => combined.abort();
207
+ if (own.aborted || caller.aborted)
208
+ {
209
+ combined.abort();
210
+ }
211
+ own.addEventListener("abort", abort);
212
+ caller.addEventListener("abort", abort);
213
+ try
214
+ {
215
+ return await run(combined.signal);
216
+ }
217
+ finally
218
+ {
219
+ own.removeEventListener("abort", abort);
220
+ caller.removeEventListener("abort", abort);
221
+ }
222
+ }
223
+
224
+ async function settle(response: Response): Promise<unknown>
225
+ {
226
+ if (response.ok)
227
+ {
228
+ return readBody(response);
229
+ }
230
+ let problem: Problem | undefined;
231
+ if ((response.headers.get("content-type") ?? "").includes("json"))
232
+ {
233
+ const body: unknown = await response.json().catch(() => undefined);
234
+ if (isProblem(body))
235
+ {
236
+ problem = { status: response.status, ...body };
237
+ }
238
+ }
239
+ throw new ProblemError(problem ?? { status: response.status, title: response.statusText === "" ? `HTTP ${response.status}` : response.statusText }, response);
240
+ }
241
+
242
+ /**
243
+ * Creates a client for a generated route table. `routes` and the `Api`
244
+ * type parameter come from the same generated module, so an operation
245
+ * name, its parameters, its body, and its output are checked together.
246
+ */
247
+ export function createClient<A extends Contract<A>>(routes: RouteTable<A>, options: ClientOptions = {}): Client<A>
248
+ {
249
+ const transport: Transport = options.transport ?? ((request) => fetch(request));
250
+ const request = (name: keyof A, input: LooseInput | undefined, callOptions: CallOptions | undefined): Request =>
251
+ {
252
+ const route = routes[name] as { method: Method; path: string } | undefined;
253
+ if (route === undefined)
254
+ {
255
+ throw new TypeError(`zerolith/client: unknown operation "${String(name)}"`);
256
+ }
257
+ return buildRequest(route, input ?? {}, callOptions ?? {}, options);
258
+ };
259
+ const call = async (name: keyof A, input: LooseInput | undefined, callOptions: CallOptions | undefined): Promise<unknown> =>
260
+ {
261
+ return settle(await transport(request(name, input, callOptions)));
262
+ };
263
+ return {
264
+ call<K extends keyof A>(name: K, ...args: InputArgs<A[K]>): Promise<Output<A[K]>>
265
+ {
266
+ return call(name, args[0] as LooseInput | undefined, args[1]) as Promise<Output<A[K]>>;
267
+ },
268
+ query<K extends keyof A>(name: K, input: () => Input<A[K]> | undefined, callOptions: CallOptions = {}): Resource<Output<A[K]>>
269
+ {
270
+ return resource<Input<A[K]>, Output<A[K]>>({
271
+ params: input,
272
+ loader: ({ params, abort }) => withSignals(abort, callOptions.signal, (signal) => call(name, params as LooseInput, { ...callOptions, signal }) as Promise<Output<A[K]>>)
273
+ });
274
+ },
275
+ request<K extends keyof A>(name: K, ...args: InputArgs<A[K]>): Request
276
+ {
277
+ return request(name, args[0] as LooseInput | undefined, args[1]);
278
+ }
279
+ };
280
+ }
package/src/port.ts ADDED
@@ -0,0 +1,163 @@
1
+ /**
2
+ * A transport over a message port, for the places `fetch` cannot reach the
3
+ * server: an Electron renderer behind `contextBridge`, a worker, a test.
4
+ * `portTransport` runs where the client runs; `servePort` runs where the
5
+ * handler runs. Bodies travel as text, which covers JSON and encoded forms.
6
+ */
7
+ import type { Transport } from "./index.js";
8
+
9
+ /** The port shapes supported: a DOM `MessagePort`, Node's, or Electron's `MessagePortMain`. */
10
+ export interface PortLike
11
+ {
12
+ /** Sends a message to the other end; `portTransport` posts each `PortRequest` through it and `servePort` each `PortResponse`. */
13
+ postMessage(message: unknown): void;
14
+ /** Starts delivering messages; called, when present, right after the listener is attached, since a DOM `MessagePort` and Electron's `MessagePortMain` queue messages until it runs. */
15
+ start?(): void;
16
+ /** Receives messages the `EventTarget` way, as a DOM `MessagePort` does; used in preference to `on` when both exist. */
17
+ addEventListener?(type: "message", listener: (event: { data: unknown }) => void): void;
18
+ /** Receives messages the `EventEmitter` way, as Electron's `MessagePortMain` does; used when `addEventListener` is absent, and a port with neither throws a `TypeError`. */
19
+ on?(type: "message", listener: (event: { data: unknown }) => void): void;
20
+ }
21
+
22
+ /** What crosses the port for a request. */
23
+ export interface PortRequest
24
+ {
25
+ /** Tags the message as a request, so `servePort` ignores other traffic on the port. */
26
+ kind: "zerolith:request";
27
+ /** Pairs the request with its response; each `portTransport` numbers its requests from 1. */
28
+ id: number;
29
+ /** The request's absolute URL, from which `servePort` builds the `Request` its handler receives. */
30
+ url: string;
31
+ /** The request method as `Request.method` reports it, such as `POST`. */
32
+ method: string;
33
+ /** The request headers as `[name, value]` pairs with lowercase names, as iterating a `Headers` yields them. */
34
+ headers: [string, string][];
35
+ /** The body as UTF-8 text, or `null` for a `GET` or `HEAD` request or an empty body. */
36
+ body: string | null;
37
+ }
38
+
39
+ /** What crosses the port for a response. */
40
+ export interface PortResponse
41
+ {
42
+ /** Tags the message as a response, so `portTransport` ignores other traffic on the port. */
43
+ kind: "zerolith:response";
44
+ /** The `id` of the request this answers; an answer with no pending request, such as one whose call was aborted, is dropped. */
45
+ id: number;
46
+ /** The status code of the handler's response, or 500 when the handler threw. */
47
+ status: number;
48
+ /** The status text of the handler's response, empty when none was set. */
49
+ statusText: string;
50
+ /** The response headers as `[name, value]` pairs with lowercase names, as iterating a `Headers` yields them. */
51
+ headers: [string, string][];
52
+ /** The body as UTF-8 text, or `null` when it is empty, as for a 204. */
53
+ body: string | null;
54
+ }
55
+
56
+ function listen(port: PortLike, handler: (data: unknown) => void): void
57
+ {
58
+ if (typeof port.addEventListener === "function")
59
+ {
60
+ port.addEventListener("message", (event) => handler(event.data));
61
+ }
62
+ else if (typeof port.on === "function")
63
+ {
64
+ port.on("message", (event) => handler(event.data));
65
+ }
66
+ else
67
+ {
68
+ throw new TypeError("zerolith/client: the port has neither addEventListener nor on");
69
+ }
70
+ port.start?.();
71
+ }
72
+
73
+ /**
74
+ * A `Transport` that sends each request over the port as a message and
75
+ * resolves with the response that comes back under the same id. Aborting
76
+ * the request's signal rejects the call; the message already sent is
77
+ * answered and ignored.
78
+ */
79
+ export function portTransport(port: PortLike): Transport
80
+ {
81
+ const pending = new Map<number, { resolve: (response: Response) => void; reject: (reason: unknown) => void }>();
82
+ let next = 1;
83
+ listen(port, (data) =>
84
+ {
85
+ const message = data as Partial<PortResponse> | null;
86
+ if (message === null || typeof message !== "object" || message.kind !== "zerolith:response" || typeof message.id !== "number")
87
+ {
88
+ return;
89
+ }
90
+ const waiting = pending.get(message.id);
91
+ if (waiting === undefined)
92
+ {
93
+ return;
94
+ }
95
+ pending.delete(message.id);
96
+ waiting.resolve(new Response(message.body ?? null, { status: message.status ?? 200, statusText: message.statusText ?? "", headers: message.headers ?? [] }));
97
+ });
98
+ return async (request) =>
99
+ {
100
+ const id = next++;
101
+ const body = request.method === "GET" || request.method === "HEAD" ? null : await request.text();
102
+ const message: PortRequest = { kind: "zerolith:request", id, url: request.url, method: request.method, headers: [...request.headers], body: body === "" ? null : body };
103
+ return new Promise<Response>((resolve, reject) =>
104
+ {
105
+ if (request.signal.aborted)
106
+ {
107
+ reject(request.signal.reason ?? new DOMException("The request was aborted", "AbortError"));
108
+ return;
109
+ }
110
+ pending.set(id, { resolve, reject });
111
+ request.signal.addEventListener("abort", () =>
112
+ {
113
+ if (pending.delete(id))
114
+ {
115
+ reject(request.signal.reason ?? new DOMException("The request was aborted", "AbortError"));
116
+ }
117
+ });
118
+ port.postMessage(message);
119
+ });
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Answers the requests `portTransport` sends by building a `Request`,
125
+ * calling `handler`, and posting the response back. A handler that throws
126
+ * answers with a 500 problem so the client sees a `ProblemError`.
127
+ */
128
+ export function servePort(port: PortLike, handler: (request: Request) => Response | Promise<Response>): void
129
+ {
130
+ listen(port, (data) =>
131
+ {
132
+ const message = data as Partial<PortRequest> | null;
133
+ if (message === null || typeof message !== "object" || message.kind !== "zerolith:request" || typeof message.id !== "number" || typeof message.url !== "string")
134
+ {
135
+ return;
136
+ }
137
+ const id = message.id;
138
+ const answer = async (): Promise<void> =>
139
+ {
140
+ let response: Response;
141
+ try
142
+ {
143
+ response = await handler(new Request(message.url ?? "", { method: message.method ?? "GET", headers: message.headers ?? [], body: message.body ?? null }));
144
+ }
145
+ catch (error)
146
+ {
147
+ if (typeof reportError === "function")
148
+ {
149
+ reportError(error);
150
+ }
151
+ else
152
+ {
153
+ console.error("zerolith/client: a servePort handler threw", error);
154
+ }
155
+ response = new Response(JSON.stringify({ title: "The handler failed", status: 500 }), { status: 500, headers: { "content-type": "application/problem+json" } });
156
+ }
157
+ const text = await response.text();
158
+ const reply: PortResponse = { kind: "zerolith:response", id, status: response.status, statusText: response.statusText, headers: [...response.headers], body: text === "" ? null : text };
159
+ port.postMessage(reply);
160
+ };
161
+ void answer();
162
+ });
163
+ }