@relayfile/client 0.10.26 → 0.10.27
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 +18 -4
- package/dist/client.js +260 -16
- package/dist/generated/control-plane.d.ts +10 -9
- 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'];
|
|
@@ -75,12 +78,23 @@ export declare class RelayfileControlPlaneClient {
|
|
|
75
78
|
private readonly requestTimeoutMs;
|
|
76
79
|
private ready;
|
|
77
80
|
constructor(options?: RelayfileClientOptions);
|
|
81
|
+
private createHTTPRequest;
|
|
78
82
|
/** Low-level request over the unix socket. Throws RelayfileControlPlaneError. */
|
|
79
83
|
private rawRequest;
|
|
80
84
|
/** Connect (auto-starting the daemon if configured), version-negotiating once. */
|
|
81
85
|
ensureReady(): Promise<void>;
|
|
82
86
|
private connectAndNegotiate;
|
|
87
|
+
private spawnDaemon;
|
|
83
88
|
private startDaemonAndConnect;
|
|
89
|
+
private stopStaleDaemon;
|
|
90
|
+
private socketOwnerPids;
|
|
91
|
+
/** Resolve a Unix-socket owner without external tools on standard Linux hosts. */
|
|
92
|
+
private linuxSocketOwnerPids;
|
|
93
|
+
private versionMismatchError;
|
|
94
|
+
private versionRejectionError;
|
|
95
|
+
private restartCommand;
|
|
96
|
+
private installedBinaryVersion;
|
|
97
|
+
private runCommand;
|
|
84
98
|
hello(): Promise<HelloResponse>;
|
|
85
99
|
resolvePath(provider: string, resource: string): Promise<ResolvePathResult>;
|
|
86
100
|
bind(input: BindRequestBody): Promise<BindResult>;
|
package/dist/client.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, readlinkSync } from 'node:fs';
|
|
3
|
+
import { request, } from 'node:http';
|
|
3
4
|
import { tmpdir } from 'node:os';
|
|
4
5
|
import { join } from 'node:path';
|
|
5
6
|
/**
|
|
@@ -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,36 @@ 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([
|
|
86
|
+
'ENOENT',
|
|
87
|
+
'ECONNREFUSED',
|
|
88
|
+
'ETIMEDOUT',
|
|
89
|
+
// A daemon that binds the socket before it is ready to serve can accept and
|
|
90
|
+
// then drop the connection mid-request; both surface during startup polling.
|
|
91
|
+
'ECONNRESET',
|
|
92
|
+
'EPIPE',
|
|
93
|
+
]);
|
|
94
|
+
function isTransientConnectionCode(code) {
|
|
95
|
+
return code != null && TRANSIENT_CONNECTION_CODES.has(code);
|
|
96
|
+
}
|
|
97
|
+
function isTransientControlPlaneError(err) {
|
|
98
|
+
return (err instanceof RelayfileControlPlaneError && err.code === 'DAEMON_UNAVAILABLE' && err.transient);
|
|
99
|
+
}
|
|
100
|
+
function shellQuote(value) {
|
|
101
|
+
return /^[A-Za-z0-9_./:@%+=,-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
102
|
+
}
|
|
79
103
|
export class RelayfileControlPlaneClient {
|
|
80
104
|
socketPath;
|
|
81
105
|
binary;
|
|
@@ -90,6 +114,9 @@ export class RelayfileControlPlaneClient {
|
|
|
90
114
|
this.startTimeoutMs = options.startTimeoutMs ?? 5000;
|
|
91
115
|
this.requestTimeoutMs = options.requestTimeoutMs ?? 10000;
|
|
92
116
|
}
|
|
117
|
+
createHTTPRequest(options, onResponse) {
|
|
118
|
+
return request(options, onResponse);
|
|
119
|
+
}
|
|
93
120
|
/** Low-level request over the unix socket. Throws RelayfileControlPlaneError. */
|
|
94
121
|
rawRequest(opts) {
|
|
95
122
|
const query = opts.query
|
|
@@ -114,13 +141,18 @@ export class RelayfileControlPlaneClient {
|
|
|
114
141
|
settled = true;
|
|
115
142
|
resolve(value);
|
|
116
143
|
};
|
|
117
|
-
const req =
|
|
144
|
+
const req = this.createHTTPRequest({
|
|
118
145
|
socketPath: this.socketPath,
|
|
119
146
|
method: opts.method,
|
|
120
147
|
path,
|
|
121
148
|
timeout: this.requestTimeoutMs,
|
|
149
|
+
// Discovery must open a fresh socket. A pooled keep-alive connection
|
|
150
|
+
// would keep reaching the unlinked stale daemon after replacement.
|
|
151
|
+
agent: opts.freshConnection ? false : undefined,
|
|
122
152
|
headers: {
|
|
123
|
-
|
|
153
|
+
...(opts.includeVersionHeader === false
|
|
154
|
+
? {}
|
|
155
|
+
: { 'X-Relayfile-API-Version': String(RELAYFILE_API_VERSION) }),
|
|
124
156
|
Accept: 'application/json',
|
|
125
157
|
...(payload
|
|
126
158
|
? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }
|
|
@@ -129,7 +161,7 @@ export class RelayfileControlPlaneClient {
|
|
|
129
161
|
}, (res) => {
|
|
130
162
|
// The response stream can emit 'error' (e.g. socket reset mid-body);
|
|
131
163
|
// without a listener that's an unhandled error -> crash.
|
|
132
|
-
res.on('error', (err) => fail(new RelayfileControlPlaneError('DAEMON_UNAVAILABLE', err.message)));
|
|
164
|
+
res.on('error', (err) => fail(new RelayfileControlPlaneError('DAEMON_UNAVAILABLE', err.message, undefined, isTransientConnectionCode(err.code))));
|
|
133
165
|
const chunks = [];
|
|
134
166
|
res.on('data', (c) => chunks.push(c));
|
|
135
167
|
res.on('end', () => {
|
|
@@ -152,7 +184,7 @@ export class RelayfileControlPlaneClient {
|
|
|
152
184
|
});
|
|
153
185
|
});
|
|
154
186
|
req.on('timeout', () => {
|
|
155
|
-
req.destroy(new RelayfileControlPlaneError('DAEMON_UNAVAILABLE', `relayfile control-plane request timed out after ${this.requestTimeoutMs}ms (${opts.method} ${opts.path})
|
|
187
|
+
req.destroy(new RelayfileControlPlaneError('DAEMON_UNAVAILABLE', `relayfile control-plane request timed out after ${this.requestTimeoutMs}ms (${opts.method} ${opts.path})`, undefined, true));
|
|
156
188
|
});
|
|
157
189
|
req.on('error', (err) => {
|
|
158
190
|
if (err instanceof RelayfileControlPlaneError) {
|
|
@@ -162,7 +194,7 @@ export class RelayfileControlPlaneClient {
|
|
|
162
194
|
// ENOENT (no socket file) / ECONNREFUSED (nothing listening) => daemon down.
|
|
163
195
|
fail(new RelayfileControlPlaneError('DAEMON_UNAVAILABLE', err.code === 'ENOENT' || err.code === 'ECONNREFUSED'
|
|
164
196
|
? `relayfile control-plane is not running at ${this.socketPath} (${err.code})`
|
|
165
|
-
: err.message, undefined));
|
|
197
|
+
: err.message, undefined, isTransientConnectionCode(err.code)));
|
|
166
198
|
});
|
|
167
199
|
if (payload)
|
|
168
200
|
req.write(payload);
|
|
@@ -186,7 +218,10 @@ export class RelayfileControlPlaneClient {
|
|
|
186
218
|
hello = await this.hello();
|
|
187
219
|
}
|
|
188
220
|
catch (err) {
|
|
189
|
-
if (
|
|
221
|
+
if (err instanceof RelayfileControlPlaneError && err.code === 'VERSION_INCOMPATIBLE') {
|
|
222
|
+
throw await this.versionRejectionError(err);
|
|
223
|
+
}
|
|
224
|
+
if (!isTransientControlPlaneError(err))
|
|
190
225
|
throw err;
|
|
191
226
|
if (!this.autoStart) {
|
|
192
227
|
throw new RelayfileControlPlaneError('DAEMON_UNAVAILABLE', `relayfile control-plane is not running at ${this.socketPath}. ` +
|
|
@@ -195,15 +230,34 @@ export class RelayfileControlPlaneClient {
|
|
|
195
230
|
hello = await this.startDaemonAndConnect();
|
|
196
231
|
}
|
|
197
232
|
if (!hello.supportedApiVersions?.includes(RELAYFILE_API_VERSION)) {
|
|
198
|
-
|
|
233
|
+
const installedVersion = await this.installedBinaryVersion();
|
|
234
|
+
const daemonVersion = firstSemver(hello.daemonVersion);
|
|
235
|
+
const canReplaceStaleDaemon = this.autoStart &&
|
|
236
|
+
installedVersion != null &&
|
|
237
|
+
daemonVersion !== '' &&
|
|
238
|
+
compareSemver(installedVersion, MIN_RELAYFILE_VERSION_FOR_CURRENT_API) >= 0 &&
|
|
239
|
+
compareSemver(installedVersion, daemonVersion) > 0;
|
|
240
|
+
if (!canReplaceStaleDaemon) {
|
|
241
|
+
throw this.versionMismatchError(hello, installedVersion);
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
await this.stopStaleDaemon();
|
|
245
|
+
hello = await this.startDaemonAndConnect(installedVersion);
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
if (err instanceof RelayfileControlPlaneError && err.code === 'VERSION_INCOMPATIBLE') {
|
|
249
|
+
throw err;
|
|
250
|
+
}
|
|
251
|
+
throw this.versionMismatchError(hello, installedVersion, `Automatic stale-daemon replacement failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
252
|
+
}
|
|
199
253
|
}
|
|
200
254
|
assertRelayfileVersion(hello.daemonVersion);
|
|
201
255
|
}
|
|
202
|
-
|
|
203
|
-
let child;
|
|
256
|
+
spawnDaemon() {
|
|
204
257
|
// A missing binary / bad RELAYFILE_BIN surfaces as an async 'error' event on
|
|
205
258
|
// the child (ENOENT), not a throw from spawn().
|
|
206
259
|
let spawnError;
|
|
260
|
+
let child;
|
|
207
261
|
try {
|
|
208
262
|
child = spawn(this.binary, ['control-plane', 'serve', '--sock', this.socketPath], {
|
|
209
263
|
detached: true,
|
|
@@ -212,33 +266,223 @@ export class RelayfileControlPlaneClient {
|
|
|
212
266
|
child.on('error', (err) => {
|
|
213
267
|
spawnError = err;
|
|
214
268
|
});
|
|
269
|
+
child.once('exit', (code, signal) => {
|
|
270
|
+
spawnError = new Error(`relayfile control-plane exited before readiness (${signal ? `signal ${signal}` : `code ${code}`})`);
|
|
271
|
+
});
|
|
215
272
|
}
|
|
216
273
|
catch (err) {
|
|
217
274
|
throw new RelayfileControlPlaneError('DAEMON_UNAVAILABLE', `failed to start relayfile control-plane (${err instanceof Error ? err.message : String(err)}). ` +
|
|
218
275
|
`Install relayfile or set RELAYFILE_BIN.`);
|
|
219
276
|
}
|
|
220
277
|
child.unref?.();
|
|
278
|
+
return { getError: () => spawnError };
|
|
279
|
+
}
|
|
280
|
+
async startDaemonAndConnect(installedVersion) {
|
|
281
|
+
const spawned = this.spawnDaemon();
|
|
221
282
|
const deadline = Date.now() + this.startTimeoutMs;
|
|
222
283
|
let lastErr;
|
|
223
284
|
while (Date.now() < deadline) {
|
|
285
|
+
const spawnError = spawned.getError();
|
|
224
286
|
if (spawnError) {
|
|
225
287
|
throw new RelayfileControlPlaneError('DAEMON_UNAVAILABLE', `failed to start relayfile control-plane (${spawnError.message}). ` +
|
|
226
288
|
`Install relayfile or set RELAYFILE_BIN.`);
|
|
227
289
|
}
|
|
228
290
|
await sleep(100);
|
|
291
|
+
let hello;
|
|
229
292
|
try {
|
|
230
|
-
|
|
293
|
+
hello = await this.hello();
|
|
231
294
|
}
|
|
232
295
|
catch (err) {
|
|
296
|
+
if (err instanceof RelayfileControlPlaneError && err.code === 'VERSION_INCOMPATIBLE') {
|
|
297
|
+
throw await this.versionRejectionError(err, installedVersion);
|
|
298
|
+
}
|
|
299
|
+
if (!isTransientControlPlaneError(err))
|
|
300
|
+
throw err;
|
|
233
301
|
lastErr = err;
|
|
302
|
+
continue;
|
|
234
303
|
}
|
|
304
|
+
if (hello.supportedApiVersions?.includes(RELAYFILE_API_VERSION)) {
|
|
305
|
+
return hello;
|
|
306
|
+
}
|
|
307
|
+
const mismatch = this.versionMismatchError(hello, installedVersion ?? (await this.installedBinaryVersion()));
|
|
308
|
+
throw mismatch;
|
|
235
309
|
}
|
|
236
310
|
throw new RelayfileControlPlaneError('DAEMON_UNAVAILABLE', `relayfile control-plane did not become ready within ${this.startTimeoutMs}ms ` +
|
|
237
311
|
`(${lastErr instanceof Error ? lastErr.message : String(lastErr)}).`);
|
|
238
312
|
}
|
|
313
|
+
async stopStaleDaemon() {
|
|
314
|
+
const pids = await this.socketOwnerPids();
|
|
315
|
+
if (pids.length !== 1) {
|
|
316
|
+
throw new Error(pids.length === 0
|
|
317
|
+
? `could not identify the process serving ${this.socketPath}`
|
|
318
|
+
: `refusing to stop multiple processes serving ${this.socketPath}: ${pids.join(', ')}`);
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
process.kill(pids[0], 'SIGTERM');
|
|
322
|
+
}
|
|
323
|
+
catch (err) {
|
|
324
|
+
throw new Error(`could not stop stale relayfile control-plane pid ${pids[0]}: ${err instanceof Error ? err.message : String(err)}`);
|
|
325
|
+
}
|
|
326
|
+
const deadline = Date.now() + this.startTimeoutMs;
|
|
327
|
+
while (existsSync(this.socketPath) && Date.now() < deadline) {
|
|
328
|
+
await sleep(50);
|
|
329
|
+
}
|
|
330
|
+
if (existsSync(this.socketPath)) {
|
|
331
|
+
throw new Error(`stale relayfile control-plane pid ${pids[0]} did not release ${this.socketPath} within ${this.startTimeoutMs}ms`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
async socketOwnerPids() {
|
|
335
|
+
if (process.platform === 'linux') {
|
|
336
|
+
const procPids = this.linuxSocketOwnerPids();
|
|
337
|
+
if (procPids.length > 0)
|
|
338
|
+
return procPids;
|
|
339
|
+
}
|
|
340
|
+
if (process.platform === 'win32') {
|
|
341
|
+
throw new Error('automatic stale-daemon replacement is not supported on Windows; stop the running relayfile control-plane manually');
|
|
342
|
+
}
|
|
343
|
+
const result = await this.runCommand('lsof', ['-t', '--', this.socketPath], 1000);
|
|
344
|
+
if (!result) {
|
|
345
|
+
throw new Error(`could not run \`lsof\` to identify the process serving ${this.socketPath} ` +
|
|
346
|
+
`(it is not installed, not on PATH, or did not exit within 1000ms); ` +
|
|
347
|
+
`install lsof or stop the stale relayfile control-plane manually`);
|
|
348
|
+
}
|
|
349
|
+
if (result.code !== 0) {
|
|
350
|
+
throw new Error(`lsof could not identify the process serving ${this.socketPath} (exit ${result.code}${result.stderr.trim() ? `: ${result.stderr.trim()}` : ''})`);
|
|
351
|
+
}
|
|
352
|
+
return [
|
|
353
|
+
...new Set(result.stdout
|
|
354
|
+
.split(/\s+/)
|
|
355
|
+
.map((value) => Number.parseInt(value, 10))
|
|
356
|
+
.filter((pid) => Number.isSafeInteger(pid) && pid > 1 && pid !== process.pid)),
|
|
357
|
+
];
|
|
358
|
+
}
|
|
359
|
+
/** Resolve a Unix-socket owner without external tools on standard Linux hosts. */
|
|
360
|
+
linuxSocketOwnerPids(procRoot = '/proc') {
|
|
361
|
+
let socketInodes;
|
|
362
|
+
try {
|
|
363
|
+
socketInodes = new Set(readFileSync(join(procRoot, 'net', 'unix'), 'utf8')
|
|
364
|
+
.split('\n')
|
|
365
|
+
.slice(1)
|
|
366
|
+
.map((line) => line.trim().split(/\s+/))
|
|
367
|
+
.filter((fields) => fields.length >= 8 && fields.slice(7).join(' ') === this.socketPath)
|
|
368
|
+
.map((fields) => fields[6]));
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return [];
|
|
372
|
+
}
|
|
373
|
+
if (socketInodes.size === 0)
|
|
374
|
+
return [];
|
|
375
|
+
const pids = new Set();
|
|
376
|
+
let processes;
|
|
377
|
+
try {
|
|
378
|
+
processes = readdirSync(procRoot).filter((name) => /^\d+$/.test(name));
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return [];
|
|
382
|
+
}
|
|
383
|
+
for (const processName of processes) {
|
|
384
|
+
const pid = Number.parseInt(processName, 10);
|
|
385
|
+
if (pid <= 1 || pid === process.pid)
|
|
386
|
+
continue;
|
|
387
|
+
const fdDir = join(procRoot, processName, 'fd');
|
|
388
|
+
let descriptors;
|
|
389
|
+
try {
|
|
390
|
+
descriptors = readdirSync(fdDir);
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
for (const descriptor of descriptors) {
|
|
396
|
+
try {
|
|
397
|
+
const target = readlinkSync(join(fdDir, descriptor));
|
|
398
|
+
const inode = target.match(/^socket:\[(\d+)\]$/)?.[1];
|
|
399
|
+
if (inode && socketInodes.has(inode)) {
|
|
400
|
+
pids.add(pid);
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
// File descriptors may disappear while /proc is scanned.
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return [...pids];
|
|
410
|
+
}
|
|
411
|
+
versionMismatchError(hello, installedVersion, detail) {
|
|
412
|
+
const supported = hello.supportedApiVersions?.length
|
|
413
|
+
? hello.supportedApiVersions.map((version) => `v${version}`).join(', ')
|
|
414
|
+
: 'none reported';
|
|
415
|
+
return new RelayfileControlPlaneError('VERSION_INCOMPATIBLE', `relayfile daemon ${hello.daemonVersion} speaks API v${hello.apiVersion} ` +
|
|
416
|
+
`(supports ${supported}); this client requires API v${RELAYFILE_API_VERSION}. ` +
|
|
417
|
+
`Installed relayfile binary: ${installedVersion ?? 'version unknown'}. ` +
|
|
418
|
+
`${detail ? `${detail} ` : ''}` +
|
|
419
|
+
`Upgrade with \`npm install -g relayfile@latest\`, then restart the running control-plane with ` +
|
|
420
|
+
`\`${this.restartCommand()}\`.`);
|
|
421
|
+
}
|
|
422
|
+
async versionRejectionError(err, installedVersion) {
|
|
423
|
+
installedVersion ??= await this.installedBinaryVersion();
|
|
424
|
+
const spoken = err.message.match(/speaks API v(\d+)/i)?.[1];
|
|
425
|
+
return new RelayfileControlPlaneError('VERSION_INCOMPATIBLE', `relayfile daemon version unknown (discovery hello was rejected) speaks API ` +
|
|
426
|
+
`${spoken ? `v${spoken}` : 'version unknown'} (supported versions were not reported); ` +
|
|
427
|
+
`this client requires API v${RELAYFILE_API_VERSION}. Installed relayfile binary: ` +
|
|
428
|
+
`${installedVersion ?? 'version unknown'}. Upgrade with \`npm install -g relayfile@latest\`, ` +
|
|
429
|
+
`then restart the running control-plane with \`${this.restartCommand()}\`. ` +
|
|
430
|
+
`Daemon response: ${err.message}`, err.status);
|
|
431
|
+
}
|
|
432
|
+
restartCommand() {
|
|
433
|
+
const socket = shellQuote(this.socketPath);
|
|
434
|
+
return (`kill $(lsof -t -- ${socket}) && ` +
|
|
435
|
+
`${shellQuote(this.binary)} control-plane serve --sock ${socket}`);
|
|
436
|
+
}
|
|
437
|
+
async installedBinaryVersion() {
|
|
438
|
+
const result = await this.runCommand(this.binary, ['--version'], Math.min(this.requestTimeoutMs, 2000));
|
|
439
|
+
return firstSemver(`${result?.stdout ?? ''} ${result?.stderr ?? ''}`) || undefined;
|
|
440
|
+
}
|
|
441
|
+
runCommand(command, args, timeoutMs) {
|
|
442
|
+
return new Promise((resolve) => {
|
|
443
|
+
let child;
|
|
444
|
+
try {
|
|
445
|
+
child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
resolve(undefined);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
let stdout = '';
|
|
452
|
+
let stderr = '';
|
|
453
|
+
let settled = false;
|
|
454
|
+
let timeout;
|
|
455
|
+
const finish = (result) => {
|
|
456
|
+
if (settled)
|
|
457
|
+
return;
|
|
458
|
+
settled = true;
|
|
459
|
+
if (timeout)
|
|
460
|
+
clearTimeout(timeout);
|
|
461
|
+
resolve(result);
|
|
462
|
+
};
|
|
463
|
+
child.stdout?.on('data', (chunk) => {
|
|
464
|
+
stdout += String(chunk);
|
|
465
|
+
});
|
|
466
|
+
child.stderr?.on('data', (chunk) => {
|
|
467
|
+
stderr += String(chunk);
|
|
468
|
+
});
|
|
469
|
+
child.once('error', () => finish());
|
|
470
|
+
child.once('close', (code) => finish({ stdout, stderr, code }));
|
|
471
|
+
timeout = setTimeout(() => {
|
|
472
|
+
child.kill();
|
|
473
|
+
finish();
|
|
474
|
+
}, timeoutMs);
|
|
475
|
+
timeout.unref?.();
|
|
476
|
+
});
|
|
477
|
+
}
|
|
239
478
|
// ── endpoints ──────────────────────────────────────────────────────────────
|
|
240
479
|
hello() {
|
|
241
|
-
return this.rawRequest({
|
|
480
|
+
return this.rawRequest({
|
|
481
|
+
method: 'GET',
|
|
482
|
+
path: '/v1/hello',
|
|
483
|
+
includeVersionHeader: false,
|
|
484
|
+
freshConnection: true,
|
|
485
|
+
});
|
|
242
486
|
}
|
|
243
487
|
resolvePath(provider, resource) {
|
|
244
488
|
return this.request({
|
|
@@ -10,7 +10,14 @@ 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
|
+
* No version parameters are declared here on purpose: discovery must stay
|
|
18
|
+
* callable by clients of any API version, including versions this contract
|
|
19
|
+
* does not know about.
|
|
20
|
+
*/
|
|
14
21
|
get: operations["hello"];
|
|
15
22
|
put?: never;
|
|
16
23
|
/** Negotiate control-plane API compatibility */
|
|
@@ -366,7 +373,6 @@ export interface components {
|
|
|
366
373
|
};
|
|
367
374
|
parameters: {
|
|
368
375
|
ApiVersionHeader: 3;
|
|
369
|
-
ApiVersionQuery: number;
|
|
370
376
|
};
|
|
371
377
|
requestBodies: never;
|
|
372
378
|
headers: never;
|
|
@@ -376,12 +382,8 @@ export type $defs = Record<string, never>;
|
|
|
376
382
|
export interface operations {
|
|
377
383
|
hello: {
|
|
378
384
|
parameters: {
|
|
379
|
-
query?:
|
|
380
|
-
|
|
381
|
-
};
|
|
382
|
-
header?: {
|
|
383
|
-
"X-Relayfile-API-Version"?: components["parameters"]["ApiVersionHeader"];
|
|
384
|
-
};
|
|
385
|
+
query?: never;
|
|
386
|
+
header?: never;
|
|
385
387
|
path?: never;
|
|
386
388
|
cookie?: never;
|
|
387
389
|
};
|
|
@@ -396,7 +398,6 @@ export interface operations {
|
|
|
396
398
|
"application/json": components["schemas"]["HelloResponse"];
|
|
397
399
|
};
|
|
398
400
|
};
|
|
399
|
-
426: components["responses"]["VersionIncompatible"];
|
|
400
401
|
};
|
|
401
402
|
};
|
|
402
403
|
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",
|
|
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",
|