@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 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. The control-plane
17
- * first shipped in 0.10.17, so anything that answers `/v1/hello` is already >=
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
- constructor(code: string, message: string, status?: number | undefined);
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. The control-plane
20
- * first shipped in 0.10.17, so anything that answers `/v1/hello` is already >=
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
- constructor(code, message, status) {
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
- 'X-Relayfile-API-Version': String(RELAYFILE_API_VERSION),
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 (!(err instanceof RelayfileControlPlaneError) || err.code !== 'DAEMON_UNAVAILABLE')
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
- throw new RelayfileControlPlaneError('VERSION_INCOMPATIBLE', `relayfile daemon speaks API v${hello.apiVersion} (supports ${hello.supportedApiVersions?.join(', ') || 'none'}); this client needs v${RELAYFILE_API_VERSION}. Upgrade relayfile (or agent-relay).`);
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
- async startDaemonAndConnect() {
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
- return await this.hello();
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({ method: 'GET', path: '/v1/hello' });
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
- /** Negotiate control-plane API compatibility */
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.26",
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",