endia 0.0.0-stage → 0.0.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.
Files changed (101) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +34 -2
  3. package/dist/archives/archive-file.d.ts +36 -0
  4. package/dist/archives/archive-file.js +71 -0
  5. package/dist/archives/build-context.d.ts +63 -0
  6. package/dist/archives/build-context.js +132 -0
  7. package/dist/archives/docker-ignore.d.ts +57 -0
  8. package/dist/archives/docker-ignore.js +250 -0
  9. package/dist/archives/fleet-archive.d.ts +33 -0
  10. package/dist/archives/fleet-archive.js +134 -0
  11. package/dist/compose-model.d.ts +32 -0
  12. package/dist/compose-model.js +39 -0
  13. package/dist/contract/archives.d.ts +14 -0
  14. package/dist/contract/archives.js +1 -0
  15. package/dist/contract/bases.d.ts +48 -0
  16. package/dist/contract/bases.js +1 -0
  17. package/dist/contract/clients.d.ts +29 -0
  18. package/dist/contract/clients.js +1 -0
  19. package/dist/contract/common.d.ts +30 -0
  20. package/dist/contract/common.js +1 -0
  21. package/dist/contract/compose.d.ts +27 -0
  22. package/dist/contract/compose.js +1 -0
  23. package/dist/contract/desktop.d.ts +269 -0
  24. package/dist/contract/desktop.js +1 -0
  25. package/dist/contract/execs.d.ts +61 -0
  26. package/dist/contract/execs.js +1 -0
  27. package/dist/contract/fleets.d.ts +136 -0
  28. package/dist/contract/fleets.js +1 -0
  29. package/dist/contract/images.d.ts +130 -0
  30. package/dist/contract/images.js +1 -0
  31. package/dist/contract/machines.d.ts +199 -0
  32. package/dist/contract/machines.js +1 -0
  33. package/dist/contract/operations.d.ts +28 -0
  34. package/dist/contract/operations.js +1 -0
  35. package/dist/contract/processes.d.ts +75 -0
  36. package/dist/contract/processes.js +1 -0
  37. package/dist/contract/purposes.d.ts +62 -0
  38. package/dist/contract/purposes.js +1 -0
  39. package/dist/contract/space.d.ts +114 -0
  40. package/dist/contract/space.js +1 -0
  41. package/dist/contract/stats.d.ts +58 -0
  42. package/dist/contract/stats.js +1 -0
  43. package/dist/contract/status.d.ts +151 -0
  44. package/dist/contract/status.js +1 -0
  45. package/dist/contract/volumes.d.ts +22 -0
  46. package/dist/contract/volumes.js +1 -0
  47. package/dist/eastindia-client.d.ts +118 -0
  48. package/dist/eastindia-client.js +142 -0
  49. package/dist/environment.d.ts +52 -0
  50. package/dist/environment.js +133 -0
  51. package/dist/errors.d.ts +81 -0
  52. package/dist/errors.js +108 -0
  53. package/dist/exec/exec-frame.d.ts +38 -0
  54. package/dist/exec/exec-frame.js +50 -0
  55. package/dist/exec/exec-session.d.ts +64 -0
  56. package/dist/exec/exec-session.js +117 -0
  57. package/dist/groups/archives.d.ts +78 -0
  58. package/dist/groups/archives.js +117 -0
  59. package/dist/groups/bases.d.ts +28 -0
  60. package/dist/groups/bases.js +39 -0
  61. package/dist/groups/certificates.d.ts +32 -0
  62. package/dist/groups/certificates.js +43 -0
  63. package/dist/groups/desktop.d.ts +143 -0
  64. package/dist/groups/desktop.js +182 -0
  65. package/dist/groups/execs.d.ts +110 -0
  66. package/dist/groups/execs.js +119 -0
  67. package/dist/groups/fleets.d.ts +206 -0
  68. package/dist/groups/fleets.js +255 -0
  69. package/dist/groups/images.d.ts +130 -0
  70. package/dist/groups/images.js +109 -0
  71. package/dist/groups/machines.d.ts +124 -0
  72. package/dist/groups/machines.js +127 -0
  73. package/dist/groups/operations.d.ts +127 -0
  74. package/dist/groups/operations.js +174 -0
  75. package/dist/groups/processes.d.ts +77 -0
  76. package/dist/groups/processes.js +75 -0
  77. package/dist/groups/stats.d.ts +29 -0
  78. package/dist/groups/stats.js +33 -0
  79. package/dist/groups/system.d.ts +33 -0
  80. package/dist/groups/system.js +40 -0
  81. package/dist/groups/volumes.d.ts +36 -0
  82. package/dist/groups/volumes.js +40 -0
  83. package/dist/index.d.ts +37 -0
  84. package/dist/index.js +20 -0
  85. package/dist/purposes.d.ts +17 -0
  86. package/dist/purposes.js +21 -0
  87. package/dist/routes.d.ts +71 -0
  88. package/dist/routes.js +73 -0
  89. package/dist/transport/api-connection.d.ts +185 -0
  90. package/dist/transport/api-connection.js +396 -0
  91. package/dist/transport/connection-failures.d.ts +14 -0
  92. package/dist/transport/connection-failures.js +57 -0
  93. package/dist/transport/json-lines.d.ts +10 -0
  94. package/dist/transport/json-lines.js +23 -0
  95. package/dist/transport/query-text.d.ts +49 -0
  96. package/dist/transport/query-text.js +62 -0
  97. package/dist/transport/service-address.d.ts +30 -0
  98. package/dist/transport/service-address.js +37 -0
  99. package/dist/transport/tls-files.d.ts +9 -0
  100. package/dist/transport/tls-files.js +1 -0
  101. package/package.json +44 -5
