@nidl/client 0.0.0-stage → 0.1.0

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/index.js ADDED
@@ -0,0 +1,817 @@
1
+ // @nidl/client: drive a running nidl core from a Node program, over its local socket, as the
2
+ // `app` client kind (docs/protocol/nidl-core-protocol.md). It never starts a core, and it checks
3
+ // the folder the socket lives in before trusting it. With a URL and a remote token it reaches a
4
+ // core on another machine through its remote port, over HTTPS (docs/specs/nidl-remote-mode.md s.5).
5
+ import { createHash, randomUUID } from "node:crypto";
6
+ import { constants } from "node:fs";
7
+ import { lstat, open, realpath } from "node:fs/promises";
8
+ import { request as httpRequest } from "node:http";
9
+ import { createConnection, isIP } from "node:net";
10
+ import { homedir } from "node:os";
11
+ import { dirname, isAbsolute, join, sep } from "node:path";
12
+ import { connect as tlsConnect } from "node:tls";
13
+ import { parseStrict } from "./json.js";
14
+ export const VERSION = "0.1.0";
15
+ const MAX_RECORD = 16 * 1024;
16
+ const strictUtf8 = new TextDecoder("utf-8", { fatal: true });
17
+ const MAX_LINE = 1024 * 1024;
18
+ const DEFAULT_TIMEOUT_MS = 10_000;
19
+ /** The longest socket path the platform takes, without its NUL. */
20
+ const MAX_SOCKET_PATH = process.platform === "darwin" ? 103 : 107;
21
+ /** PQ4: the most a secret frame's payload may be, as the core bounds it. */
22
+ const MAX_SECRET = 64 * 1024;
23
+ /**
24
+ * Why a call failed. `code` is for programs and `message` for people. `outcome` says whether
25
+ * anything happened: `not_started`, or `unknown` when the connection was lost after the request
26
+ * was sent.
27
+ */
28
+ export class NidlError extends Error {
29
+ code;
30
+ outcome;
31
+ name = "NidlError";
32
+ constructor(code, message, outcome = "not_started") {
33
+ super(message);
34
+ this.code = code;
35
+ this.outcome = outcome;
36
+ }
37
+ }
38
+ /**
39
+ * Connect to the core serving `dataDir`, or, with `url` and `token`, to the core whose remote port
40
+ * is at `url`; then say hello once to prove it answers.
41
+ */
42
+ export async function connect(options = {}) {
43
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
44
+ let nidl;
45
+ if (options.url !== undefined || options.token !== undefined || options.fingerprint !== undefined) {
46
+ if (options.dataDir !== undefined) {
47
+ throw new NidlError("invalid_input", "Name one core: a data folder, or a remote URL.");
48
+ }
49
+ if (options.url === undefined || options.token === undefined) {
50
+ throw new NidlError("invalid_input", "A remote core needs both its URL and a remote token.");
51
+ }
52
+ nidl = new Nidl(undefined, timeoutMs, new Remote(options.url, options.token, options.fingerprint));
53
+ }
54
+ else {
55
+ nidl = new Nidl(options.dataDir ?? join(homedir(), ".nidl"), timeoutMs);
56
+ }
57
+ await nidl.refresh();
58
+ return nidl;
59
+ }
60
+ export class Nidl {
61
+ dataDir;
62
+ timeoutMs;
63
+ remote;
64
+ #identity;
65
+ /** @internal Use `connect()`. */
66
+ constructor(
67
+ /** The data folder of the core on this machine; undefined for a remote core. */
68
+ dataDir, timeoutMs, remote) {
69
+ this.dataDir = dataDir;
70
+ this.timeoutMs = timeoutMs;
71
+ this.remote = remote;
72
+ }
73
+ /** The core's identity from the latest hello. */
74
+ get identity() {
75
+ if (!this.#identity) {
76
+ throw new NidlError("not_connected", "Call connect() first.");
77
+ }
78
+ return this.#identity;
79
+ }
80
+ /** Whether the core offers a command, by the latest hello. */
81
+ has(name) {
82
+ return this.identity.commands.includes(name);
83
+ }
84
+ /**
85
+ * Run one command and return its result, or throw a `NidlError`. Each call opens its own
86
+ * connection, checks the folder again and says hello again. `name` is one an app may run
87
+ * (CL8); a newer core's command is called with a cast, `"its.name" as CommandName`.
88
+ */
89
+ async command(name, args = {}) {
90
+ // PQ4: a key goes in the socket's secret frame, never in a command's arguments. The type
91
+ // leaves `pools.lend` out; this refuses it from JavaScript, or through a cast.
92
+ if (name === "pools.lend") {
93
+ throw new NidlError("invalid_input", "pools.lend takes a key; call lend(), which sends it in the socket's secret frame.");
94
+ }
95
+ this.offered(name);
96
+ const reply = await this.exchange({ name, version: 1, arguments: args, requestId: randomUUID() });
97
+ return commandResult(reply);
98
+ }
99
+ /**
100
+ * PQ4 (docs/specs/nidl-external-pools.md s.6 item 1): lend `apiKey` to the borrowed account
101
+ * `accountId` of the core on this machine. An app proves the account's project with
102
+ * `appToken`, an app token of one of that project's targets. The key travels in the socket's
103
+ * secret frame, never in the command's arguments, and never through the remote port. A
104
+ * JavaScript string cannot be wiped; the frame's own bytes are overwritten once written.
105
+ */
106
+ async lend(accountId, apiKey, options = {}) {
107
+ this.onThisMachine();
108
+ this.offered("pools.lend");
109
+ const args = { accountId };
110
+ if (options.expiresInSeconds !== undefined)
111
+ args.expiresInSeconds = options.expiresInSeconds;
112
+ const frame = { credential: { apiKey } };
113
+ if (options.appToken !== undefined)
114
+ frame.appToken = options.appToken;
115
+ const secret = secretFrame(Buffer.from(JSON.stringify(frame)));
116
+ let reply;
117
+ try {
118
+ reply = await this.exchange({ name: "pools.lend", version: 1, arguments: args, requestId: randomUUID() }, secret);
119
+ }
120
+ finally {
121
+ // However the call ended, before the frame was written included (the scoped review, m3).
122
+ secret.fill(0);
123
+ }
124
+ return commandResult(reply);
125
+ }
126
+ /**
127
+ * PQ4: end the lease of the borrowed account `accountId` on the core on this machine.
128
+ * Withdrawing none is not an error: the result says whether there was one.
129
+ */
130
+ async withdraw(accountId) {
131
+ this.onThisMachine();
132
+ return this.command("pools.withdraw", { accountId });
133
+ }
134
+ /**
135
+ * PQ4: report an observed account's quota readings, on this machine or through the remote port
136
+ * with a remote token bound to the account's project. `capturedAt` (RFC 3339) defaults to now.
137
+ */
138
+ async observe(accountId, windows, capturedAt) {
139
+ const args = { accountId, windows };
140
+ if (capturedAt !== undefined)
141
+ args.capturedAt = capturedAt;
142
+ return this.command("readings.observe", args);
143
+ }
144
+ onThisMachine() {
145
+ if (this.remote) {
146
+ throw new NidlError("unsupported", "A key is lent and withdrawn only on the nidl core on this machine, never through the remote port.");
147
+ }
148
+ }
149
+ offered(name) {
150
+ if (!this.has(name)) {
151
+ throw new NidlError("not_offered", `This nidl core (version ${this.identity.coreVersion}) does not offer ${name}; update nidl.`);
152
+ }
153
+ }
154
+ /** @internal Say hello on a fresh connection and keep the identity. */
155
+ async refresh() {
156
+ await this.exchange(undefined);
157
+ }
158
+ /** `secret`, when given, is the secret frame written right after the command's line (PQ4). */
159
+ async exchange(execute, secret) {
160
+ if (this.remote) {
161
+ const deadline = Date.now() + this.timeoutMs;
162
+ if (execute === undefined) {
163
+ this.#identity = await this.remote.hello(deadline);
164
+ return this.#identity;
165
+ }
166
+ return this.remote.command(String(execute.name), execute.arguments, deadline);
167
+ }
168
+ const namespace = await checkedNamespace(this.dataDir ?? join(homedir(), ".nidl"));
169
+ const connection = await Connection.open(namespace, this.timeoutMs);
170
+ try {
171
+ await connection.afterConnect(namespace);
172
+ const identity = await connection.hello(namespace);
173
+ this.#identity = identity;
174
+ if (execute === undefined) {
175
+ await verifyUnchanged(namespace);
176
+ return identity;
177
+ }
178
+ const reply = await connection.request("command.execute", execute, secret);
179
+ try {
180
+ await verifyUnchanged(namespace);
181
+ }
182
+ catch (error) {
183
+ // CLb s.10 item 1: the command was sent and may have run, whatever the check found.
184
+ if (error instanceof NidlError)
185
+ throw new NidlError(error.code, error.message, "unknown");
186
+ throw error;
187
+ }
188
+ return reply;
189
+ }
190
+ finally {
191
+ connection.close();
192
+ }
193
+ }
194
+ }
195
+ function startHint(dataDir) {
196
+ return `No nidl core is running for ${dataDir}. Start one with: nidl serve --data-dir ${dataDir}`;
197
+ }
198
+ function unsafe(what) {
199
+ return new NidlError("unsafe_folder", `${what}, so nidl's socket there cannot be trusted. Check its owner and mode.`);
200
+ }
201
+ function isObject(value) {
202
+ return typeof value === "object" && value !== null && !Array.isArray(value);
203
+ }
204
+ function canonicalNumber(value) {
205
+ return typeof value === "string" && /^(0|[1-9][0-9]*)$/.test(value);
206
+ }
207
+ function errorCode(error) {
208
+ return isObject(error) && typeof error.code === "string" ? error.code : undefined;
209
+ }
210
+ /** The checks of the protocol's s.2 item 2 that Node can make: owners, modes, the record, the socket. */
211
+ async function checkedNamespace(dataDir) {
212
+ if (process.platform !== "darwin" && process.platform !== "linux") {
213
+ throw new NidlError("unsupported", "nidl runs on macOS and Linux only.");
214
+ }
215
+ const uid = process.geteuid?.();
216
+ if (uid === undefined) {
217
+ throw new NidlError("unsupported", "This platform has no user ids.");
218
+ }
219
+ let real;
220
+ try {
221
+ real = await realpath(dataDir);
222
+ }
223
+ catch (error) {
224
+ if (errorCode(error) === "ENOENT")
225
+ throw new NidlError("not_running", startHint(dataDir));
226
+ throw unsafe(`The data folder ${dataDir} cannot be read`);
227
+ }
228
+ if (!isAbsolute(real) || real.split(sep).includes("..")) {
229
+ throw unsafe(`The data folder ${dataDir} is not an absolute path`);
230
+ }
231
+ // Every folder from / down to the data folder: a folder, the user's or root's, and not writable
232
+ // by group or others.
233
+ const chain = [];
234
+ for (let path = real;; path = dirname(path)) {
235
+ chain.unshift(path);
236
+ if (dirname(path) === path)
237
+ break;
238
+ }
239
+ for (const path of chain) {
240
+ const stats = await lstat(path, { bigint: true }).catch(() => {
241
+ throw unsafe(`${path} cannot be read`);
242
+ });
243
+ if (!stats.isDirectory() ||
244
+ (stats.uid !== BigInt(uid) && stats.uid !== 0n) ||
245
+ (stats.mode & 18n) !== 0n) {
246
+ throw unsafe(`${path} is writable by others or owned by another user`);
247
+ }
248
+ }
249
+ const control = join(real, ".nidl-runtime");
250
+ const controlStats = await lstatOrNotRunning(control, dataDir);
251
+ if (!controlStats.isDirectory() ||
252
+ controlStats.uid !== BigInt(uid) ||
253
+ (controlStats.mode & 4095n) !== 448n) {
254
+ throw unsafe(`${control} is not a folder only you can use`);
255
+ }
256
+ const recordPath = join(control, "core-info.json");
257
+ const recordText = await readRecord(recordPath, dataDir, uid);
258
+ const record = parseRecord(recordText, {
259
+ device: controlStats.dev.toString(),
260
+ inode: controlStats.ino.toString(),
261
+ });
262
+ const socketPath = join(control, "core.sock");
263
+ if (Buffer.byteLength(socketPath) > MAX_SOCKET_PATH) {
264
+ throw new NidlError("path_too_long", `The socket path ${socketPath} is longer than this platform allows (${MAX_SOCKET_PATH} bytes); use a shorter data folder.`);
265
+ }
266
+ const namespace = { dataDir, socketPath, recordPath, recordText, record };
267
+ await checkSocket(namespace, uid);
268
+ return namespace;
269
+ }
270
+ async function lstatOrNotRunning(path, dataDir) {
271
+ try {
272
+ return await lstat(path, { bigint: true });
273
+ }
274
+ catch (error) {
275
+ if (errorCode(error) === "ENOENT")
276
+ throw new NidlError("not_running", startHint(dataDir));
277
+ throw unsafe(`${path} cannot be read`);
278
+ }
279
+ }
280
+ async function readRecord(path, dataDir, uid) {
281
+ const stats = await lstatOrNotRunning(path, dataDir);
282
+ if (!stats.isFile() ||
283
+ stats.nlink !== 1n ||
284
+ stats.uid !== BigInt(uid) ||
285
+ (stats.mode & 4095n) !== 384n ||
286
+ stats.size > BigInt(MAX_RECORD)) {
287
+ throw unsafe(`${path} is not a private record`);
288
+ }
289
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW).catch(() => {
290
+ throw unsafe(`${path} cannot be opened`);
291
+ });
292
+ try {
293
+ const opened = await handle.stat({ bigint: true });
294
+ if (opened.dev !== stats.dev || opened.ino !== stats.ino) {
295
+ throw unsafe(`${path} changed while it was read`);
296
+ }
297
+ const buffer = Buffer.alloc(MAX_RECORD + 1);
298
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
299
+ if (bytesRead > MAX_RECORD) {
300
+ throw unsafe(`${path} is too long`);
301
+ }
302
+ // CLb s.10 item 7: invalid UTF-8 is refused, never replaced.
303
+ try {
304
+ return strictUtf8.decode(buffer.subarray(0, bytesRead));
305
+ }
306
+ catch {
307
+ throw unsafe(`${path} is not UTF-8 text`);
308
+ }
309
+ }
310
+ finally {
311
+ await handle.close();
312
+ }
313
+ }
314
+ function parseRecord(text, root) {
315
+ let value;
316
+ try {
317
+ value = parseStrict(text, { integersOnly: true });
318
+ }
319
+ catch {
320
+ throw unsafe("The core's record is not one JSON object");
321
+ }
322
+ if (!isObject(value) ||
323
+ Object.keys(value).sort().join(",") !== "endpoint,runtime,schemaVersion,socket" ||
324
+ value.schemaVersion !== 1 ||
325
+ value.endpoint !== "core.sock" ||
326
+ !isObject(value.socket) ||
327
+ Object.keys(value.socket).sort().join(",") !== "device,inode" ||
328
+ !canonicalNumber(value.socket.device) ||
329
+ !canonicalNumber(value.socket.inode) ||
330
+ !isObject(value.runtime) ||
331
+ !isObject(value.runtime.root) ||
332
+ value.runtime.root.device !== root.device ||
333
+ value.runtime.root.inode !== root.inode ||
334
+ typeof value.runtime.instanceNonce !== "string" ||
335
+ !/^[0-9a-f]{64}$/.test(value.runtime.instanceNonce)) {
336
+ throw unsafe("The core's record does not describe this folder's core");
337
+ }
338
+ if (value.runtime.apiMajor !== 1) {
339
+ throw new NidlError("version_conflict", `This nidl core speaks protocol version ${String(value.runtime.apiMajor)}; this library speaks 1.`);
340
+ }
341
+ return {
342
+ socket: { device: value.socket.device, inode: value.socket.inode },
343
+ runtime: value.runtime,
344
+ };
345
+ }
346
+ async function checkSocket(namespace, uid) {
347
+ const stats = await lstatOrNotRunning(namespace.socketPath, namespace.dataDir);
348
+ if (!stats.isSocket() ||
349
+ stats.nlink !== 1n ||
350
+ stats.uid !== BigInt(uid) ||
351
+ (stats.mode & 4095n) !== 384n ||
352
+ stats.dev.toString() !== namespace.record.socket.device ||
353
+ stats.ino.toString() !== namespace.record.socket.inode) {
354
+ throw unsafe(`${namespace.socketPath} is not the socket the core's record names`);
355
+ }
356
+ }
357
+ /** After a reply: the record and the socket are still the ones checked before connecting. */
358
+ async function verifyUnchanged(namespace) {
359
+ const uid = process.geteuid?.() ?? -1;
360
+ const text = await readRecord(namespace.recordPath, namespace.dataDir, uid);
361
+ if (text !== namespace.recordText) {
362
+ throw unsafe("The core's record changed during the call");
363
+ }
364
+ await checkSocket(namespace, uid);
365
+ }
366
+ /**
367
+ * PQ4: the socket's secret frame (docs/protocol/nidl-core-protocol.md s.8): `NSF1`, the payload's
368
+ * length as a big-endian u32, the payload, and a newline. The payload is overwritten here.
369
+ */
370
+ function secretFrame(payload) {
371
+ if (payload.length === 0 || payload.length > MAX_SECRET) {
372
+ payload.fill(0);
373
+ throw new NidlError("invalid_input", "The key and its app token are longer than the core accepts (64 KiB).");
374
+ }
375
+ const frame = Buffer.alloc(payload.length + 9);
376
+ frame.write("NSF1", 0, "latin1");
377
+ frame.writeUInt32BE(payload.length, 4);
378
+ payload.copy(frame, 8);
379
+ frame[frame.length - 1] = 0x0a;
380
+ payload.fill(0);
381
+ return frame;
382
+ }
383
+ function commandResult(reply) {
384
+ if (!isObject(reply) || typeof reply.ok !== "boolean") {
385
+ throw new NidlError("protocol_error", "The core's reply is not a command reply.");
386
+ }
387
+ if (reply.ok) {
388
+ return reply.result;
389
+ }
390
+ const error = reply.error;
391
+ if (!isObject(error) || typeof error.code !== "string" || typeof error.message !== "string") {
392
+ throw new NidlError("protocol_error", "The core's reply is not a command reply.");
393
+ }
394
+ throw new NidlError(error.code, error.message, typeof error.outcome === "string" ? error.outcome : "not_started");
395
+ }
396
+ /** One connection: hello, at most one request, closed. */
397
+ class Connection {
398
+ socket;
399
+ buffer = Buffer.alloc(0);
400
+ waiting;
401
+ failure;
402
+ written = false;
403
+ deadline;
404
+ constructor(socket, timeoutMs) {
405
+ this.socket = socket;
406
+ this.deadline = Date.now() + timeoutMs;
407
+ socket.on("data", (chunk) => this.received(chunk));
408
+ socket.on("error", (error) => this.fail(error));
409
+ socket.on("close", () => this.fail(new Error("closed")));
410
+ }
411
+ static open(namespace, timeoutMs) {
412
+ return new Promise((resolve, reject) => {
413
+ const socket = createConnection({ path: namespace.socketPath });
414
+ const timer = setTimeout(() => {
415
+ socket.destroy();
416
+ reject(new NidlError("timeout", "The nidl core did not accept the connection in time."));
417
+ }, timeoutMs);
418
+ socket.once("connect", () => {
419
+ clearTimeout(timer);
420
+ socket.removeAllListeners("error");
421
+ resolve(new Connection(socket, timeoutMs));
422
+ });
423
+ socket.once("error", (error) => {
424
+ clearTimeout(timer);
425
+ const code = errorCode(error);
426
+ reject(code === "ECONNREFUSED" || code === "ENOENT"
427
+ ? new NidlError("not_running", startHint(namespace.dataDir))
428
+ : new NidlError("unavailable", `The nidl core's socket could not be reached (${code ?? "error"}).`));
429
+ });
430
+ });
431
+ }
432
+ /** Node cannot ask for the peer's user, so the socket is checked again once connected. */
433
+ async afterConnect(namespace) {
434
+ await checkSocket(namespace, process.geteuid?.() ?? -1);
435
+ }
436
+ async hello(namespace) {
437
+ const runtime = namespace.record.runtime;
438
+ const result = await this.request("runtime.hello", {
439
+ apiMajor: 1,
440
+ apiMinor: 0,
441
+ clientKind: "app",
442
+ clientVersion: VERSION,
443
+ expectedRoot: runtime.root,
444
+ expectedInstance: runtime.instanceNonce,
445
+ });
446
+ if (!isObject(result) ||
447
+ !isObject(result.root) ||
448
+ result.root.device !== runtime.root.device ||
449
+ result.root.inode !== runtime.root.inode ||
450
+ result.instanceNonce !== runtime.instanceNonce ||
451
+ result.apiMajor !== 1 ||
452
+ typeof result.coreVersion !== "string" ||
453
+ !Array.isArray(result.commands) ||
454
+ !result.commands.every((name) => typeof name === "string")) {
455
+ throw new NidlError("protocol_error", "The nidl core's hello does not match its record.");
456
+ }
457
+ return result;
458
+ }
459
+ async request(method, params, secret) {
460
+ const id = randomUUID();
461
+ const line = `${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`;
462
+ if (Buffer.byteLength(line) > MAX_LINE) {
463
+ secret?.fill(0);
464
+ throw new NidlError("invalid_input", "The request is larger than the core accepts (1 MiB).");
465
+ }
466
+ this.socket.write(line);
467
+ this.written = method === "command.execute";
468
+ if (secret) {
469
+ // PQ4: the secret frame follows its command's line at once, and is overwritten once the
470
+ // socket has taken it (or failed to).
471
+ this.socket.write(secret, () => secret.fill(0));
472
+ }
473
+ const bytes = await this.line();
474
+ let reply;
475
+ try {
476
+ reply = parseStrict(strictUtf8.decode(bytes));
477
+ }
478
+ catch {
479
+ throw new NidlError("protocol_error", "The nidl core's reply is not JSON.", this.outcome());
480
+ }
481
+ if (!isObject(reply) || reply.jsonrpc !== "2.0" || reply.id !== id) {
482
+ throw new NidlError("protocol_error", "The nidl core's reply does not answer this request.", this.outcome());
483
+ }
484
+ if (isObject(reply.error)) {
485
+ const data = isObject(reply.error.data) ? reply.error.data : {};
486
+ throw new NidlError(typeof data.code === "string" ? data.code : "protocol_error", typeof reply.error.message === "string" ? reply.error.message : "The nidl core refused the request.", typeof data.outcome === "string" ? data.outcome : "not_started");
487
+ }
488
+ if (!("result" in reply)) {
489
+ throw new NidlError("protocol_error", "The nidl core's reply has no result.", this.outcome());
490
+ }
491
+ return reply.result;
492
+ }
493
+ close() {
494
+ this.socket.destroy();
495
+ }
496
+ outcome() {
497
+ return this.written ? "unknown" : "not_started";
498
+ }
499
+ line() {
500
+ const ready = this.take();
501
+ if (ready !== undefined)
502
+ return Promise.resolve(ready);
503
+ if (this.failure)
504
+ return Promise.reject(this.lost());
505
+ return new Promise((resolve, reject) => {
506
+ const timer = setTimeout(() => {
507
+ this.waiting = undefined;
508
+ this.socket.destroy();
509
+ reject(new NidlError("timeout", "The nidl core did not answer in time.", this.outcome()));
510
+ }, Math.max(0, this.deadline - Date.now()));
511
+ this.waiting = (line) => {
512
+ clearTimeout(timer);
513
+ this.waiting = undefined;
514
+ if (line instanceof Error)
515
+ reject(line);
516
+ else
517
+ resolve(line);
518
+ };
519
+ });
520
+ }
521
+ take() {
522
+ const end = this.buffer.indexOf(0x0a);
523
+ if (end < 0)
524
+ return undefined;
525
+ const line = Buffer.from(this.buffer.subarray(0, end));
526
+ this.buffer = this.buffer.subarray(end + 1);
527
+ return line;
528
+ }
529
+ received(chunk) {
530
+ this.buffer = Buffer.concat([this.buffer, chunk]);
531
+ if (this.buffer.indexOf(0x0a) < 0 && this.buffer.length > MAX_LINE) {
532
+ this.failure = new NidlError("protocol_error", "The nidl core's reply is longer than 1 MiB.", this.outcome());
533
+ this.socket.destroy();
534
+ this.waiting?.(this.failure);
535
+ return;
536
+ }
537
+ // A line is taken only for a request waiting on it; otherwise it stays for the next `line()`.
538
+ if (this.waiting) {
539
+ const line = this.take();
540
+ if (line !== undefined)
541
+ this.waiting(line);
542
+ }
543
+ }
544
+ fail(error) {
545
+ if (this.failure)
546
+ return;
547
+ this.failure = error;
548
+ this.waiting?.(this.lost());
549
+ }
550
+ lost() {
551
+ if (this.failure instanceof NidlError)
552
+ return this.failure;
553
+ return new NidlError("unavailable", "The connection to the nidl core was lost.", this.outcome());
554
+ }
555
+ }
556
+ // NR3 (docs/specs/nidl-remote-mode.md s.5): a core's remote port, over HTTPS.
557
+ /** The most a command's body may be, and the most an answer may be. */
558
+ const MAX_BODY = 1024 * 1024;
559
+ const MAX_ANSWER = 2 * 1024 * 1024;
560
+ function invalid(message) {
561
+ return new NidlError("invalid_input", message);
562
+ }
563
+ /** `https://host[:port][/]`: nothing else, so a token never goes in plain text or to a path the caller did not mean. */
564
+ function parseUrl(url) {
565
+ if (!url.startsWith("https://")) {
566
+ throw invalid("A remote nidl is reached over https://, never plain http.");
567
+ }
568
+ let authority = url.slice("https://".length);
569
+ if (authority.endsWith("/"))
570
+ authority = authority.slice(0, -1);
571
+ // A name, an IPv4 address, or an IPv6 address in brackets, and a port: nothing that could
572
+ // become another header or another place (NR3's review, m4).
573
+ if (!/^[A-Za-z0-9.:[\]-]+$/.test(authority)) {
574
+ throw invalid("A remote nidl's URL is https://HOST[:PORT], with no path, query or user.");
575
+ }
576
+ let host;
577
+ let portText;
578
+ if (authority.startsWith("[")) {
579
+ const close = authority.indexOf("]");
580
+ if (close < 0)
581
+ throw invalid("The URL's IPv6 address has no closing bracket.");
582
+ host = authority.slice(1, close);
583
+ const after = authority.slice(close + 1);
584
+ if (after !== "" && !after.startsWith(":")) {
585
+ throw invalid("After an IPv6 address's bracket comes only :PORT.");
586
+ }
587
+ if (isIP(host) !== 6)
588
+ throw invalid("The URL's brackets hold no IPv6 address.");
589
+ portText = after === "" ? undefined : after.slice(1);
590
+ }
591
+ else {
592
+ const colon = authority.indexOf(":");
593
+ host = colon < 0 ? authority : authority.slice(0, colon);
594
+ portText = colon < 0 ? undefined : authority.slice(colon + 1);
595
+ }
596
+ if (/[[\]]/.test(host))
597
+ throw invalid("The URL names no host.");
598
+ let port = 443;
599
+ if (portText !== undefined) {
600
+ if (!/^[0-9]{1,5}$/.test(portText) || Number(portText) < 1 || Number(portText) > 65535) {
601
+ throw invalid("The URL's port is not a number from 1 to 65535.");
602
+ }
603
+ port = Number(portText);
604
+ }
605
+ if (host === "")
606
+ throw invalid("The URL names no host.");
607
+ return { host, port, authority };
608
+ }
609
+ /** `sha256:` and 64 hex digits, as `nidl remote status` shows it. */
610
+ function parseFingerprint(fingerprint) {
611
+ const match = /^sha256:([0-9a-fA-F]{64})$/.exec(fingerprint);
612
+ if (!match?.[1]) {
613
+ throw invalid("A fingerprint is sha256: and 64 hex digits, as `nidl remote status` shows it.");
614
+ }
615
+ return Buffer.from(match[1], "hex");
616
+ }
617
+ function timeLeft(deadline) {
618
+ const left = deadline - Date.now();
619
+ if (left <= 0)
620
+ throw new NidlError("timeout", "The remote nidl did not answer in time.");
621
+ return left;
622
+ }
623
+ /**
624
+ * A core's remote port: one TLS connection per call, closed after the answer. The certificate is
625
+ * checked once the handshake is done and before anything is written, so a token is never sent to a
626
+ * server that is not the core's. With a pin, the handshake's own signature still proves the server
627
+ * holds the pinned certificate's key.
628
+ */
629
+ class Remote {
630
+ host;
631
+ port;
632
+ authority;
633
+ pin;
634
+ /** A true private field, so `console.log` and `util.inspect` never show it (NR3's review, M2). */
635
+ #token;
636
+ constructor(url, token, fingerprint) {
637
+ ({ host: this.host, port: this.port, authority: this.authority } = parseUrl(url));
638
+ if (typeof token !== "string" || token.length === 0 || token.length > 256 || !/^[!-~]+$/.test(token)) {
639
+ throw invalid("A remote token is the printable text `nidl remote-tokens issue` gave.");
640
+ }
641
+ this.#token = token;
642
+ this.pin = fingerprint === undefined ? undefined : parseFingerprint(fingerprint);
643
+ }
644
+ async hello(deadline) {
645
+ const { status, body } = await this.exchange("GET", "/nidl/v1/hello", undefined, deadline);
646
+ const identity = this.answer(status, body, false);
647
+ if (!isObject(identity)) {
648
+ throw new NidlError("protocol_error", "The remote nidl's hello is not a core's identity.");
649
+ }
650
+ if (identity.apiMajor !== 1) {
651
+ throw new NidlError("version_conflict", `This nidl core speaks protocol version ${String(identity.apiMajor)}; this library speaks 1.`);
652
+ }
653
+ if (typeof identity.coreVersion !== "string" ||
654
+ typeof identity.scope !== "string" ||
655
+ !Array.isArray(identity.commands) ||
656
+ !identity.commands.every((name) => typeof name === "string")) {
657
+ throw new NidlError("protocol_error", "The remote nidl's hello is not a core's identity.");
658
+ }
659
+ return identity;
660
+ }
661
+ async command(name, args, deadline) {
662
+ const body = Buffer.from(JSON.stringify({ name, arguments: args ?? {} }));
663
+ if (body.length > MAX_BODY) {
664
+ throw invalid("The command is larger than a remote nidl accepts (1 MiB).");
665
+ }
666
+ const answer = await this.exchange("POST", "/nidl/v1/command", body, deadline);
667
+ return this.answer(answer.status, answer.body, true);
668
+ }
669
+ /** A reply or an identity for 200, a reply for the 403 and 502 that carry one; otherwise the error the status names. */
670
+ answer(status, body, command) {
671
+ let parsed;
672
+ try {
673
+ parsed = parseStrict(strictUtf8.decode(body));
674
+ }
675
+ catch {
676
+ parsed = undefined;
677
+ }
678
+ const said = isObject(parsed) && typeof parsed.error === "string" ? parsed.error : undefined;
679
+ // A 403 or a 502 carries a reply only as a refusal (NR3's review, m7).
680
+ const carriesReply = isObject(parsed) && parsed.ok === false;
681
+ const outcome = command ? "unknown" : "not_started";
682
+ if (status === 200) {
683
+ if (parsed === undefined)
684
+ throw new NidlError("protocol_error", "The remote nidl's answer is not JSON.", outcome);
685
+ return parsed;
686
+ }
687
+ if ((status === 403 || status === 502) && command && carriesReply)
688
+ return parsed;
689
+ switch (status) {
690
+ case 401:
691
+ throw new NidlError("unauthorized", "The remote nidl refused the token: it is unknown, revoked or expired.");
692
+ case 429:
693
+ throw new NidlError("throttled", said ?? "Too many failed attempts from this address; wait a minute.");
694
+ case 400:
695
+ case 413:
696
+ throw invalid(said ?? "The remote nidl refused the request.");
697
+ case 503:
698
+ throw new NidlError("unavailable", said ?? "The remote nidl is not serving commands now.");
699
+ case 404:
700
+ throw new NidlError("unsupported", "This nidl's remote port takes no commands; update nidl on that machine.");
701
+ case 403:
702
+ throw new NidlError("not_authorized", said ?? "The remote nidl refused the request.");
703
+ default:
704
+ throw new NidlError("protocol_error", `The remote nidl answered HTTP ${status}.`, outcome);
705
+ }
706
+ }
707
+ untrusted() {
708
+ return new NidlError("untrusted_certificate", `The remote nidl at ${this.authority} did not present the certificate expected of it; nothing was sent. ` +
709
+ "Check the fingerprint `nidl remote status` shows there.");
710
+ }
711
+ /** A TLS connection whose certificate has been checked, before anything is written on it. */
712
+ secured(deadline) {
713
+ const left = timeLeft(deadline);
714
+ return new Promise((resolve, reject) => {
715
+ const socket = tlsConnect({
716
+ host: this.host,
717
+ port: this.port,
718
+ ...(isIP(this.host) === 0 ? { servername: this.host } : {}),
719
+ rejectUnauthorized: this.pin === undefined,
720
+ minVersion: "TLSv1.2",
721
+ ALPNProtocols: ["http/1.1"],
722
+ });
723
+ const timer = setTimeout(() => {
724
+ socket.destroy();
725
+ reject(new NidlError("timeout", "The remote nidl did not accept the connection in time."));
726
+ }, left);
727
+ socket.once("secureConnect", () => {
728
+ clearTimeout(timer);
729
+ if (this.pin !== undefined) {
730
+ const raw = socket.getPeerCertificate().raw;
731
+ if (!raw || !createHash("sha256").update(raw).digest().equals(this.pin)) {
732
+ socket.destroy();
733
+ reject(this.untrusted());
734
+ return;
735
+ }
736
+ }
737
+ // The request's own handlers take over; this one keeps a late error from going unheard.
738
+ socket.on("error", () => { });
739
+ resolve(socket);
740
+ });
741
+ socket.once("error", (error) => {
742
+ clearTimeout(timer);
743
+ const code = errorCode(error) ?? "";
744
+ reject(/CERT|SELF_SIGNED|UNABLE_TO_(GET|VERIFY)|ALTNAME/.test(code)
745
+ ? this.untrusted()
746
+ : new NidlError("unavailable", `The remote nidl at ${this.authority} could not be reached (${code || error.message}).`));
747
+ });
748
+ });
749
+ }
750
+ /** One request on one connection, every step within `deadline`. */
751
+ async exchange(method, path, body, deadline) {
752
+ const socket = await this.secured(deadline);
753
+ const outcome = method === "POST" ? "unknown" : "not_started";
754
+ const left = deadline - Date.now();
755
+ return new Promise((resolve, reject) => {
756
+ let settled = false;
757
+ const settle = (result) => {
758
+ if (settled)
759
+ return;
760
+ settled = true;
761
+ clearTimeout(timer);
762
+ socket.destroy();
763
+ if (result instanceof NidlError)
764
+ reject(result);
765
+ else
766
+ resolve(result);
767
+ };
768
+ const timer = setTimeout(() => settle(new NidlError("timeout", "The remote nidl did not answer in time.", outcome)), Math.max(0, left));
769
+ const request = httpRequest({
770
+ method,
771
+ path,
772
+ host: this.host,
773
+ port: this.port,
774
+ headers: {
775
+ Host: this.authority,
776
+ Authorization: `Bearer ${this.#token}`,
777
+ "User-Agent": `nidl-client-node/${VERSION}`,
778
+ Accept: "application/json",
779
+ "Content-Type": "application/json",
780
+ "Content-Length": String(body?.length ?? 0),
781
+ Connection: "close",
782
+ },
783
+ createConnection: () => socket,
784
+ });
785
+ request.on("response", (response) => {
786
+ // The core always says its answer's length, so one framed only by the connection's end is
787
+ // refused, and a cut-off one is never taken as whole (NR3's review, m6).
788
+ const encoding = response.headers["transfer-encoding"];
789
+ if ((response.headers["content-length"] === undefined && encoding?.toLowerCase() !== "chunked") ||
790
+ (encoding !== undefined && encoding.toLowerCase() !== "chunked")) {
791
+ settle(new NidlError("protocol_error", "The remote nidl's answer is not framed as the core frames it.", outcome));
792
+ return;
793
+ }
794
+ response.on("aborted", () => settle(new NidlError("unavailable", "The connection to the remote nidl was lost.", outcome)));
795
+ const chunks = [];
796
+ let size = 0;
797
+ response.on("data", (chunk) => {
798
+ size += chunk.length;
799
+ if (size > MAX_ANSWER) {
800
+ settle(new NidlError("protocol_error", "The remote nidl's answer is too long.", outcome));
801
+ return;
802
+ }
803
+ chunks.push(chunk);
804
+ });
805
+ response.on("end", () => settle({ status: response.statusCode ?? 0, body: Buffer.concat(chunks) }));
806
+ response.on("error", () => settle(new NidlError("unavailable", "The connection to the remote nidl was lost.", outcome)));
807
+ });
808
+ request.on("error", () => settle(new NidlError("unavailable", "The connection to the remote nidl was lost.", outcome)));
809
+ request.end(body ?? Buffer.alloc(0));
810
+ });
811
+ }
812
+ }
813
+ // CL8 (docs/specs/nidl-client-libraries.md s.11): the names an app may run, from the registry.
814
+ export { COMMAND_NAMES } from "./commands.generated.js";
815
+ // TQ3 (docs/specs/nidl-task-vaults.md D7): a Trigger.dev task's own core.
816
+ export { openTaskCore, startTaskCore, TASK_VAULT_KEY_VARIABLE, TASK_VAULT_VARIABLE, } from "./task.js";
817
+ //# sourceMappingURL=index.js.map