@zerolith/client 0.0.0-stage → 0.1.0-alpha.2
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/LICENSE +201 -0
- package/README.md +25 -2
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +61 -0
- package/dist/cli.js.map +1 -0
- package/dist/generate.d.ts +112 -0
- package/dist/generate.d.ts.map +1 -0
- package/dist/generate.js +345 -0
- package/dist/generate.js.map +1 -0
- package/dist/index.d.ts +76 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +121 -0
- package/dist/index.js.map +1 -0
- package/dist/port.d.ts +50 -0
- package/dist/port.d.ts.map +1 -0
- package/dist/port.js +81 -0
- package/dist/port.js.map +1 -0
- package/package.json +59 -5
- package/src/cli.ts +85 -0
- package/src/generate.ts +572 -0
- package/src/index.ts +228 -0
- package/src/port.ts +140 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
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
|
+
/** The HTTP methods an operation can use; `query` is the HTTP QUERY method. */
|
|
10
|
+
export type Method = "get" | "put" | "post" | "delete" | "options" | "head" | "patch" | "trace" | "query";
|
|
11
|
+
|
|
12
|
+
/** One operation as the generated `Api` interface describes it. */
|
|
13
|
+
export interface Operation
|
|
14
|
+
{
|
|
15
|
+
readonly method: Method;
|
|
16
|
+
readonly path: string;
|
|
17
|
+
/** Path parameters, by the name inside the braces. */
|
|
18
|
+
readonly params?: Record<string, string | number | boolean>;
|
|
19
|
+
/** Query parameters; arrays repeat the key, objects are JSON. */
|
|
20
|
+
readonly query?: Record<string, unknown>;
|
|
21
|
+
/** The request body; JSON unless it is a `FormData`, `URLSearchParams`, `Blob`, `ArrayBuffer`, or string. */
|
|
22
|
+
readonly body?: unknown;
|
|
23
|
+
/** What a successful call resolves to. */
|
|
24
|
+
readonly output: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What a generated `Api` interface satisfies: every member is an operation. */
|
|
28
|
+
export type Contract<A> = { readonly [K in keyof A]: Operation };
|
|
29
|
+
|
|
30
|
+
/** The runtime half of a contract: the method and path of every operation. */
|
|
31
|
+
export type RouteTable<A extends Contract<A>> = { readonly [K in keyof A]: { readonly method: A[K]["method"]; readonly path: A[K]["path"] } };
|
|
32
|
+
|
|
33
|
+
/** What a call takes: the operation's `params`, `query`, and `body`, as optional as the contract makes them. */
|
|
34
|
+
export type Input<Op extends Operation> = Omit<Op, "method" | "path" | "output">;
|
|
35
|
+
|
|
36
|
+
/** What a call resolves to. */
|
|
37
|
+
export type Output<Op extends Operation> = Op["output"];
|
|
38
|
+
|
|
39
|
+
/** Per-call options. */
|
|
40
|
+
export interface CallOptions
|
|
41
|
+
{
|
|
42
|
+
signal?: AbortSignal;
|
|
43
|
+
headers?: HeadersInit;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type InputArgs<Op extends Operation> = Record<never, never> extends Input<Op> ? [input?: Input<Op>, options?: CallOptions] : [input: Input<Op>, options?: CallOptions];
|
|
47
|
+
|
|
48
|
+
/** Sends a request. The default is `fetch`; a test or an Electron bridge can answer in process. */
|
|
49
|
+
export type Transport = (request: Request) => Promise<Response>;
|
|
50
|
+
|
|
51
|
+
/** Options for `createClient`. */
|
|
52
|
+
export interface ClientOptions
|
|
53
|
+
{
|
|
54
|
+
/** Prepended to every path. Defaults to "", the page's origin in a browser; pass an absolute URL elsewhere. */
|
|
55
|
+
baseUrl?: string;
|
|
56
|
+
transport?: Transport;
|
|
57
|
+
/** Headers sent with every request, or a function returning them, for values such as a bearer token. */
|
|
58
|
+
headers?: HeadersInit | (() => HeadersInit);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Thrown for a response outside 2xx, carrying the problem the server sent or one built from the status line. */
|
|
62
|
+
export class ProblemError extends Error
|
|
63
|
+
{
|
|
64
|
+
readonly problem: Problem;
|
|
65
|
+
readonly response: Response;
|
|
66
|
+
|
|
67
|
+
constructor(problem: Problem, response: Response)
|
|
68
|
+
{
|
|
69
|
+
super(problem.title ?? `HTTP ${response.status}`);
|
|
70
|
+
this.name = "ProblemError";
|
|
71
|
+
this.problem = problem;
|
|
72
|
+
this.response = response;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The client for one contract. */
|
|
77
|
+
export interface Client<A extends Contract<A>>
|
|
78
|
+
{
|
|
79
|
+
/** Calls an operation and resolves with its output, or rejects with a `ProblemError` for a response outside 2xx. */
|
|
80
|
+
call<K extends keyof A>(name: K, ...args: InputArgs<A[K]>): Promise<Output<A[K]>>;
|
|
81
|
+
/** A resource over an operation: it calls again when the signals read by `input` change, and is idle while `input` returns undefined. */
|
|
82
|
+
query<K extends keyof A>(name: K, input: () => Input<A[K]> | undefined, options?: CallOptions): Resource<Output<A[K]>>;
|
|
83
|
+
/** The `Request` a call would send, for tests and custom transports. */
|
|
84
|
+
request<K extends keyof A>(name: K, ...args: InputArgs<A[K]>): Request;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface LooseInput
|
|
88
|
+
{
|
|
89
|
+
params?: Record<string, string | number | boolean>;
|
|
90
|
+
query?: Record<string, unknown>;
|
|
91
|
+
body?: unknown;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isRawBody(body: unknown): body is BodyInit
|
|
95
|
+
{
|
|
96
|
+
return typeof body === "string" || body instanceof FormData || body instanceof URLSearchParams || body instanceof Blob || body instanceof ArrayBuffer || ArrayBuffer.isView(body) || body instanceof ReadableStream;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function searchOf(query: Record<string, unknown>): string
|
|
100
|
+
{
|
|
101
|
+
const search = new URLSearchParams();
|
|
102
|
+
for (const [key, value] of Object.entries(query))
|
|
103
|
+
{
|
|
104
|
+
if (value === undefined || value === null)
|
|
105
|
+
{
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
for (const item of Array.isArray(value) ? value : [value])
|
|
109
|
+
{
|
|
110
|
+
search.append(key, typeof item === "object" && item !== null ? JSON.stringify(item) : String(item));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return search.size > 0 ? `?${search.toString()}` : "";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function buildRequest(route: { method: Method; path: string }, input: LooseInput, options: CallOptions, client: ClientOptions): Request
|
|
117
|
+
{
|
|
118
|
+
const path = route.path.replace(/\{([^}]+)\}/g, (_match, name: string) =>
|
|
119
|
+
{
|
|
120
|
+
const value = input.params?.[name];
|
|
121
|
+
if (value === undefined)
|
|
122
|
+
{
|
|
123
|
+
throw new TypeError(`zerolith/client: ${route.method.toUpperCase()} ${route.path} needs the path parameter "${name}"`);
|
|
124
|
+
}
|
|
125
|
+
return encodeURIComponent(String(value));
|
|
126
|
+
});
|
|
127
|
+
const base = (client.baseUrl ?? "").replace(/\/$/, "");
|
|
128
|
+
const headers = new Headers(typeof client.headers === "function" ? client.headers() : client.headers);
|
|
129
|
+
new Headers(options.headers).forEach((value, key) => headers.set(key, value));
|
|
130
|
+
if (!headers.has("accept"))
|
|
131
|
+
{
|
|
132
|
+
headers.set("accept", `application/json, ${PROBLEM_MEDIA_TYPE}`);
|
|
133
|
+
}
|
|
134
|
+
const init: RequestInit = { method: route.method.toUpperCase(), headers };
|
|
135
|
+
if (options.signal !== undefined)
|
|
136
|
+
{
|
|
137
|
+
init.signal = options.signal;
|
|
138
|
+
}
|
|
139
|
+
if (input.body !== undefined)
|
|
140
|
+
{
|
|
141
|
+
if (isRawBody(input.body))
|
|
142
|
+
{
|
|
143
|
+
init.body = input.body;
|
|
144
|
+
}
|
|
145
|
+
else
|
|
146
|
+
{
|
|
147
|
+
init.body = JSON.stringify(input.body);
|
|
148
|
+
if (!headers.has("content-type"))
|
|
149
|
+
{
|
|
150
|
+
headers.set("content-type", "application/json");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return new Request(`${base}${path}${searchOf(input.query ?? {})}`, init);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function readBody(response: Response): Promise<unknown>
|
|
158
|
+
{
|
|
159
|
+
if (response.status === 204 || response.status === 205)
|
|
160
|
+
{
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
const type = response.headers.get("content-type") ?? "";
|
|
164
|
+
if (type.includes("json"))
|
|
165
|
+
{
|
|
166
|
+
return response.json();
|
|
167
|
+
}
|
|
168
|
+
const text = await response.text();
|
|
169
|
+
return text === "" ? undefined : text;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function settle(response: Response): Promise<unknown>
|
|
173
|
+
{
|
|
174
|
+
if (response.ok)
|
|
175
|
+
{
|
|
176
|
+
return readBody(response);
|
|
177
|
+
}
|
|
178
|
+
let problem: Problem | undefined;
|
|
179
|
+
if ((response.headers.get("content-type") ?? "").includes("json"))
|
|
180
|
+
{
|
|
181
|
+
const body: unknown = await response.json().catch(() => undefined);
|
|
182
|
+
if (isProblem(body))
|
|
183
|
+
{
|
|
184
|
+
problem = { status: response.status, ...body };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
throw new ProblemError(problem ?? { status: response.status, title: response.statusText === "" ? `HTTP ${response.status}` : response.statusText }, response);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Creates a client for a generated route table. `routes` and the `Api`
|
|
192
|
+
* type parameter come from the same generated module, so an operation
|
|
193
|
+
* name, its parameters, its body, and its output are checked together.
|
|
194
|
+
*/
|
|
195
|
+
export function createClient<A extends Contract<A>>(routes: RouteTable<A>, options: ClientOptions = {}): Client<A>
|
|
196
|
+
{
|
|
197
|
+
const transport: Transport = options.transport ?? ((request) => fetch(request));
|
|
198
|
+
const request = (name: keyof A, input: LooseInput | undefined, callOptions: CallOptions | undefined): Request =>
|
|
199
|
+
{
|
|
200
|
+
const route = routes[name] as { method: Method; path: string } | undefined;
|
|
201
|
+
if (route === undefined)
|
|
202
|
+
{
|
|
203
|
+
throw new TypeError(`zerolith/client: unknown operation "${String(name)}"`);
|
|
204
|
+
}
|
|
205
|
+
return buildRequest(route, input ?? {}, callOptions ?? {}, options);
|
|
206
|
+
};
|
|
207
|
+
const call = async (name: keyof A, input: LooseInput | undefined, callOptions: CallOptions | undefined): Promise<unknown> =>
|
|
208
|
+
{
|
|
209
|
+
return settle(await transport(request(name, input, callOptions)));
|
|
210
|
+
};
|
|
211
|
+
return {
|
|
212
|
+
call<K extends keyof A>(name: K, ...args: InputArgs<A[K]>): Promise<Output<A[K]>>
|
|
213
|
+
{
|
|
214
|
+
return call(name, args[0] as LooseInput | undefined, args[1]) as Promise<Output<A[K]>>;
|
|
215
|
+
},
|
|
216
|
+
query<K extends keyof A>(name: K, input: () => Input<A[K]> | undefined, callOptions: CallOptions = {}): Resource<Output<A[K]>>
|
|
217
|
+
{
|
|
218
|
+
return resource<Input<A[K]>, Output<A[K]>>({
|
|
219
|
+
params: input,
|
|
220
|
+
loader: ({ params, abort }) => call(name, params as LooseInput, { ...callOptions, signal: abort }) as Promise<Output<A[K]>>
|
|
221
|
+
});
|
|
222
|
+
},
|
|
223
|
+
request<K extends keyof A>(name: K, ...args: InputArgs<A[K]>): Request
|
|
224
|
+
{
|
|
225
|
+
return request(name, args[0] as LooseInput | undefined, args[1]);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
}
|
package/src/port.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
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
|
+
postMessage(message: unknown): void;
|
|
13
|
+
start?(): void;
|
|
14
|
+
addEventListener?(type: "message", listener: (event: { data: unknown }) => void): void;
|
|
15
|
+
on?(type: "message", listener: (event: { data: unknown }) => void): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** What crosses the port for a request. */
|
|
19
|
+
export interface PortRequest
|
|
20
|
+
{
|
|
21
|
+
kind: "zerolith:request";
|
|
22
|
+
id: number;
|
|
23
|
+
url: string;
|
|
24
|
+
method: string;
|
|
25
|
+
headers: [string, string][];
|
|
26
|
+
body: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What crosses the port for a response. */
|
|
30
|
+
export interface PortResponse
|
|
31
|
+
{
|
|
32
|
+
kind: "zerolith:response";
|
|
33
|
+
id: number;
|
|
34
|
+
status: number;
|
|
35
|
+
statusText: string;
|
|
36
|
+
headers: [string, string][];
|
|
37
|
+
body: string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function listen(port: PortLike, handler: (data: unknown) => void): void
|
|
41
|
+
{
|
|
42
|
+
if (typeof port.addEventListener === "function")
|
|
43
|
+
{
|
|
44
|
+
port.addEventListener("message", (event) => handler(event.data));
|
|
45
|
+
}
|
|
46
|
+
else if (typeof port.on === "function")
|
|
47
|
+
{
|
|
48
|
+
port.on("message", (event) => handler(event.data));
|
|
49
|
+
}
|
|
50
|
+
else
|
|
51
|
+
{
|
|
52
|
+
throw new TypeError("zerolith/client: the port has neither addEventListener nor on");
|
|
53
|
+
}
|
|
54
|
+
port.start?.();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A `Transport` that sends each request over the port as a message and
|
|
59
|
+
* resolves with the response that comes back under the same id. Aborting
|
|
60
|
+
* the request's signal rejects the call; the message already sent is
|
|
61
|
+
* answered and ignored.
|
|
62
|
+
*/
|
|
63
|
+
export function portTransport(port: PortLike): Transport
|
|
64
|
+
{
|
|
65
|
+
const pending = new Map<number, { resolve: (response: Response) => void; reject: (reason: unknown) => void }>();
|
|
66
|
+
let next = 1;
|
|
67
|
+
listen(port, (data) =>
|
|
68
|
+
{
|
|
69
|
+
const message = data as Partial<PortResponse> | null;
|
|
70
|
+
if (message === null || typeof message !== "object" || message.kind !== "zerolith:response" || typeof message.id !== "number")
|
|
71
|
+
{
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const waiting = pending.get(message.id);
|
|
75
|
+
if (waiting === undefined)
|
|
76
|
+
{
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
pending.delete(message.id);
|
|
80
|
+
waiting.resolve(new Response(message.body ?? null, { status: message.status ?? 200, statusText: message.statusText ?? "", headers: message.headers ?? [] }));
|
|
81
|
+
});
|
|
82
|
+
return async (request) =>
|
|
83
|
+
{
|
|
84
|
+
const id = next++;
|
|
85
|
+
const body = request.method === "GET" || request.method === "HEAD" ? null : await request.text();
|
|
86
|
+
const message: PortRequest = { kind: "zerolith:request", id, url: request.url, method: request.method, headers: [...request.headers], body: body === "" ? null : body };
|
|
87
|
+
return new Promise<Response>((resolve, reject) =>
|
|
88
|
+
{
|
|
89
|
+
if (request.signal.aborted)
|
|
90
|
+
{
|
|
91
|
+
reject(request.signal.reason ?? new DOMException("The request was aborted", "AbortError"));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
pending.set(id, { resolve, reject });
|
|
95
|
+
request.signal.addEventListener("abort", () =>
|
|
96
|
+
{
|
|
97
|
+
if (pending.delete(id))
|
|
98
|
+
{
|
|
99
|
+
reject(request.signal.reason ?? new DOMException("The request was aborted", "AbortError"));
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
port.postMessage(message);
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Answers the requests `portTransport` sends by building a `Request`,
|
|
109
|
+
* calling `handler`, and posting the response back. A handler that throws
|
|
110
|
+
* answers with a 500 problem so the client sees a `ProblemError`.
|
|
111
|
+
*/
|
|
112
|
+
export function servePort(port: PortLike, handler: (request: Request) => Response | Promise<Response>): void
|
|
113
|
+
{
|
|
114
|
+
listen(port, (data) =>
|
|
115
|
+
{
|
|
116
|
+
const message = data as Partial<PortRequest> | null;
|
|
117
|
+
if (message === null || typeof message !== "object" || message.kind !== "zerolith:request" || typeof message.id !== "number" || typeof message.url !== "string")
|
|
118
|
+
{
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const id = message.id;
|
|
122
|
+
const answer = async (): Promise<void> =>
|
|
123
|
+
{
|
|
124
|
+
let response: Response;
|
|
125
|
+
try
|
|
126
|
+
{
|
|
127
|
+
response = await handler(new Request(message.url ?? "", { method: message.method ?? "GET", headers: message.headers ?? [], body: message.body ?? null }));
|
|
128
|
+
}
|
|
129
|
+
catch (error)
|
|
130
|
+
{
|
|
131
|
+
const title = error instanceof Error ? error.message : String(error);
|
|
132
|
+
response = new Response(JSON.stringify({ title, status: 500 }), { status: 500, headers: { "content-type": "application/problem+json" } });
|
|
133
|
+
}
|
|
134
|
+
const text = await response.text();
|
|
135
|
+
const reply: PortResponse = { kind: "zerolith:response", id, status: response.status, statusText: response.statusText, headers: [...response.headers], body: text === "" ? null : text };
|
|
136
|
+
port.postMessage(reply);
|
|
137
|
+
};
|
|
138
|
+
void answer();
|
|
139
|
+
});
|
|
140
|
+
}
|