@@ -0,0 +1,396 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import http, {} from "node:http";
3
+ import https from "node:https";
4
+ import net from "node:net";
5
+ import { setTimeout as delay } from "node:timers/promises";
6
+ import WebSocket from "ws";
7
+ import { EastIndiaApiError, EastIndiaConnectionError } from "../errors.js";
8
+ import { Headers, MediaTypes } from "../routes.js";
9
+ import { classifyConnectionFailure } from "./connection-failures.js";
10
+ import { readJsonLines } from "./json-lines.js";
11
+ import { describeServiceAddress } from "./service-address.js";
12
+ /**
13
+ * The one connection the route groups share, over HTTPS with mutual TLS or over a local socket. Without it each group would repeat
14
+ * three rules that every request follows. A changing request carries an `Idempotency-Key`, and it is sent again with the same key
15
+ * when its connection fails before any reply. A refusal becomes a `EastIndiaApiError` with the service's error. A request that gets
16
+ * no answer becomes a `EastIndiaConnectionError` that says why.
17
+ *
18
+ * Over HTTPS the client presents the caller's certificate and accepts the server only when its certificate chains to `ca.pem`, is
19
+ * meant for server authentication and names the address: Node's TLS checks all three when `ca.pem` is the only authority given.
20
+ */
21
+ export class ApiConnection {
22
+ settings;
23
+ agent;
24
+ addressText;
25
+ /**
26
+ * Opens the connection; no request is sent until a route group sends one.
27
+ *
28
+ * @param settings - The address, certificate files and patience.
29
+ * @throws Error when an HTTPS address comes without the certificate files.
30
+ */
31
+ constructor(settings) {
32
+ this.settings = settings;
33
+ this.addressText = describeServiceAddress(settings.address);
34
+ this.agent = ApiConnection.createAgent(settings);
35
+ }
36
+ /**
37
+ * Reads a route's JSON answer. A read is sent once: it changes nothing, so a failed connection is reported rather than retried.
38
+ *
39
+ * @param path - The route's path with its query.
40
+ * @param signal - Cancels the request.
41
+ * @returns The parsed answer.
42
+ */
43
+ async read(path, signal) {
44
+ return parseJson(await this.send({ method: "GET", path, signal }, { keyed: false, timed: true }));
45
+ }
46
+ /**
47
+ * Sends a request that changes something and reads its JSON answer. It carries a new `Idempotency-Key`, and when its connection
48
+ * fails before any reply it is sent again with the same key, so that the service does the work once.
49
+ *
50
+ * @param exchange - The request.
51
+ * @param timed - Whether the client's time limit applies; false for an upload and for a wait the service answers only when it ends.
52
+ * @returns The parsed answer.
53
+ */
54
+ async change(exchange, timed = true) {
55
+ return parseJson(await this.send(exchange, { keyed: true, timed }));
56
+ }
57
+ /**
58
+ * Sends a request that changes something and answers without a body, with the key and retries of `change`.
59
+ *
60
+ * @param exchange - The request.
61
+ * @returns When the service has answered with success.
62
+ */
63
+ async changeWithoutAnswer(exchange) {
64
+ await this.send(exchange, { keyed: true, timed: true });
65
+ }
66
+ /**
67
+ * Sends a request exactly once, without an `Idempotency-Key`, without a retry and without the client's time limit, and reads its
68
+ * JSON answer. A request whose body carries a password goes this way, because the service must never store it and a repeat could
69
+ * answer a prompt twice; so does a request whose answer carries a private key.
70
+ *
71
+ * @param exchange - The request.
72
+ * @returns The parsed answer.
73
+ */
74
+ async sendOnce(exchange) {
75
+ return parseJson(await this.send(exchange, { keyed: false, timed: false }));
76
+ }
77
+ /**
78
+ * Sends a request that changes nothing although it is a `POST`, such as a screenshot, without an `Idempotency-Key`, and gives the
79
+ * whole answer, for an answer that is not JSON.
80
+ *
81
+ * @param exchange - The request.
82
+ * @returns The answer.
83
+ */
84
+ sendWithoutKey(exchange) {
85
+ return this.send(exchange, { keyed: false, timed: true });
86
+ }
87
+ /**
88
+ * Sends a request that has no body and whose answer has none, such as a HEAD, and gives the answer's headers.
89
+ *
90
+ * @param path - The route's path with its query.
91
+ * @param signal - Cancels the request.
92
+ * @returns The answer's headers.
93
+ */
94
+ async head(path, signal) {
95
+ return (await this.send({ method: "HEAD", path, signal }, { keyed: false, timed: true })).headers;
96
+ }
97
+ /**
98
+ * Sends a request whose answer is streamed, such as a followed log, and gives the answer once its headers have arrived. The client's
99
+ * time limit does not apply, because a followed stream stays open for as long as the service has more to send.
100
+ *
101
+ * @param exchange - The request.
102
+ * @returns The answer's body as it arrives; the caller reads it to its end or destroys it.
103
+ */
104
+ async openStream(exchange) {
105
+ const response = await this.dispatch(exchange, undefined, exchange.signal);
106
+ if (exchange.signal !== undefined) {
107
+ const signal = exchange.signal;
108
+ const stop = () => {
109
+ response.destroy(signal.reason instanceof Error ? signal.reason : undefined);
110
+ };
111
+ signal.addEventListener("abort", stop, { once: true });
112
+ response.once("close", () => {
113
+ signal.removeEventListener("abort", stop);
114
+ });
115
+ }
116
+ return response;
117
+ }
118
+ /**
119
+ * Reads a route whose answer is a stream of JSON objects, one per line, such as a machine's log or its readings. The request is
120
+ * sent when the iteration starts, and stopping the iteration early ends it.
121
+ *
122
+ * @param path - The route's path with its query.
123
+ * @param signal - Ends the request.
124
+ * @returns The objects, as their lines arrive.
125
+ */
126
+ async *readLines(path, signal) {
127
+ yield* readJsonLines(await this.openStream({ method: "GET", path, signal }));
128
+ }
129
+ /**
130
+ * Opens a route as a WebSocket, with the same certificate and the same server check as every other request.
131
+ *
132
+ * @param path - The route, such as `/v1/execs/{id}/start`.
133
+ * @param signal - Cancels the opening.
134
+ * @returns The open WebSocket; the caller closes it.
135
+ * @throws EastIndiaApiError when the service refuses the upgrade, with the error its answer carries.
136
+ * @throws EastIndiaConnectionError when no answer came.
137
+ */
138
+ openWebSocket(path, signal) {
139
+ signal?.throwIfAborted();
140
+ const socket = this.createWebSocket(path);
141
+ return new Promise((resolve, reject) => {
142
+ const abort = () => {
143
+ socket.terminate();
144
+ reject(signal?.reason);
145
+ };
146
+ signal?.addEventListener("abort", abort, { once: true });
147
+ const settle = () => {
148
+ signal?.removeEventListener("abort", abort);
149
+ socket.removeAllListeners("open").removeAllListeners("unexpected-response").removeAllListeners("error");
150
+ };
151
+ socket.once("open", () => {
152
+ settle();
153
+ resolve(socket);
154
+ });
155
+ // A refused upgrade is answered like any other refusal, with the error as its body. The ws package hands that answer to this
156
+ // listener and leaves the upgrade request open, so the listener reads the body and then destroys the request.
157
+ socket.once("unexpected-response", (request, response) => {
158
+ settle();
159
+ readAll(response).then((body) => {
160
+ reject(new EastIndiaApiError(response.statusCode ?? 0, readError({ status: response.statusCode ?? 0, headers: response.headers, body })));
161
+ }, (error) => {
162
+ reject(this.connectionError(error));
163
+ }).finally(() => {
164
+ request.destroy();
165
+ });
166
+ });
167
+ socket.once("error", (error) => {
168
+ settle();
169
+ reject(this.connectionError(error));
170
+ });
171
+ });
172
+ }
173
+ /** Closes the connections kept open for later requests. A request sent afterwards opens a new one. */
174
+ close() {
175
+ this.agent.destroy();
176
+ }
177
+ async send(exchange, sending) {
178
+ if (!sending.keyed) {
179
+ return this.sendAttempt(exchange, undefined, sending.timed);
180
+ }
181
+ // Every attempt of one changing request carries the same key, so that the service does the work once.
182
+ const key = randomUUID().replaceAll("-", "");
183
+ for (let attempt = 0;; attempt++) {
184
+ try {
185
+ return await this.sendAttempt(exchange, key, sending.timed);
186
+ }
187
+ catch (error) {
188
+ if (!(error instanceof EastIndiaConnectionError) || error.failure !== "unreachable" || attempt >= this.settings.requestRetries) {
189
+ throw error;
190
+ }
191
+ await delay(this.settings.requestRetryDelayMilliseconds, undefined, { signal: exchange.signal });
192
+ }
193
+ }
194
+ }
195
+ // One attempt, read to the end of its answer. The client's time limit covers both the reply and the reading of its body; when it
196
+ // passes, the request is abandoned and reported as timedOut, while the caller's own signal rejects with the caller's reason.
197
+ async sendAttempt(exchange, key, timed) {
198
+ const limit = timed ? AbortSignal.timeout(this.settings.requestTimeoutMilliseconds) : undefined;
199
+ const signal = combine(exchange.signal, limit);
200
+ try {
201
+ const response = await this.dispatch(exchange, key, signal);
202
+ const body = await readAll(response, signal).catch((error) => {
203
+ throw signal?.aborted === true ? error : this.connectionError(error);
204
+ });
205
+ return { status: response.statusCode ?? 0, headers: response.headers, body };
206
+ }
207
+ catch (error) {
208
+ if (limit?.aborted === true && exchange.signal?.aborted !== true) {
209
+ throw new EastIndiaConnectionError(this.addressText, "timedOut", error);
210
+ }
211
+ throw error;
212
+ }
213
+ }
214
+ // Sends the request and gives the answer once its headers have arrived. A refusal is read to its end and thrown as
215
+ // EastIndiaApiError; an error before any answer is thrown as EastIndiaConnectionError, unless the signal ended the request.
216
+ dispatch(exchange, key, signal) {
217
+ signal?.throwIfAborted();
218
+ return new Promise((resolve, reject) => {
219
+ const request = this.createRequest(exchange, key);
220
+ const abort = () => {
221
+ request.destroy(signal?.reason instanceof Error ? signal.reason : undefined);
222
+ reject(signal?.reason);
223
+ };
224
+ signal?.addEventListener("abort", abort, { once: true });
225
+ request.once("response", (response) => {
226
+ signal?.removeEventListener("abort", abort);
227
+ const status = response.statusCode ?? 0;
228
+ if (status < 400) {
229
+ resolve(response);
230
+ return;
231
+ }
232
+ readAll(response, signal).then((body) => {
233
+ reject(new EastIndiaApiError(status, readError({ status, headers: response.headers, body })));
234
+ }, (error) => {
235
+ reject(signal?.aborted === true ? signal.reason : this.connectionError(error));
236
+ });
237
+ });
238
+ request.once("error", (error) => {
239
+ signal?.removeEventListener("abort", abort);
240
+ reject(signal?.aborted === true ? signal.reason : this.connectionError(error));
241
+ });
242
+ this.writeBody(request, exchange.body);
243
+ });
244
+ }
245
+ createRequest(exchange, key) {
246
+ const headers = {};
247
+ if (key !== undefined) {
248
+ headers[Headers.idempotencyKey] = key;
249
+ }
250
+ if (exchange.body !== undefined) {
251
+ headers["Content-Type"] = exchange.body.contentType;
252
+ const length = "bytes" in exchange.body ? exchange.body.bytes.byteLength : exchange.body.length;
253
+ if (length !== undefined) {
254
+ headers["Content-Length"] = String(length);
255
+ }
256
+ }
257
+ const address = this.settings.address;
258
+ const options = { method: exchange.method, path: exchange.path, headers, agent: this.agent };
259
+ return address.kind === "https"
260
+ ? https.request({ ...options, host: address.hostname, port: address.port })
261
+ : http.request({ ...options, socketPath: address.path, host: "localhost" });
262
+ }
263
+ writeBody(request, body) {
264
+ if (body === undefined) {
265
+ request.end();
266
+ return;
267
+ }
268
+ if ("bytes" in body) {
269
+ request.end(body.bytes);
270
+ return;
271
+ }
272
+ const stream = body.open();
273
+ stream.once("error", (error) => request.destroy(error));
274
+ stream.pipe(request);
275
+ }
276
+ createWebSocket(path) {
277
+ const address = this.settings.address;
278
+ if (address.kind === "socket") {
279
+ return new WebSocket(`ws://localhost${path}`, { createConnection: () => net.connect(address.path) });
280
+ }
281
+ const tls = this.settings.tls;
282
+ const host = address.hostname.includes(":") ? `[${address.hostname}]` : address.hostname;
283
+ return new WebSocket(`wss://${host}:${String(address.port)}${path}`, { ca: tls?.ca, cert: tls?.cert, key: tls?.key });
284
+ }
285
+ connectionError(error) {
286
+ return error instanceof EastIndiaConnectionError
287
+ ? error
288
+ : new EastIndiaConnectionError(this.addressText, classifyConnectionFailure(error, this.settings.address.kind === "socket"), error);
289
+ }
290
+ static createAgent(settings) {
291
+ if (settings.address.kind === "socket") {
292
+ return new http.Agent({ keepAlive: true });
293
+ }
294
+ if (settings.tls === undefined) {
295
+ throw new Error("A connection over HTTPS needs the certificate files: ca, cert and key.");
296
+ }
297
+ return new https.Agent({ keepAlive: true, ca: settings.tls.ca, cert: settings.tls.cert, key: settings.tls.key });
298
+ }
299
+ }
300
+ /**
301
+ * Writes a JSON request body. Members whose value is undefined are left out, so that the service applies their defaults; the service
302
+ * refuses a member it does not know.
303
+ *
304
+ * @param value - The body.
305
+ * @returns The body as UTF-8 JSON.
306
+ */
307
+ export function jsonBody(value) {
308
+ return { contentType: MediaTypes.json, bytes: Buffer.from(JSON.stringify(value), "utf8") };
309
+ }
310
+ /**
311
+ * Writes a tar archive the caller packed as a request body.
312
+ *
313
+ * @param archive - The archive's bytes, or a function that opens it afresh, so that a retried request sends the same bytes.
314
+ * @returns The body, of type `application/x-tar`.
315
+ */
316
+ export function tarBody(archive) {
317
+ return archive instanceof Uint8Array ? { contentType: MediaTypes.tar, bytes: archive } : { contentType: MediaTypes.tar, open: archive };
318
+ }
319
+ /**
320
+ * Reads the error of a refusal: the JSON body, or, for an answer without a body such as a HEAD's, the `X-EastIndia-Error` header,
321
+ * which holds the same JSON in Base64. Gives null when neither holds a readable error.
322
+ *
323
+ * @param answer - The refusal.
324
+ * @returns The error, or null.
325
+ */
326
+ export function readError(answer) {
327
+ const fromBody = tryParseError(answer.body.toString("utf8"));
328
+ if (fromBody !== null) {
329
+ return fromBody;
330
+ }
331
+ const header = answer.headers[Headers.error.toLowerCase()];
332
+ return typeof header === "string" ? tryParseError(Buffer.from(header, "base64").toString("utf8")) : null;
333
+ }
334
+ /**
335
+ * Reads a JSON value that the API sends Base64-encoded in a header, such as `X-EastIndia-Path-Stat`.
336
+ *
337
+ * @param headers - The answer's headers.
338
+ * @param name - The header's name.
339
+ * @returns The value, or undefined when the header is absent or not Base64 of JSON.
340
+ */
341
+ export function readHeaderJson(headers, name) {
342
+ const text = headers[name.toLowerCase()];
343
+ if (typeof text !== "string") {
344
+ return undefined;
345
+ }
346
+ try {
347
+ return JSON.parse(Buffer.from(text, "base64").toString("utf8"));
348
+ }
349
+ catch {
350
+ return undefined;
351
+ }
352
+ }
353
+ function tryParseError(text) {
354
+ try {
355
+ const value = JSON.parse(text);
356
+ return typeof value === "object" && value !== null && "code" in value && "message" in value ? value : null;
357
+ }
358
+ catch {
359
+ return null;
360
+ }
361
+ }
362
+ function parseJson(answer) {
363
+ if (answer.body.length === 0) {
364
+ throw new Error(`The service answered ${String(answer.status)} without the JSON body the route promises.`);
365
+ }
366
+ return JSON.parse(answer.body.toString("utf8"));
367
+ }
368
+ /**
369
+ * Reads a body to its end.
370
+ *
371
+ * @param stream - The body.
372
+ * @param signal - Stops the reading; the promise then rejects with the signal's reason.
373
+ * @returns The bytes.
374
+ */
375
+ export async function readAll(stream, signal) {
376
+ const chunks = [];
377
+ const stop = () => {
378
+ stream.destroy(signal?.reason instanceof Error ? signal.reason : undefined);
379
+ };
380
+ signal?.addEventListener("abort", stop, { once: true });
381
+ try {
382
+ for await (const chunk of stream) {
383
+ chunks.push(chunk);
384
+ }
385
+ }
386
+ catch (error) {
387
+ throw signal?.aborted === true ? signal.reason : error;
388
+ }
389
+ finally {
390
+ signal?.removeEventListener("abort", stop);
391
+ }
392
+ return Buffer.concat(chunks);
393
+ }
394
+ function combine(first, second) {
395
+ return first === undefined ? second : second === undefined ? first : AbortSignal.any([first, second]);
396
+ }
@@ -0,0 +1,14 @@
1
+ import type { ConnectionFailure } from "../errors.js";
2
+ /**
3
+ * Decides why a request got no answer, from the error Node reported while sending it.
4
+ *
5
+ * A server certificate this client refused carries one of OpenSSL's verification codes. A client certificate the service refused
6
+ * ends the handshake with a TLS alert, whose OpenSSL code and message name the alert; under TLS 1.3 the alert arrives on the first
7
+ * read, after the request has been written, and is still named. A refused named pipe is an access error. Anything else is a
8
+ * connection that never reached the service.
9
+ *
10
+ * @param error - The error Node reported.
11
+ * @param overSocket - Whether the request went to a local socket, such as the named pipe, rather than over HTTPS.
12
+ * @returns The reason no answer came.
13
+ */
14
+ export declare function classifyConnectionFailure(error: unknown, overSocket: boolean): ConnectionFailure;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The error codes Node gives when this client refuses the server's certificate: OpenSSL's verification results, and Node's own code
3
+ * for a certificate that does not name the address. Any of them means the server is not the EastIndia service the client trusts.
4
+ */
5
+ const ServerCertificateRefusals = new Set([
6
+ "CERT_HAS_EXPIRED",
7
+ "CERT_NOT_YET_VALID",
8
+ "CERT_REJECTED",
9
+ "CERT_REVOKED",
10
+ "CERT_SIGNATURE_FAILURE",
11
+ "CERT_UNTRUSTED",
12
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
13
+ "ERR_TLS_CERT_ALTNAME_INVALID",
14
+ "HOSTNAME_MISMATCH",
15
+ "INVALID_CA",
16
+ "INVALID_PURPOSE",
17
+ "PATH_LENGTH_EXCEEDED",
18
+ "SELF_SIGNED_CERT_IN_CHAIN",
19
+ "UNABLE_TO_DECRYPT_CERT_SIGNATURE",
20
+ "UNABLE_TO_DECODE_ISSUER_PUBLIC_KEY",
21
+ "UNABLE_TO_GET_ISSUER_CERT",
22
+ "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
23
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
24
+ ]);
25
+ /** The error codes Windows gives, through Node, when it refuses to open a named pipe for the calling account. */
26
+ const PipeRefusals = new Set(["EACCES", "EPERM"]);
27
+ /**
28
+ * Decides why a request got no answer, from the error Node reported while sending it.
29
+ *
30
+ * A server certificate this client refused carries one of OpenSSL's verification codes. A client certificate the service refused
31
+ * ends the handshake with a TLS alert, whose OpenSSL code and message name the alert; under TLS 1.3 the alert arrives on the first
32
+ * read, after the request has been written, and is still named. A refused named pipe is an access error. Anything else is a
33
+ * connection that never reached the service.
34
+ *
35
+ * @param error - The error Node reported.
36
+ * @param overSocket - Whether the request went to a local socket, such as the named pipe, rather than over HTTPS.
37
+ * @returns The reason no answer came.
38
+ */
39
+ export function classifyConnectionFailure(error, overSocket) {
40
+ const code = codeOf(error);
41
+ if (ServerCertificateRefusals.has(code)) {
42
+ return "serverNotTrusted";
43
+ }
44
+ if (code.includes("ALERT") || messageOf(error).toLowerCase().includes("alert")) {
45
+ return "certificateRefused";
46
+ }
47
+ if (overSocket && PipeRefusals.has(code)) {
48
+ return "pipeRefused";
49
+ }
50
+ return "unreachable";
51
+ }
52
+ function codeOf(error) {
53
+ return error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : "";
54
+ }
55
+ function messageOf(error) {
56
+ return error instanceof Error ? error.message : "";
57
+ }
@@ -0,0 +1,10 @@
1
+ import type { Readable } from "node:stream";
2
+ /**
3
+ * Reads a stream of JSON objects, one per line (`application/x-ndjson`), which is how the service answers its log and readings
4
+ * routes. The generator yields each object as soon as its line arrives. Blank lines are skipped. Stopping the iteration early destroys the stream, which
5
+ * ends the request.
6
+ *
7
+ * @param stream - The answer's body.
8
+ * @returns The objects, in the order the lines arrive.
9
+ */
10
+ export declare function readJsonLines<T>(stream: Readable): AsyncGenerator<T, void, undefined>;
@@ -0,0 +1,23 @@
1
+ import { createInterface } from "node:readline";
2
+ /**
3
+ * Reads a stream of JSON objects, one per line (`application/x-ndjson`), which is how the service answers its log and readings
4
+ * routes. The generator yields each object as soon as its line arrives. Blank lines are skipped. Stopping the iteration early destroys the stream, which
5
+ * ends the request.
6
+ *
7
+ * @param stream - The answer's body.
8
+ * @returns The objects, in the order the lines arrive.
9
+ */
10
+ export async function* readJsonLines(stream) {
11
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
12
+ try {
13
+ for await (const line of lines) {
14
+ if (line.length > 0) {
15
+ yield JSON.parse(line);
16
+ }
17
+ }
18
+ }
19
+ finally {
20
+ lines.close();
21
+ stream.destroy();
22
+ }
23
+ }
@@ -0,0 +1,49 @@
1
+ import type { Filters } from "../contract/common.js";
2
+ /** A value a query parameter takes: text, a number, or a boolean written `true` or `false`. */
3
+ type QueryValue = string | number | boolean;
4
+ /**
5
+ * Builds a route's query string as the API reads it. A parameter whose value is undefined is left out, so that the service applies
6
+ * its default; the service refuses a parameter it does not know, so nothing else is ever added.
7
+ */
8
+ export declare class QueryText {
9
+ private readonly parameters;
10
+ /**
11
+ * Adds one parameter, unless its value is undefined.
12
+ *
13
+ * @param name - The parameter's name, as the API spells it.
14
+ * @param value - The value; undefined leaves the parameter out.
15
+ * @returns This query.
16
+ */
17
+ add(name: string, value: QueryValue | undefined): this;
18
+ /**
19
+ * Adds a boolean parameter only when it is true: false is every route's default for its boolean parameters.
20
+ *
21
+ * @param name - The parameter's name.
22
+ * @param value - The value; false or undefined leaves the parameter out.
23
+ * @returns This query.
24
+ */
25
+ addFlag(name: string, value: boolean | undefined): this;
26
+ /**
27
+ * Adds a list, which the API reads as the parameter repeated once per value.
28
+ *
29
+ * @param name - The parameter's name.
30
+ * @param values - The values; none leaves the parameter out.
31
+ * @returns This query.
32
+ */
33
+ addEach(name: string, values: readonly string[] | undefined): this;
34
+ /**
35
+ * Adds an object the API reads as URL-encoded JSON, such as `filters`, `buildargs` or `labels`.
36
+ *
37
+ * @param name - The parameter's name.
38
+ * @param value - The object; undefined or an object without members leaves the parameter out.
39
+ * @returns This query.
40
+ */
41
+ addJson(name: string, value: Filters | Readonly<Record<string, string>> | undefined): this;
42
+ /**
43
+ * Writes the query.
44
+ *
45
+ * @returns The query with its leading `?`, or an empty string when it has no parameter.
46
+ */
47
+ toString(): string;
48
+ }
49
+ export {};
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Builds a route's query string as the API reads it. A parameter whose value is undefined is left out, so that the service applies
3
+ * its default; the service refuses a parameter it does not know, so nothing else is ever added.
4
+ */
5
+ export class QueryText {
6
+ parameters = new URLSearchParams();
7
+ /**
8
+ * Adds one parameter, unless its value is undefined.
9
+ *
10
+ * @param name - The parameter's name, as the API spells it.
11
+ * @param value - The value; undefined leaves the parameter out.
12
+ * @returns This query.
13
+ */
14
+ add(name, value) {
15
+ if (value !== undefined) {
16
+ this.parameters.append(name, String(value));
17
+ }
18
+ return this;
19
+ }
20
+ /**
21
+ * Adds a boolean parameter only when it is true: false is every route's default for its boolean parameters.
22
+ *
23
+ * @param name - The parameter's name.
24
+ * @param value - The value; false or undefined leaves the parameter out.
25
+ * @returns This query.
26
+ */
27
+ addFlag(name, value) {
28
+ return value === true ? this.add(name, true) : this;
29
+ }
30
+ /**
31
+ * Adds a list, which the API reads as the parameter repeated once per value.
32
+ *
33
+ * @param name - The parameter's name.
34
+ * @param values - The values; none leaves the parameter out.
35
+ * @returns This query.
36
+ */
37
+ addEach(name, values) {
38
+ for (const value of values ?? []) {
39
+ this.add(name, value);
40
+ }
41
+ return this;
42
+ }
43
+ /**
44
+ * Adds an object the API reads as URL-encoded JSON, such as `filters`, `buildargs` or `labels`.
45
+ *
46
+ * @param name - The parameter's name.
47
+ * @param value - The object; undefined or an object without members leaves the parameter out.
48
+ * @returns This query.
49
+ */
50
+ addJson(name, value) {
51
+ return value === undefined || Object.keys(value).length === 0 ? this : this.add(name, JSON.stringify(value));
52
+ }
53
+ /**
54
+ * Writes the query.
55
+ *
56
+ * @returns The query with its leading `?`, or an empty string when it has no parameter.
57
+ */
58
+ toString() {
59
+ const text = this.parameters.toString();
60
+ return text === "" ? "" : `?${text}`;
61
+ }
62
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Where the client sends its requests: a host and port reached over HTTPS with mutual TLS, or a local socket, which on Windows is
3
+ * the service's named pipe and needs no certificate. The `endia` CLI writes the same two places as `tcp://<host>:<port>` and
4
+ * `npipe:////./pipe/<name>`, and `parseServiceAddress` reads those forms.
5
+ */
6
+ export type ServiceAddress = {
7
+ readonly kind: "https";
8
+ readonly hostname: string;
9
+ readonly port: number;
10
+ } | {
11
+ readonly kind: "socket";
12
+ readonly path: string;
13
+ };
14
+ /**
15
+ * Reads an address in the forms the CLI and `EASTINDIA_HOST` take: `tcp://<host>:<port>`, over which TLS is always used and always
16
+ * verified, or `npipe:////./pipe/<name>`, a named pipe on this PC.
17
+ *
18
+ * @param host - The address, for example `tcp://host.docker.internal:7443` or `npipe:////./pipe/eastindia_engine`.
19
+ * @param setting - Where the address came from, which the error message names, for example `EASTINDIA_HOST`.
20
+ * @returns Where the client connects.
21
+ * @throws Error when the address has neither form.
22
+ */
23
+ export declare function parseServiceAddress(host: string, setting: string): ServiceAddress;
24
+ /**
25
+ * Names an address as messages name it: the host and port, or the socket's path.
26
+ *
27
+ * @param address - The address.
28
+ * @returns For example `host.docker.internal:7443` or `\\.\pipe\eastindia_engine`.
29
+ */
30
+ export declare function describeServiceAddress(address: ServiceAddress): string;