@zerolith/client 0.1.0-alpha.5 → 0.1.0-alpha.6
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/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +12 -6
- package/dist/cli.js.map +1 -1
- package/dist/generate.d.ts +61 -1
- package/dist/generate.d.ts.map +1 -1
- package/dist/generate.js +82 -7
- package/dist/generate.js.map +1 -1
- package/dist/index.d.ts +15 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +39 -5
- package/dist/index.js.map +1 -1
- package/dist/port.d.ts +16 -0
- package/dist/port.d.ts.map +1 -1
- package/dist/port.js +7 -2
- package/dist/port.js.map +1 -1
- package/package.json +3 -3
- package/src/cli.ts +14 -6
- package/src/generate.ts +153 -7
- package/src/index.ts +60 -8
- package/src/port.ts +25 -2
package/src/index.ts
CHANGED
|
@@ -6,19 +6,25 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { PROBLEM_MEDIA_TYPE, type Problem, type Resource, isProblem, resource } from "zerolith";
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
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 & {});
|
|
11
15
|
|
|
12
16
|
/** One operation as the generated `Api` interface describes it. */
|
|
13
17
|
export interface Operation
|
|
14
18
|
{
|
|
19
|
+
/** The HTTP method in lowercase, such as `get`; the request sends it uppercased. */
|
|
15
20
|
readonly method: Method;
|
|
21
|
+
/** The path template, such as `/notes/{id}`, whose placeholders the client fills from `params` with URI-encoded values. */
|
|
16
22
|
readonly path: string;
|
|
17
23
|
/** Path parameters, by the name inside the braces. */
|
|
18
24
|
readonly params?: Record<string, string | number | boolean>;
|
|
19
25
|
/** Query parameters; arrays repeat the key, objects are JSON. */
|
|
20
26
|
readonly query?: Record<string, unknown>;
|
|
21
|
-
/** The request body; JSON unless it is a `FormData`, `URLSearchParams`, `Blob`, `ArrayBuffer`, or string. */
|
|
27
|
+
/** The request body; JSON unless it is a `FormData`, `URLSearchParams`, `Blob`, `ArrayBuffer`, `ReadableStream`, or string, which are sent as they are. */
|
|
22
28
|
readonly body?: unknown;
|
|
23
29
|
/** What a successful call resolves to. */
|
|
24
30
|
readonly output: unknown;
|
|
@@ -39,7 +45,9 @@ export type Output<Op extends Operation> = Op["output"];
|
|
|
39
45
|
/** Per-call options. */
|
|
40
46
|
export interface CallOptions
|
|
41
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. */
|
|
42
49
|
signal?: AbortSignal;
|
|
50
|
+
/** Headers for this call, set over the client's `headers` so that a name in both takes this value. */
|
|
43
51
|
headers?: HeadersInit;
|
|
44
52
|
}
|
|
45
53
|
|
|
@@ -53,6 +61,7 @@ export interface ClientOptions
|
|
|
53
61
|
{
|
|
54
62
|
/** Prepended to every path. Defaults to "", the page's origin in a browser; pass an absolute URL elsewhere. */
|
|
55
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. */
|
|
56
65
|
transport?: Transport;
|
|
57
66
|
/** Headers sent with every request, or a function returning them, for values such as a bearer token. */
|
|
58
67
|
headers?: HeadersInit | (() => HeadersInit);
|
|
@@ -61,9 +70,12 @@ export interface ClientOptions
|
|
|
61
70
|
/** Thrown for a response outside 2xx, carrying the problem the server sent or one built from the status line. */
|
|
62
71
|
export class ProblemError extends Error
|
|
63
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. */
|
|
64
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. */
|
|
65
76
|
readonly response: Response;
|
|
66
77
|
|
|
78
|
+
/** Wraps `problem` and the `response` it came from; the message is the problem's `title`, or `HTTP <status>` when it has none. */
|
|
67
79
|
constructor(problem: Problem, response: Response)
|
|
68
80
|
{
|
|
69
81
|
super(problem.title ?? `HTTP ${response.status}`);
|
|
@@ -122,7 +134,12 @@ function buildRequest(route: { method: Method; path: string }, input: LooseInput
|
|
|
122
134
|
{
|
|
123
135
|
throw new TypeError(`zerolith/client: ${route.method.toUpperCase()} ${route.path} needs the path parameter "${name}"`);
|
|
124
136
|
}
|
|
125
|
-
|
|
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;
|
|
126
143
|
});
|
|
127
144
|
const base = (client.baseUrl ?? "").replace(/\/$/, "");
|
|
128
145
|
const headers = new Headers(typeof client.headers === "function" ? client.headers() : client.headers);
|
|
@@ -141,6 +158,10 @@ function buildRequest(route: { method: Method; path: string }, input: LooseInput
|
|
|
141
158
|
if (isRawBody(input.body))
|
|
142
159
|
{
|
|
143
160
|
init.body = input.body;
|
|
161
|
+
if (input.body instanceof ReadableStream)
|
|
162
|
+
{
|
|
163
|
+
(init as RequestInit & { duplex: "half" }).duplex = "half";
|
|
164
|
+
}
|
|
144
165
|
}
|
|
145
166
|
else
|
|
146
167
|
{
|
|
@@ -154,19 +175,50 @@ function buildRequest(route: { method: Method; path: string }, input: LooseInput
|
|
|
154
175
|
return new Request(`${base}${path}${searchOf(input.query ?? {})}`, init);
|
|
155
176
|
}
|
|
156
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. */
|
|
157
179
|
async function readBody(response: Response): Promise<unknown>
|
|
158
180
|
{
|
|
159
181
|
if (response.status === 204 || response.status === 205)
|
|
160
182
|
{
|
|
161
183
|
return undefined;
|
|
162
184
|
}
|
|
163
|
-
const type = response.headers.get("content-type") ?? "";
|
|
185
|
+
const type = (response.headers.get("content-type") ?? "").toLowerCase();
|
|
164
186
|
if (type.includes("json"))
|
|
165
187
|
{
|
|
166
188
|
return response.json();
|
|
167
189
|
}
|
|
168
|
-
|
|
169
|
-
|
|
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
|
+
}
|
|
170
222
|
}
|
|
171
223
|
|
|
172
224
|
async function settle(response: Response): Promise<unknown>
|
|
@@ -217,7 +269,7 @@ export function createClient<A extends Contract<A>>(routes: RouteTable<A>, optio
|
|
|
217
269
|
{
|
|
218
270
|
return resource<Input<A[K]>, Output<A[K]>>({
|
|
219
271
|
params: input,
|
|
220
|
-
loader: ({ params, abort }) => call(name, params as LooseInput, { ...callOptions, signal
|
|
272
|
+
loader: ({ params, abort }) => withSignals(abort, callOptions.signal, (signal) => call(name, params as LooseInput, { ...callOptions, signal }) as Promise<Output<A[K]>>)
|
|
221
273
|
});
|
|
222
274
|
},
|
|
223
275
|
request<K extends keyof A>(name: K, ...args: InputArgs<A[K]>): Request
|
package/src/port.ts
CHANGED
|
@@ -9,31 +9,47 @@ import type { Transport } from "./index.js";
|
|
|
9
9
|
/** The port shapes supported: a DOM `MessagePort`, Node's, or Electron's `MessagePortMain`. */
|
|
10
10
|
export interface PortLike
|
|
11
11
|
{
|
|
12
|
+
/** Sends a message to the other end; `portTransport` posts each `PortRequest` through it and `servePort` each `PortResponse`. */
|
|
12
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. */
|
|
13
15
|
start?(): void;
|
|
16
|
+
/** Receives messages the `EventTarget` way, as a DOM `MessagePort` does; used in preference to `on` when both exist. */
|
|
14
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`. */
|
|
15
19
|
on?(type: "message", listener: (event: { data: unknown }) => void): void;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
/** What crosses the port for a request. */
|
|
19
23
|
export interface PortRequest
|
|
20
24
|
{
|
|
25
|
+
/** Tags the message as a request, so `servePort` ignores other traffic on the port. */
|
|
21
26
|
kind: "zerolith:request";
|
|
27
|
+
/** Pairs the request with its response; each `portTransport` numbers its requests from 1. */
|
|
22
28
|
id: number;
|
|
29
|
+
/** The request's absolute URL, from which `servePort` builds the `Request` its handler receives. */
|
|
23
30
|
url: string;
|
|
31
|
+
/** The request method as `Request.method` reports it, such as `POST`. */
|
|
24
32
|
method: string;
|
|
33
|
+
/** The request headers as `[name, value]` pairs with lowercase names, as iterating a `Headers` yields them. */
|
|
25
34
|
headers: [string, string][];
|
|
35
|
+
/** The body as UTF-8 text, or `null` for a `GET` or `HEAD` request or an empty body. */
|
|
26
36
|
body: string | null;
|
|
27
37
|
}
|
|
28
38
|
|
|
29
39
|
/** What crosses the port for a response. */
|
|
30
40
|
export interface PortResponse
|
|
31
41
|
{
|
|
42
|
+
/** Tags the message as a response, so `portTransport` ignores other traffic on the port. */
|
|
32
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. */
|
|
33
45
|
id: number;
|
|
46
|
+
/** The status code of the handler's response, or 500 when the handler threw. */
|
|
34
47
|
status: number;
|
|
48
|
+
/** The status text of the handler's response, empty when none was set. */
|
|
35
49
|
statusText: string;
|
|
50
|
+
/** The response headers as `[name, value]` pairs with lowercase names, as iterating a `Headers` yields them. */
|
|
36
51
|
headers: [string, string][];
|
|
52
|
+
/** The body as UTF-8 text, or `null` when it is empty, as for a 204. */
|
|
37
53
|
body: string | null;
|
|
38
54
|
}
|
|
39
55
|
|
|
@@ -128,8 +144,15 @@ export function servePort(port: PortLike, handler: (request: Request) => Respons
|
|
|
128
144
|
}
|
|
129
145
|
catch (error)
|
|
130
146
|
{
|
|
131
|
-
|
|
132
|
-
|
|
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" } });
|
|
133
156
|
}
|
|
134
157
|
const text = await response.text();
|
|
135
158
|
const reply: PortResponse = { kind: "zerolith:response", id, status: response.status, statusText: response.statusText, headers: [...response.headers], body: text === "" ? null : text };
|