pmtiles-swarm 0.2.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/src/config.js ADDED
@@ -0,0 +1,231 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Defaults. Every one of these can be overridden in the config file, and a few
6
+ * of the deployment-specific ones by environment variables.
7
+ */
8
+ const DEFAULTS = {
9
+ port: 8090,
10
+ host: '0.0.0.0',
11
+ /** Where the catalog, generated .torrent files and keys live. */
12
+ dataDir: './data',
13
+ /** Which SeedEngine to use: 'webtorrent' or 'qbittorrent'. */
14
+ engine: 'webtorrent',
15
+ qbittorrent: {
16
+ url: 'http://127.0.0.1:8080',
17
+ username: undefined,
18
+ password: undefined,
19
+ },
20
+ webtorrent: {
21
+ savePath: './data/torrents-data',
22
+ },
23
+ /**
24
+ * Piece length for torrents we create. 4 MiB is a deliberate compromise:
25
+ * tools default much higher for large files, which is fine for whole-file
26
+ * downloads but terrible for the random access a tile server does, since
27
+ * every cold tile costs a whole piece. Smaller pieces cut that, at the cost
28
+ * of a larger hash list that peers must transfer before any tile is served.
29
+ *
30
+ * Overridable per source and per request, so an archive nobody will read
31
+ * randomly can keep the larger pieces its size would normally suggest.
32
+ *
33
+ * Note this has little bearing on load imposed on network equipment: peers
34
+ * request data in 16 KiB blocks whatever the piece size, so packet volume is
35
+ * unchanged. What strains a consumer router is the number of simultaneous
36
+ * connections holding NAT table entries — see maxConnections.
37
+ */
38
+ pieceLength: 4 * 1024 * 1024,
39
+ /**
40
+ * Cap on simultaneous peer connections.
41
+ *
42
+ * This is the setting that decides how hard the node leans on a router:
43
+ * every peer is a NAT table entry, and cheap consumer hardware starts
44
+ * dropping or stalling connections well before a server would. Lower it if
45
+ * the network misbehaves while seeding.
46
+ */
47
+ maxConnections: 100,
48
+ /** Trackers baked into every torrent we create. */
49
+ trackers: [
50
+ 'udp://tracker.opentrackr.org:1337/announce',
51
+ 'udp://tracker.torrent.eu.org:451/announce',
52
+ ],
53
+ /**
54
+ * Copy every .torrent we create into this directory as well.
55
+ *
56
+ * Most clients, qBittorrent included, can watch a folder and add whatever
57
+ * appears in it. For the single job of "start seeding this", that is simpler
58
+ * and more robust than an API, and it works when the client shares a disk but
59
+ * is not reachable over HTTP.
60
+ */
61
+ torrentDropDir: undefined,
62
+ /** Rights statement for the RSS channel. */
63
+ feedCopyright: undefined,
64
+ /**
65
+ * Most items to include in a feed, newest first. Zero means no limit.
66
+ *
67
+ * Choose it against how often subscribers poll, not how tidy the feed looks:
68
+ * a feed holding a single item is only safe if everyone polls more often than
69
+ * you publish. A consumer that was down overnight would otherwise miss that
70
+ * build entirely, with nothing to indicate it had.
71
+ */
72
+ feedMaxItems: 50,
73
+ /** Scheduled upstreams that publish a new archive per date. See sources.js. */
74
+ sources: [],
75
+ /** How often to poll scheduled sources, in hours. */
76
+ sourceCheckIntervalHours: 6,
77
+ /** Public base URL, used to build absolute links in the RSS feed and TileJSON. */
78
+ publicUrl: undefined,
79
+ /**
80
+ * Trust X-Forwarded-* headers, for running behind a reverse proxy or CDN.
81
+ *
82
+ * Takes anything Express accepts: `true`, a hop count, or a subnet list such
83
+ * as "loopback, 10.0.0.0/8". Off by default, because trusting these headers
84
+ * from an untrusted client lets it claim any protocol or address it likes.
85
+ *
86
+ * Set it when a proxy terminates TLS, or the TileJSON will advertise http://
87
+ * tile URLs that browsers block as mixed content. Setting `publicUrl`
88
+ * instead sidesteps the question entirely.
89
+ */
90
+ trustProxy: false,
91
+ /**
92
+ * Tile serving: a TileJSON endpoint and z/x/y tiles per archive.
93
+ *
94
+ * A node holding a complete copy reads its local file. A node in cache mode
95
+ * reads through the swarm, pulling only the pieces a requested tile lives in
96
+ * — which is what lets a machine with 10 GiB free serve a 700 GiB planet.
97
+ */
98
+ tiles: {
99
+ /**
100
+ * Open archives kept alive at once. Each holds a file descriptor or a
101
+ * torrent reader plus its piece cache, so this bounds both.
102
+ */
103
+ maxOpenArchives: 16,
104
+ /** Header and directory cache entries, shared across every archive. */
105
+ directoryCacheEntries: 200,
106
+ /**
107
+ * Byte budget for the piece cache of one swarm-read archive. Left unset it
108
+ * is sized from the torrent's piece length, which is the safer default: a
109
+ * fixed budget is a trap with 16 MiB pieces, since 64 MiB holds only four.
110
+ */
111
+ pieceCacheBytes: undefined,
112
+ /** How long no read must be in flight before background hydration resumes. */
113
+ hydrateIdleMs: undefined,
114
+ /** How long to wait for one piece before giving up on a tile. */
115
+ pieceTimeoutMs: 120000,
116
+ /** How long to wait for torrent metadata when opening an archive. */
117
+ readyTimeoutMs: 60000,
118
+ },
119
+ /** Folders scanned for new archives: [{ path, category, webSeedBase }]. */
120
+ watch: [],
121
+ /** Feeds to follow: [{ url, mode, category }] where mode is 'mirror' or 'cache'. */
122
+ subscriptions: [],
123
+ /**
124
+ * How often to re-check whether the sources archives were built from have
125
+ * changed, in seconds. Zero disables it. A check is one HEAD request or stat
126
+ * per archive, so this is cheap; it defaults off only because a node that
127
+ * merely joins other people's torrents has nothing to check.
128
+ */
129
+ originCheckIntervalSeconds: 0,
130
+ /**
131
+ * Rebuild an archive automatically when its source changes.
132
+ *
133
+ * Off by default, and guarded when on, because a rebuild re-hashes the
134
+ * archive and for a remote source re-downloads it — potentially hours of
135
+ * transfer started by nobody. Enable it for local build outputs, where the
136
+ * cost is a local read; think harder before enabling it for http sources.
137
+ */
138
+ autoRebuild: {
139
+ enabled: false,
140
+ /** Source types eligible. 'http' means re-downloading the whole archive. */
141
+ sources: ['file'],
142
+ /** Skip anything larger than this. Zero disables the cap. */
143
+ maxBytes: 50 * 1024 * 1024 * 1024,
144
+ /** The source must be unchanged for this long before rebuilding. */
145
+ stabilitySeconds: 300,
146
+ },
147
+ /** How often to poll subscribed feeds, in seconds. */
148
+ subscriptionIntervalSeconds: 900,
149
+ /** Republish interval for BEP 46 records, in seconds. DHT items expire. */
150
+ republishIntervalSeconds: 3600,
151
+ };
152
+
153
+ /**
154
+ * Merges a config object over the defaults, one level deep so nested engine
155
+ * settings can be partially overridden.
156
+ * @param {object} base - Defaults.
157
+ * @param {object} override - User values.
158
+ * @returns {object} - The merged config.
159
+ */
160
+ function merge(base, override) {
161
+ const out = { ...base };
162
+ for (const [key, value] of Object.entries(override ?? {})) {
163
+ if (value === undefined) continue;
164
+ // eslint-disable-next-line security/detect-object-injection -- keys come from a config file the operator controls
165
+ out[key] =
166
+ value && typeof value === 'object' && !Array.isArray(value)
167
+ ? // eslint-disable-next-line security/detect-object-injection -- as above
168
+ merge(base[key] ?? {}, value)
169
+ : value;
170
+ }
171
+ return out;
172
+ }
173
+
174
+ /**
175
+ * Loads configuration from a JSON file, applying defaults and environment
176
+ * overrides. A missing file is fine — the defaults run.
177
+ * @param {string} [configPath] - Path to a JSON config file.
178
+ * @returns {Promise<object>} - The resolved configuration.
179
+ */
180
+ export async function loadConfig(configPath) {
181
+ let fileConfig = {};
182
+ if (configPath) {
183
+ try {
184
+ fileConfig = JSON.parse(await fs.readFile(configPath, 'utf8'));
185
+ } catch (error) {
186
+ if (error.code !== 'ENOENT') {
187
+ throw new Error(
188
+ `could not read config ${configPath}: ${error.message}`,
189
+ { cause: error },
190
+ );
191
+ }
192
+ }
193
+ }
194
+
195
+ const config = merge(DEFAULTS, fileConfig);
196
+
197
+ // Environment overrides, for containerised deployments.
198
+ if (process.env.PMTILES_SWARM_PORT) {
199
+ config.port = Number(process.env.PMTILES_SWARM_PORT);
200
+ }
201
+ if (process.env.PMTILES_SWARM_DATA_DIR) {
202
+ config.dataDir = process.env.PMTILES_SWARM_DATA_DIR;
203
+ }
204
+ if (process.env.PMTILES_SWARM_ENGINE) {
205
+ config.engine = process.env.PMTILES_SWARM_ENGINE;
206
+ }
207
+ if (process.env.PMTILES_SWARM_QBT_URL) {
208
+ config.qbittorrent.url = process.env.PMTILES_SWARM_QBT_URL;
209
+ }
210
+ if (process.env.PMTILES_SWARM_QBT_USERNAME) {
211
+ config.qbittorrent.username = process.env.PMTILES_SWARM_QBT_USERNAME;
212
+ }
213
+ if (process.env.PMTILES_SWARM_QBT_PASSWORD) {
214
+ config.qbittorrent.password = process.env.PMTILES_SWARM_QBT_PASSWORD;
215
+ }
216
+ if (process.env.PMTILES_SWARM_PUBLIC_URL) {
217
+ config.publicUrl = process.env.PMTILES_SWARM_PUBLIC_URL;
218
+ }
219
+
220
+ // Resolve paths relative to the config file, so a config can be moved as a
221
+ // unit with the data it points at.
222
+ const base = configPath ? path.dirname(path.resolve(configPath)) : process.cwd();
223
+ config.dataDir = path.resolve(base, config.dataDir);
224
+ config.webtorrent.savePath = path.resolve(base, config.webtorrent.savePath);
225
+ config.watch = config.watch.map((entry) => ({
226
+ ...entry,
227
+ path: path.resolve(base, entry.path),
228
+ }));
229
+
230
+ return config;
231
+ }
@@ -0,0 +1,384 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createRequire } from 'node:module';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * Locates the libtorrent sidecar script.
7
+ *
8
+ * It lives in pmtiles-torrent rather than here. The two projects used to carry
9
+ * a copy each, which drifted — the read side grew `info` and `priority` ops
10
+ * that this copy never got. Since pmtiles-torrent is now a dependency and
11
+ * ships the script, there is one file again, and the read and seed sides
12
+ * cannot disagree about the protocol they speak over the same pipe.
13
+ * @returns {string} - Absolute path to the sidecar script.
14
+ */
15
+ function resolveSidecar() {
16
+ const require = createRequire(import.meta.url);
17
+ try {
18
+ return path.join(
19
+ path.dirname(require.resolve('pmtiles-torrent/package.json')),
20
+ 'sidecar',
21
+ 'libtorrent_sidecar.py',
22
+ );
23
+ } catch (error) {
24
+ throw new Error(
25
+ 'cannot locate the libtorrent sidecar: pmtiles-torrent is not resolvable. ' +
26
+ `Run npm install, or pass libtorrent.script to point at it. (${error.message})`,
27
+ );
28
+ }
29
+ }
30
+
31
+ /**
32
+ * A SeedEngine backed by libtorrent, through a sidecar process.
33
+ *
34
+ * libtorrent is the only one of the three engines that offers what large-scale
35
+ * map distribution actually wants: BitTorrent v2 and hybrid torrents, resume
36
+ * data so a restart does not re-hash the store, piece-level control for
37
+ * on-demand reads, and seeding that holds up at multi-terabyte scale.
38
+ *
39
+ * It reaches it through a child process rather than a native binding because
40
+ * Node has no maintained libtorrent binding — the packages on npm are
41
+ * abandoned 2022 stubs, and the one live fork exposes neither piece deadlines
42
+ * nor v2. A sidecar also keeps the install honest: one distro package rather
43
+ * than a C++ toolchain plus Boost.
44
+ *
45
+ * The protocol is line-delimited JSON, so this class is unchanged if the other
46
+ * end is later replaced by a real N-API addon.
47
+ */
48
+ export class LibtorrentEngine {
49
+ #options;
50
+ #child = null;
51
+ #pending = new Map();
52
+ #nextId = 1;
53
+ #buffer = '';
54
+ #ready = null;
55
+ #version = null;
56
+
57
+ /**
58
+ * Creates the engine.
59
+ * @param {object} options - Engine options.
60
+ * @param {string} options.savePath - Default directory for torrent data.
61
+ * @param {string} [options.resumeDir] - Where resume data is kept.
62
+ * @param {string} [options.python] - Python executable. Default 'python3'.
63
+ * @param {string} [options.script] - Override the sidecar script path.
64
+ * @param {string} [options.listen] - Listen interfaces, e.g. '0.0.0.0:6881'.
65
+ * @param {number} [options.startTimeoutMs] - How long to wait for the sidecar. Default 20s.
66
+ */
67
+ constructor(options) {
68
+ if (!options?.savePath) {
69
+ throw new Error('libtorrent engine requires a savePath');
70
+ }
71
+ this.name = 'libtorrent';
72
+ this.#options = { python: 'python3', startTimeoutMs: 20000, ...options };
73
+ }
74
+
75
+ /** @returns {string | null} - libtorrent version, once connected. */
76
+ get version() {
77
+ return this.#version;
78
+ }
79
+
80
+ /**
81
+ * Starts the sidecar and waits for it to report readiness.
82
+ * @returns {Promise<void>} - Resolves once usable.
83
+ */
84
+ connect() {
85
+ if (this.#ready) return this.#ready;
86
+
87
+ this.#ready = new Promise((resolve, reject) => {
88
+ const script = this.#options.script ?? resolveSidecar();
89
+
90
+ const child = spawn(this.#options.python, [script], {
91
+ stdio: ['pipe', 'pipe', 'pipe'],
92
+ env: {
93
+ ...process.env,
94
+ SIDECAR_SETTINGS: JSON.stringify({
95
+ listen: this.#options.listen,
96
+ resumeDir: this.#options.resumeDir,
97
+ maxConnections: this.#options.maxConnections,
98
+ dht: this.#options.dht,
99
+ uploadLimit: this.#options.uploadLimit,
100
+ downloadLimit: this.#options.downloadLimit,
101
+ }),
102
+ },
103
+ });
104
+ this.#child = child;
105
+
106
+ const timer = setTimeout(() => {
107
+ reject(
108
+ new Error(
109
+ `libtorrent sidecar did not start within ${this.#options.startTimeoutMs}ms`,
110
+ ),
111
+ );
112
+ child.kill();
113
+ }, this.#options.startTimeoutMs);
114
+
115
+ child.stdout.setEncoding('utf8');
116
+ child.stdout.on('data', (chunk) => {
117
+ this.#buffer += chunk;
118
+ let newline;
119
+ while ((newline = this.#buffer.indexOf('\n')) >= 0) {
120
+ const line = this.#buffer.slice(0, newline).trim();
121
+ this.#buffer = this.#buffer.slice(newline + 1);
122
+ if (!line) continue;
123
+
124
+ let message;
125
+ try {
126
+ message = JSON.parse(line);
127
+ } catch {
128
+ console.error(`[libtorrent] unparseable output: ${line}`);
129
+ continue;
130
+ }
131
+
132
+ if (message.event === 'ready') {
133
+ clearTimeout(timer);
134
+ this.#version = message.libtorrent;
135
+ resolve();
136
+ continue;
137
+ }
138
+ this.#settle(message);
139
+ }
140
+ });
141
+
142
+ // The sidecar reports missing bindings and tracebacks on stderr; those
143
+ // are the errors an operator most needs to see.
144
+ child.stderr.setEncoding('utf8');
145
+ child.stderr.on('data', (text) => {
146
+ for (const line of text.split('\n')) {
147
+ if (line.trim()) console.error(`[libtorrent] ${line}`);
148
+ }
149
+ });
150
+
151
+ child.on('error', (error) => {
152
+ clearTimeout(timer);
153
+ reject(
154
+ new Error(
155
+ `could not start ${this.#options.python}: ${error.message}. ` +
156
+ 'Install python3 and libtorrent (apt install python3-libtorrent).',
157
+ { cause: error },
158
+ ),
159
+ );
160
+ });
161
+
162
+ child.on('exit', (code) => {
163
+ clearTimeout(timer);
164
+ this.#child = null;
165
+ const error = new Error(`libtorrent sidecar exited (code ${code})`);
166
+ for (const { reject: fail } of this.#pending.values()) fail(error);
167
+ this.#pending.clear();
168
+ reject(error);
169
+ });
170
+ });
171
+
172
+ return this.#ready;
173
+ }
174
+
175
+ /**
176
+ * Adds a torrent.
177
+ * @param {import('./types.js').AddRequest} request - What to add.
178
+ * @returns {Promise<string>} - The infohash.
179
+ */
180
+ async add(request) {
181
+ const result = await this.#call('add', {
182
+ torrentFile: request.torrentFile
183
+ ? Buffer.from(request.torrentFile).toString('base64')
184
+ : undefined,
185
+ magnet: request.magnet,
186
+ savePath: request.savePath ?? this.#options.savePath,
187
+ mode: request.mode,
188
+ paused: request.paused,
189
+ });
190
+ return result.infoHash;
191
+ }
192
+
193
+ /**
194
+ * Removes a torrent.
195
+ * @param {string} infoHash - The torrent to remove.
196
+ * @param {{deleteData?: boolean}} [options] - Whether to delete data too.
197
+ * @returns {Promise<void>} - Resolves once removed.
198
+ */
199
+ async remove(infoHash, options = {}) {
200
+ await this.#call('remove', {
201
+ infoHash,
202
+ deleteData: Boolean(options.deleteData),
203
+ });
204
+ }
205
+
206
+ /**
207
+ * Lists every torrent in the session.
208
+ * @returns {Promise<import('./types.js').TorrentStatus[]>} - Statuses.
209
+ */
210
+ async list() {
211
+ return this.#call('list', {});
212
+ }
213
+
214
+ /**
215
+ * One torrent's state.
216
+ * @param {string} infoHash - The torrent to look up.
217
+ * @returns {Promise<import('./types.js').TorrentStatus | null>} - Its status.
218
+ */
219
+ async get(infoHash) {
220
+ return this.#call('get', { infoHash });
221
+ }
222
+
223
+ /**
224
+ * Per-peer detail.
225
+ * @param {string} infoHash - The torrent to inspect.
226
+ * @returns {Promise<object[]>} - One entry per peer.
227
+ */
228
+ async peers(infoHash) {
229
+ return this.#call('peers', { infoHash });
230
+ }
231
+
232
+ /**
233
+ * Creates a torrent from a local file.
234
+ *
235
+ * Defaults to hybrid v1+v2, which is the capability that justifies this
236
+ * engine: v2 gives per-file merkle trees with 16 KiB leaf blocks, so a peer
237
+ * can verify a small block without holding the whole hash list, while the v1
238
+ * half keeps every existing client working.
239
+ * @param {string} filePath - Path to the archive.
240
+ * @param {object} [options] - Piece length, trackers, web seeds, format.
241
+ * @returns {Promise<object>} - The created torrent, torrentFile as bytes.
242
+ */
243
+ async createTorrent(filePath, options = {}) {
244
+ const result = await this.#call(
245
+ 'create',
246
+ {
247
+ path: filePath,
248
+ pieceLength: options.pieceLength,
249
+ trackers: options.trackers ?? [],
250
+ webSeeds: options.webSeeds ?? [],
251
+ comment: options.comment,
252
+ format: options.format ?? 'hybrid',
253
+ },
254
+ // Hashing a large archive takes as long as it takes.
255
+ options.timeoutMs ?? 6 * 60 * 60 * 1000,
256
+ );
257
+ return {
258
+ ...result,
259
+ torrentFile: new Uint8Array(Buffer.from(result.torrentFile, 'base64')),
260
+ };
261
+ }
262
+
263
+ /**
264
+ * Reads one piece, promoted ahead of the normal picker.
265
+ *
266
+ * This is the primitive on-demand tile serving wants, and the reason
267
+ * qBittorrent cannot do cache mode properly — its WebUI has no equivalent.
268
+ * @param {string} infoHash - The torrent.
269
+ * @param {number} piece - Piece index.
270
+ * @param {object} [options] - Deadline and timeout.
271
+ * @returns {Promise<Uint8Array>} - The piece contents.
272
+ */
273
+ async readPiece(infoHash, piece, options = {}) {
274
+ const result = await this.#call(
275
+ 'read_piece',
276
+ {
277
+ infoHash,
278
+ piece,
279
+ deadlineMs: options.deadlineMs,
280
+ timeoutMs: options.timeoutMs,
281
+ },
282
+ (options.timeoutMs ?? 60000) + 5000,
283
+ );
284
+ return new Uint8Array(Buffer.from(result.data, 'base64'));
285
+ }
286
+
287
+ /**
288
+ * Reports the piece geometry a reader needs to map byte ranges onto pieces.
289
+ *
290
+ * Note the two coordinate systems: `pieceLength` and `numPieces` describe the
291
+ * torrent's global byte space, while `fileOffset` locates the archive inside
292
+ * it. A single-file torrent has a zero offset; a multi-file one does not, and
293
+ * getting that wrong reads the neighbouring file.
294
+ * @param {string} infoHash - The torrent.
295
+ * @param {number} [fileIndex] - Which file in a multi-file torrent.
296
+ * @returns {Promise<object>} - {infoHash, pieceLength, numPieces, fileLength, fileOffset, name}.
297
+ */
298
+ async info(infoHash, fileIndex = 0) {
299
+ return this.#call('info', { infoHash, fileIndex });
300
+ }
301
+
302
+ /**
303
+ * Sets the download priority of a piece range.
304
+ *
305
+ * Zero means "do not fetch", which is how cache mode avoids pulling an entire
306
+ * archive while still seeding what it holds.
307
+ * @param {string} infoHash - The torrent.
308
+ * @param {number} first - First piece index, inclusive.
309
+ * @param {number} last - Last piece index, inclusive.
310
+ * @param {number} priority - libtorrent piece priority, 0 to 7.
311
+ * @returns {Promise<void>} - Resolves once applied.
312
+ */
313
+ async setPriority(infoHash, first, last, priority) {
314
+ await this.#call('set_priority', { infoHash, first, last, priority });
315
+ }
316
+
317
+ /**
318
+ * Persists resume data, so the next start skips re-hashing the store.
319
+ * @param {string} [infoHash] - One torrent, or all when omitted.
320
+ * @returns {Promise<void>} - Resolves once saved.
321
+ */
322
+ async saveResume(infoHash) {
323
+ await this.#call('save_resume', { infoHash });
324
+ }
325
+
326
+ /**
327
+ * Saves resume data and stops the sidecar.
328
+ * @returns {Promise<void>} - Resolves once stopped.
329
+ */
330
+ async destroy() {
331
+ if (!this.#child) return;
332
+ await this.#call('shutdown', {}, 15000).catch(() => {});
333
+ this.#child?.kill();
334
+ this.#child = null;
335
+ this.#ready = null;
336
+ }
337
+
338
+ /**
339
+ * Sends a request and waits for its reply.
340
+ * @param {string} op - Operation name.
341
+ * @param {object} params - Operation parameters.
342
+ * @param {number} [timeoutMs] - How long to wait. Default 60s.
343
+ * @returns {Promise<any>} - The result.
344
+ */
345
+ async #call(op, params, timeoutMs = 60000) {
346
+ if (op !== 'shutdown') await this.connect();
347
+ const child = this.#child;
348
+ if (!child) throw new Error('libtorrent sidecar is not running');
349
+
350
+ const id = this.#nextId++;
351
+ return new Promise((resolve, reject) => {
352
+ const timer = setTimeout(() => {
353
+ this.#pending.delete(id);
354
+ reject(new Error(`libtorrent ${op} timed out after ${timeoutMs}ms`));
355
+ }, timeoutMs);
356
+
357
+ this.#pending.set(id, {
358
+ resolve: (value) => {
359
+ clearTimeout(timer);
360
+ resolve(value);
361
+ },
362
+ reject: (error) => {
363
+ clearTimeout(timer);
364
+ reject(error);
365
+ },
366
+ });
367
+
368
+ child.stdin.write(`${JSON.stringify({ id, op, params })}\n`);
369
+ });
370
+ }
371
+
372
+ /**
373
+ * Routes a reply to whoever is waiting for it.
374
+ * @param {object} message - A decoded reply.
375
+ * @returns {void}
376
+ */
377
+ #settle(message) {
378
+ const waiter = this.#pending.get(message.id);
379
+ if (!waiter) return;
380
+ this.#pending.delete(message.id);
381
+ if (message.ok) waiter.resolve(message.result);
382
+ else waiter.reject(new Error(message.error ?? 'unknown sidecar error'));
383
+ }
384
+ }