@relayfile/client 0.10.26 → 0.10.27-rc.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/client.d.ts +14 -4
- package/dist/client.js +176 -14
- package/dist/generated/control-plane.d.ts +5 -2
- package/package.json +1 -1
package/dist/client.d.ts
CHANGED
|
@@ -13,9 +13,8 @@ import type { components } from './generated/control-plane.js';
|
|
|
13
13
|
/** Control-plane API version this client speaks. Bump when adding required endpoint support. */
|
|
14
14
|
export declare const RELAYFILE_API_VERSION = 3;
|
|
15
15
|
/**
|
|
16
|
-
* Minimum `relayfile` daemon version this client requires.
|
|
17
|
-
*
|
|
18
|
-
* this — API compatibility is enforced by RELAYFILE_API_VERSION.
|
|
16
|
+
* Minimum `relayfile` daemon version this client requires. API compatibility is
|
|
17
|
+
* enforced independently through supportedApiVersions.
|
|
19
18
|
*/
|
|
20
19
|
export declare const MIN_RELAYFILE_VERSION = "0.10.17";
|
|
21
20
|
/** First semver-looking token in a string (tolerates a leading `v` and surrounding words). */
|
|
@@ -34,7 +33,11 @@ export declare function defaultRelayfileSocketPath(): string;
|
|
|
34
33
|
export declare class RelayfileControlPlaneError extends Error {
|
|
35
34
|
readonly code: string;
|
|
36
35
|
readonly status?: number | undefined;
|
|
37
|
-
|
|
36
|
+
/** True only for connection failures that may clear while a daemon starts. */
|
|
37
|
+
readonly transient: boolean;
|
|
38
|
+
constructor(code: string, message: string, status?: number | undefined,
|
|
39
|
+
/** True only for connection failures that may clear while a daemon starts. */
|
|
40
|
+
transient?: boolean);
|
|
38
41
|
}
|
|
39
42
|
type Schemas = components['schemas'];
|
|
40
43
|
export type HelloResponse = Schemas['HelloResponse'];
|
|
@@ -80,7 +83,14 @@ export declare class RelayfileControlPlaneClient {
|
|
|
80
83
|
/** Connect (auto-starting the daemon if configured), version-negotiating once. */
|
|
81
84
|
ensureReady(): Promise<void>;
|
|
82
85
|
private connectAndNegotiate;
|
|
86
|
+
private spawnDaemon;
|
|
83
87
|
private startDaemonAndConnect;
|
|
88
|
+
private stopStaleDaemon;
|
|
89
|
+
private versionMismatchError;
|
|
90
|
+
private versionRejectionError;
|
|
91
|
+
private restartCommand;
|
|
92
|
+
private installedBinaryVersion;
|
|
93
|
+
private runCommand;
|
|
84
94
|
hello(): Promise<HelloResponse>;
|
|
85
95
|
resolvePath(provider: string, resource: string): Promise<ResolvePathResult>;
|
|
86
96
|
bind(input: BindRequestBody): Promise<BindResult>;
|
package/dist/client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
2
3
|
import { request } from 'node:http';
|
|
3
4
|
import { tmpdir } from 'node:os';
|
|
4
5
|
import { join } from 'node:path';
|
|
@@ -16,11 +17,12 @@ import { join } from 'node:path';
|
|
|
16
17
|
/** Control-plane API version this client speaks. Bump when adding required endpoint support. */
|
|
17
18
|
export const RELAYFILE_API_VERSION = 3;
|
|
18
19
|
/**
|
|
19
|
-
* Minimum `relayfile` daemon version this client requires.
|
|
20
|
-
*
|
|
21
|
-
* this — API compatibility is enforced by RELAYFILE_API_VERSION.
|
|
20
|
+
* Minimum `relayfile` daemon version this client requires. API compatibility is
|
|
21
|
+
* enforced independently through supportedApiVersions.
|
|
22
22
|
*/
|
|
23
23
|
export const MIN_RELAYFILE_VERSION = '0.10.17';
|
|
24
|
+
/** First published relayfile binary that can replace a stale daemon for API v3. */
|
|
25
|
+
const MIN_RELAYFILE_VERSION_FOR_CURRENT_API = '0.10.21';
|
|
24
26
|
const SEMVER_RE = /^\d+\.\d+\.\d+(?:[-+].*)?$/;
|
|
25
27
|
/** First semver-looking token in a string (tolerates a leading `v` and surrounding words). */
|
|
26
28
|
export function firstSemver(value) {
|
|
@@ -68,14 +70,28 @@ function relayfileBinary() {
|
|
|
68
70
|
export class RelayfileControlPlaneError extends Error {
|
|
69
71
|
code;
|
|
70
72
|
status;
|
|
71
|
-
|
|
73
|
+
transient;
|
|
74
|
+
constructor(code, message, status,
|
|
75
|
+
/** True only for connection failures that may clear while a daemon starts. */
|
|
76
|
+
transient = false) {
|
|
72
77
|
super(message);
|
|
73
78
|
this.code = code;
|
|
74
79
|
this.status = status;
|
|
80
|
+
this.transient = transient;
|
|
75
81
|
this.name = 'RelayfileControlPlaneError';
|
|
76
82
|
}
|
|
77
83
|
}
|
|
78
84
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
85
|
+
const TRANSIENT_CONNECTION_CODES = new Set(['ENOENT', 'ECONNREFUSED', 'ETIMEDOUT']);
|
|
86
|
+
function isTransientConnectionCode(code) {
|
|
87
|
+
return code != null && TRANSIENT_CONNECTION_CODES.has(code);
|
|
88
|
+
}
|
|
89
|
+
function isTransientControlPlaneError(err) {
|
|
90
|
+
return (err instanceof RelayfileControlPlaneError && err.code === 'DAEMON_UNAVAILABLE' && err.transient);
|
|
91
|
+
}
|
|
92
|
+
function shellQuote(value) {
|
|
93
|
+
return /^[A-Za-z0-9_./:@%+=,-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
94
|
+
}
|
|
79
95
|
export class RelayfileControlPlaneClient {
|
|
80
96
|
socketPath;
|
|
81
97
|
binary;
|
|
@@ -119,8 +135,13 @@ export class RelayfileControlPlaneClient {
|
|
|
119
135
|
method: opts.method,
|
|
120
136
|
path,
|
|
121
137
|
timeout: this.requestTimeoutMs,
|
|
138
|
+
// Discovery must open a fresh socket. A pooled keep-alive connection
|
|
139
|
+
// would keep reaching the unlinked stale daemon after replacement.
|
|
140
|
+
agent: opts.freshConnection ? false : undefined,
|
|
122
141
|
headers: {
|
|
123
|
-
|
|
142
|
+
...(opts.includeVersionHeader === false
|
|
143
|
+
? {}
|
|
144
|
+
: { 'X-Relayfile-API-Version': String(RELAYFILE_API_VERSION) }),
|
|
124
145
|
Accept: 'application/json',
|
|
125
146
|
...(payload
|
|
126
147
|
? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }
|
|
@@ -129,7 +150,7 @@ export class RelayfileControlPlaneClient {
|
|
|
129
150
|
}, (res) => {
|
|
130
151
|
// The response stream can emit 'error' (e.g. socket reset mid-body);
|
|
131
152
|
// without a listener that's an unhandled error -> crash.
|
|
132
|
-
res.on('error', (err) => fail(new RelayfileControlPlaneError('DAEMON_UNAVAILABLE', err.message)));
|
|
153
|
+
res.on('error', (err) => fail(new RelayfileControlPlaneError('DAEMON_UNAVAILABLE', err.message, undefined, isTransientConnectionCode(err.code))));
|
|
133
154
|
const chunks = [];
|
|
134
155
|
res.on('data', (c) => chunks.push(c));
|
|
135
156
|
res.on('end', () => {
|
|
@@ -152,7 +173,7 @@ export class RelayfileControlPlaneClient {
|
|
|
152
173
|
});
|
|
153
174
|
});
|
|
154
175
|
req.on('timeout', () => {
|
|
155
|
-
req.destroy(new RelayfileControlPlaneError('DAEMON_UNAVAILABLE', `relayfile control-plane request timed out after ${this.requestTimeoutMs}ms (${opts.method} ${opts.path})
|
|
176
|
+
req.destroy(new RelayfileControlPlaneError('DAEMON_UNAVAILABLE', `relayfile control-plane request timed out after ${this.requestTimeoutMs}ms (${opts.method} ${opts.path})`, undefined, true));
|
|
156
177
|
});
|
|
157
178
|
req.on('error', (err) => {
|
|
158
179
|
if (err instanceof RelayfileControlPlaneError) {
|
|
@@ -162,7 +183,7 @@ export class RelayfileControlPlaneClient {
|
|
|
162
183
|
// ENOENT (no socket file) / ECONNREFUSED (nothing listening) => daemon down.
|
|
163
184
|
fail(new RelayfileControlPlaneError('DAEMON_UNAVAILABLE', err.code === 'ENOENT' || err.code === 'ECONNREFUSED'
|
|
164
185
|
? `relayfile control-plane is not running at ${this.socketPath} (${err.code})`
|
|
165
|
-
: err.message, undefined));
|
|
186
|
+
: err.message, undefined, isTransientConnectionCode(err.code)));
|
|
166
187
|
});
|
|
167
188
|
if (payload)
|
|
168
189
|
req.write(payload);
|
|
@@ -186,7 +207,10 @@ export class RelayfileControlPlaneClient {
|
|
|
186
207
|
hello = await this.hello();
|
|
187
208
|
}
|
|
188
209
|
catch (err) {
|
|
189
|
-
if (
|
|
210
|
+
if (err instanceof RelayfileControlPlaneError && err.code === 'VERSION_INCOMPATIBLE') {
|
|
211
|
+
throw await this.versionRejectionError(err);
|
|
212
|
+
}
|
|
213
|
+
if (!isTransientControlPlaneError(err))
|
|
190
214
|
throw err;
|
|
191
215
|
if (!this.autoStart) {
|
|
192
216
|
throw new RelayfileControlPlaneError('DAEMON_UNAVAILABLE', `relayfile control-plane is not running at ${this.socketPath}. ` +
|
|
@@ -195,15 +219,34 @@ export class RelayfileControlPlaneClient {
|
|
|
195
219
|
hello = await this.startDaemonAndConnect();
|
|
196
220
|
}
|
|
197
221
|
if (!hello.supportedApiVersions?.includes(RELAYFILE_API_VERSION)) {
|
|
198
|
-
|
|
222
|
+
const installedVersion = await this.installedBinaryVersion();
|
|
223
|
+
const daemonVersion = firstSemver(hello.daemonVersion);
|
|
224
|
+
const canReplaceStaleDaemon = this.autoStart &&
|
|
225
|
+
installedVersion != null &&
|
|
226
|
+
daemonVersion !== '' &&
|
|
227
|
+
compareSemver(installedVersion, MIN_RELAYFILE_VERSION_FOR_CURRENT_API) >= 0 &&
|
|
228
|
+
compareSemver(installedVersion, daemonVersion) > 0;
|
|
229
|
+
if (!canReplaceStaleDaemon) {
|
|
230
|
+
throw this.versionMismatchError(hello, installedVersion);
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
await this.stopStaleDaemon();
|
|
234
|
+
hello = await this.startDaemonAndConnect(installedVersion);
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
if (err instanceof RelayfileControlPlaneError && err.code === 'VERSION_INCOMPATIBLE') {
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
throw this.versionMismatchError(hello, installedVersion, `Automatic stale-daemon replacement failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
241
|
+
}
|
|
199
242
|
}
|
|
200
243
|
assertRelayfileVersion(hello.daemonVersion);
|
|
201
244
|
}
|
|
202
|
-
|
|
203
|
-
let child;
|
|
245
|
+
spawnDaemon() {
|
|
204
246
|
// A missing binary / bad RELAYFILE_BIN surfaces as an async 'error' event on
|
|
205
247
|
// the child (ENOENT), not a throw from spawn().
|
|
206
248
|
let spawnError;
|
|
249
|
+
let child;
|
|
207
250
|
try {
|
|
208
251
|
child = spawn(this.binary, ['control-plane', 'serve', '--sock', this.socketPath], {
|
|
209
252
|
detached: true,
|
|
@@ -212,33 +255,152 @@ export class RelayfileControlPlaneClient {
|
|
|
212
255
|
child.on('error', (err) => {
|
|
213
256
|
spawnError = err;
|
|
214
257
|
});
|
|
258
|
+
child.once('exit', (code, signal) => {
|
|
259
|
+
spawnError = new Error(`relayfile control-plane exited before readiness (${signal ? `signal ${signal}` : `code ${code}`})`);
|
|
260
|
+
});
|
|
215
261
|
}
|
|
216
262
|
catch (err) {
|
|
217
263
|
throw new RelayfileControlPlaneError('DAEMON_UNAVAILABLE', `failed to start relayfile control-plane (${err instanceof Error ? err.message : String(err)}). ` +
|
|
218
264
|
`Install relayfile or set RELAYFILE_BIN.`);
|
|
219
265
|
}
|
|
220
266
|
child.unref?.();
|
|
267
|
+
return { getError: () => spawnError };
|
|
268
|
+
}
|
|
269
|
+
async startDaemonAndConnect(installedVersion) {
|
|
270
|
+
const spawned = this.spawnDaemon();
|
|
221
271
|
const deadline = Date.now() + this.startTimeoutMs;
|
|
222
272
|
let lastErr;
|
|
223
273
|
while (Date.now() < deadline) {
|
|
274
|
+
const spawnError = spawned.getError();
|
|
224
275
|
if (spawnError) {
|
|
225
276
|
throw new RelayfileControlPlaneError('DAEMON_UNAVAILABLE', `failed to start relayfile control-plane (${spawnError.message}). ` +
|
|
226
277
|
`Install relayfile or set RELAYFILE_BIN.`);
|
|
227
278
|
}
|
|
228
279
|
await sleep(100);
|
|
280
|
+
let hello;
|
|
229
281
|
try {
|
|
230
|
-
|
|
282
|
+
hello = await this.hello();
|
|
231
283
|
}
|
|
232
284
|
catch (err) {
|
|
285
|
+
if (err instanceof RelayfileControlPlaneError && err.code === 'VERSION_INCOMPATIBLE') {
|
|
286
|
+
throw await this.versionRejectionError(err, installedVersion);
|
|
287
|
+
}
|
|
288
|
+
if (!isTransientControlPlaneError(err))
|
|
289
|
+
throw err;
|
|
233
290
|
lastErr = err;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (hello.supportedApiVersions?.includes(RELAYFILE_API_VERSION)) {
|
|
294
|
+
return hello;
|
|
234
295
|
}
|
|
296
|
+
const mismatch = this.versionMismatchError(hello, installedVersion ?? (await this.installedBinaryVersion()));
|
|
297
|
+
throw mismatch;
|
|
235
298
|
}
|
|
236
299
|
throw new RelayfileControlPlaneError('DAEMON_UNAVAILABLE', `relayfile control-plane did not become ready within ${this.startTimeoutMs}ms ` +
|
|
237
300
|
`(${lastErr instanceof Error ? lastErr.message : String(lastErr)}).`);
|
|
238
301
|
}
|
|
302
|
+
async stopStaleDaemon() {
|
|
303
|
+
const result = await this.runCommand('lsof', ['-t', '--', this.socketPath], 1000);
|
|
304
|
+
const pids = [
|
|
305
|
+
...new Set((result?.code === 0 ? result.stdout : '')
|
|
306
|
+
.split(/\s+/)
|
|
307
|
+
.map((value) => Number.parseInt(value, 10))
|
|
308
|
+
.filter((pid) => Number.isSafeInteger(pid) && pid > 1 && pid !== process.pid)),
|
|
309
|
+
];
|
|
310
|
+
if (pids.length !== 1) {
|
|
311
|
+
throw new Error(pids.length === 0
|
|
312
|
+
? `could not identify the process serving ${this.socketPath}`
|
|
313
|
+
: `refusing to stop multiple processes serving ${this.socketPath}: ${pids.join(', ')}`);
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
process.kill(pids[0], 'SIGTERM');
|
|
317
|
+
}
|
|
318
|
+
catch (err) {
|
|
319
|
+
throw new Error(`could not stop stale relayfile control-plane pid ${pids[0]}: ${err instanceof Error ? err.message : String(err)}`);
|
|
320
|
+
}
|
|
321
|
+
const deadline = Date.now() + this.startTimeoutMs;
|
|
322
|
+
while (existsSync(this.socketPath) && Date.now() < deadline) {
|
|
323
|
+
await sleep(50);
|
|
324
|
+
}
|
|
325
|
+
if (existsSync(this.socketPath)) {
|
|
326
|
+
throw new Error(`stale relayfile control-plane pid ${pids[0]} did not release ${this.socketPath} within ${this.startTimeoutMs}ms`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
versionMismatchError(hello, installedVersion, detail) {
|
|
330
|
+
const supported = hello.supportedApiVersions?.length
|
|
331
|
+
? hello.supportedApiVersions.map((version) => `v${version}`).join(', ')
|
|
332
|
+
: 'none reported';
|
|
333
|
+
return new RelayfileControlPlaneError('VERSION_INCOMPATIBLE', `relayfile daemon ${hello.daemonVersion} speaks API v${hello.apiVersion} ` +
|
|
334
|
+
`(supports ${supported}); this client requires API v${RELAYFILE_API_VERSION}. ` +
|
|
335
|
+
`Installed relayfile binary: ${installedVersion ?? 'version unknown'}. ` +
|
|
336
|
+
`${detail ? `${detail} ` : ''}` +
|
|
337
|
+
`Upgrade with \`npm install -g relayfile@latest\`, then restart the running control-plane with ` +
|
|
338
|
+
`\`${this.restartCommand()}\`.`);
|
|
339
|
+
}
|
|
340
|
+
async versionRejectionError(err, installedVersion) {
|
|
341
|
+
installedVersion ??= await this.installedBinaryVersion();
|
|
342
|
+
const spoken = err.message.match(/speaks API v(\d+)/i)?.[1];
|
|
343
|
+
return new RelayfileControlPlaneError('VERSION_INCOMPATIBLE', `relayfile daemon version unknown (discovery hello was rejected) speaks API ` +
|
|
344
|
+
`${spoken ? `v${spoken}` : 'version unknown'} (supported versions were not reported); ` +
|
|
345
|
+
`this client requires API v${RELAYFILE_API_VERSION}. Installed relayfile binary: ` +
|
|
346
|
+
`${installedVersion ?? 'version unknown'}. Upgrade with \`npm install -g relayfile@latest\`, ` +
|
|
347
|
+
`then restart the running control-plane with \`${this.restartCommand()}\`. ` +
|
|
348
|
+
`Daemon response: ${err.message}`, err.status);
|
|
349
|
+
}
|
|
350
|
+
restartCommand() {
|
|
351
|
+
const socket = shellQuote(this.socketPath);
|
|
352
|
+
return (`kill $(lsof -t -- ${socket}) && ` +
|
|
353
|
+
`${shellQuote(this.binary)} control-plane serve --sock ${socket}`);
|
|
354
|
+
}
|
|
355
|
+
async installedBinaryVersion() {
|
|
356
|
+
const result = await this.runCommand(this.binary, ['--version'], Math.min(this.requestTimeoutMs, 2000));
|
|
357
|
+
return firstSemver(`${result?.stdout ?? ''} ${result?.stderr ?? ''}`) || undefined;
|
|
358
|
+
}
|
|
359
|
+
runCommand(command, args, timeoutMs) {
|
|
360
|
+
return new Promise((resolve) => {
|
|
361
|
+
let child;
|
|
362
|
+
try {
|
|
363
|
+
child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
resolve(undefined);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
let stdout = '';
|
|
370
|
+
let stderr = '';
|
|
371
|
+
let settled = false;
|
|
372
|
+
let timeout;
|
|
373
|
+
const finish = (result) => {
|
|
374
|
+
if (settled)
|
|
375
|
+
return;
|
|
376
|
+
settled = true;
|
|
377
|
+
if (timeout)
|
|
378
|
+
clearTimeout(timeout);
|
|
379
|
+
resolve(result);
|
|
380
|
+
};
|
|
381
|
+
child.stdout?.on('data', (chunk) => {
|
|
382
|
+
stdout += String(chunk);
|
|
383
|
+
});
|
|
384
|
+
child.stderr?.on('data', (chunk) => {
|
|
385
|
+
stderr += String(chunk);
|
|
386
|
+
});
|
|
387
|
+
child.once('error', () => finish());
|
|
388
|
+
child.once('close', (code) => finish({ stdout, stderr, code }));
|
|
389
|
+
timeout = setTimeout(() => {
|
|
390
|
+
child.kill();
|
|
391
|
+
finish();
|
|
392
|
+
}, timeoutMs);
|
|
393
|
+
timeout.unref?.();
|
|
394
|
+
});
|
|
395
|
+
}
|
|
239
396
|
// ── endpoints ──────────────────────────────────────────────────────────────
|
|
240
397
|
hello() {
|
|
241
|
-
return this.rawRequest({
|
|
398
|
+
return this.rawRequest({
|
|
399
|
+
method: 'GET',
|
|
400
|
+
path: '/v1/hello',
|
|
401
|
+
includeVersionHeader: false,
|
|
402
|
+
freshConnection: true,
|
|
403
|
+
});
|
|
242
404
|
}
|
|
243
405
|
resolvePath(provider, resource) {
|
|
244
406
|
return this.request({
|
|
@@ -10,7 +10,11 @@ export interface paths {
|
|
|
10
10
|
path?: never;
|
|
11
11
|
cookie?: never;
|
|
12
12
|
};
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Negotiate control-plane API compatibility
|
|
15
|
+
* @description Unversioned discovery endpoint. The daemon always returns its current
|
|
16
|
+
* and supported API versions; any version header or query is ignored.
|
|
17
|
+
*/
|
|
14
18
|
get: operations["hello"];
|
|
15
19
|
put?: never;
|
|
16
20
|
/** Negotiate control-plane API compatibility */
|
|
@@ -396,7 +400,6 @@ export interface operations {
|
|
|
396
400
|
"application/json": components["schemas"]["HelloResponse"];
|
|
397
401
|
};
|
|
398
402
|
};
|
|
399
|
-
426: components["responses"]["VersionIncompatible"];
|
|
400
403
|
};
|
|
401
404
|
};
|
|
402
405
|
helloPost: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@relayfile/client",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.27-rc.0",
|
|
4
4
|
"description": "Typed client for the relayfile local control-plane (unix-socket HTTP/JSON), generated from the authoritative OpenAPI contract",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|