@juspay/neurolink 9.93.2 → 9.94.1

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/browser/neurolink.min.js +263 -263
  3. package/dist/cli/commands/proxy.js +672 -182
  4. package/dist/cli/utils/audioPlayer.d.ts +44 -9
  5. package/dist/cli/utils/audioPlayer.js +162 -65
  6. package/dist/lib/proxy/rollingProxyServer.d.ts +3 -0
  7. package/dist/lib/proxy/rollingProxyServer.js +149 -0
  8. package/dist/lib/proxy/rollingWorkerProcess.d.ts +2 -0
  9. package/dist/lib/proxy/rollingWorkerProcess.js +194 -0
  10. package/dist/lib/proxy/rollingWorkerProtocol.d.ts +5 -0
  11. package/dist/lib/proxy/rollingWorkerProtocol.js +43 -0
  12. package/dist/lib/proxy/rollingWorkerSupervisor.d.ts +39 -0
  13. package/dist/lib/proxy/rollingWorkerSupervisor.js +402 -0
  14. package/dist/lib/proxy/socketWorkerRuntime.d.ts +14 -0
  15. package/dist/lib/proxy/socketWorkerRuntime.js +294 -0
  16. package/dist/lib/skills/skillMatcher.d.ts +7 -1
  17. package/dist/lib/skills/skillMatcher.js +23 -8
  18. package/dist/lib/types/cli.d.ts +47 -0
  19. package/dist/lib/types/proxy.d.ts +156 -0
  20. package/dist/lib/utils/errorHandling.d.ts +8 -0
  21. package/dist/lib/utils/errorHandling.js +20 -0
  22. package/dist/proxy/rollingProxyServer.d.ts +3 -0
  23. package/dist/proxy/rollingProxyServer.js +148 -0
  24. package/dist/proxy/rollingWorkerProcess.d.ts +2 -0
  25. package/dist/proxy/rollingWorkerProcess.js +193 -0
  26. package/dist/proxy/rollingWorkerProtocol.d.ts +5 -0
  27. package/dist/proxy/rollingWorkerProtocol.js +42 -0
  28. package/dist/proxy/rollingWorkerSupervisor.d.ts +39 -0
  29. package/dist/proxy/rollingWorkerSupervisor.js +401 -0
  30. package/dist/proxy/socketWorkerRuntime.d.ts +14 -0
  31. package/dist/proxy/socketWorkerRuntime.js +293 -0
  32. package/dist/skills/skillMatcher.d.ts +7 -1
  33. package/dist/skills/skillMatcher.js +23 -8
  34. package/dist/types/cli.d.ts +47 -0
  35. package/dist/types/proxy.d.ts +156 -0
  36. package/dist/utils/errorHandling.d.ts +8 -0
  37. package/dist/utils/errorHandling.js +20 -0
  38. package/package.json +3 -2
@@ -6,32 +6,67 @@
6
6
  *
7
7
  * @module cli/utils/audioPlayer
8
8
  */
9
- import type { AudioFormat } from "../../lib/types/index.js";
9
+ import type { TTSAudioFormat, CliAudioPlayerCommand } from "../../lib/types/index.js";
10
10
  /**
11
11
  * Get the file extension for an audio format
12
12
  *
13
13
  * @param format - Audio format
14
14
  * @returns File extension string (e.g., "mp3", "wav")
15
15
  */
16
- export declare function getAudioExtension(format: AudioFormat): string;
16
+ export declare function getAudioExtension(format: TTSAudioFormat): string;
17
+ /**
18
+ * Escape a value for interpolation inside a single-quoted PowerShell string.
19
+ * PowerShell's single-quoted strings only treat `''` as a literal `'` — unlike
20
+ * shell/execFile argv, this string is parsed by powershell.exe itself, so an
21
+ * unescaped quote (e.g. a Windows username like `O'Brien` under `%TEMP%`)
22
+ * would break out of the string and let the remainder run as PS code.
23
+ */
24
+ export declare function escapePowerShellSingleQuoted(value: string): string;
25
+ /**
26
+ * Build the ordered list of player commands to try for a file.
27
+ *
28
+ * The list is tried in order until one succeeds; a missing binary (ENOENT) or a
29
+ * decode failure simply advances to the next candidate.
30
+ *
31
+ * The Linux ordering is the crux of the mp3-playback fix (#1138): `paplay`
32
+ * (PulseAudio) and `aplay` (ALSA) only decode libsndfile/PCM formats and
33
+ * **cannot** decode mp3/AAC. Since `--tts-format` defaults to mp3, compressed
34
+ * formats must lead with real decoders (ffplay/mpv/mpg123/cvlc); paplay/aplay
35
+ * remain only as last-resort fallbacks (and still work for wav/ogg).
36
+ *
37
+ * @param filePath - Path to the audio file
38
+ * @param format - Audio format
39
+ * @param platform - Platform id (defaults to `process.platform`; injectable for tests)
40
+ * @returns Ordered player commands; empty for unsupported platforms
41
+ */
42
+ export declare function getPlayerCandidates(filePath: string, format: TTSAudioFormat, platform?: NodeJS.Platform): CliAudioPlayerCommand[];
43
+ /**
44
+ * Build a helpful, format-aware error message when no player could decode the file.
45
+ *
46
+ * Exported for testing: the message a user sees when Linux mp3 playback finds no
47
+ * decoder must name the real fix (ffmpeg/mpv/mpg123/VLC or `--tts-format wav`),
48
+ * not the misleading "install PulseAudio" that paplay-only routing produced (#1138).
49
+ */
50
+ export declare function buildPlaybackErrorMessage(platform: NodeJS.Platform, format: TTSAudioFormat, attempts: string[]): string;
17
51
  /**
18
52
  * Play audio from a buffer using platform-specific CLI tools
19
53
  *
20
- * Writes the buffer to a temporary file, plays it using the appropriate
21
- * system audio player, and cleans up the temp file afterward.
54
+ * Writes the buffer to a temporary file, plays it using the first available
55
+ * system audio player that can decode the format, and cleans up the temp file.
22
56
  *
23
57
  * Supported platforms:
24
- * - macOS: uses `afplay` (built-in, supports mp3/wav/aac/flac)
25
- * - Linux: uses `paplay` for non-wav, `aplay` for wav
26
- * - Windows: uses PowerShell SoundPlayer (wav) or WMPlayer.OCX (mp3)
58
+ * - macOS: `afplay` (built-in; decodes mp3/wav/aac/flac)
59
+ * - Linux: real decoders (ffplay/mpv/mpg123/cvlc) for compressed formats,
60
+ * aplay/paplay for wav falling through the list until one works
61
+ * - Windows: PowerShell SoundPlayer (wav) or WMPlayer.OCX (compressed)
27
62
  *
28
63
  * @param buffer - Audio data buffer
29
64
  * @param format - Audio format (mp3, wav, ogg, opus)
30
- * @throws Error if playback fails or platform is unsupported
65
+ * @throws Error if no available player can play the audio, or platform is unsupported
31
66
  *
32
67
  * @example
33
68
  * ```typescript
34
69
  * await playAudio(audioBuffer, "mp3");
35
70
  * ```
36
71
  */
37
- export declare function playAudio(buffer: Buffer, format: AudioFormat): Promise<void>;
72
+ export declare function playAudio(buffer: Buffer, format: TTSAudioFormat): Promise<void>;
@@ -7,11 +7,25 @@
7
7
  * @module cli/utils/audioPlayer
8
8
  */
9
9
  import { execFile } from "node:child_process";
10
+ import { randomUUID } from "node:crypto";
10
11
  import fs from "node:fs";
11
12
  import os from "node:os";
12
13
  import path from "node:path";
13
14
  import { promisify } from "node:util";
14
15
  const execFileAsync = promisify(execFile);
16
+ /** Wall-clock cap on a single player invocation; a hung decoder must not block the CLI forever. */
17
+ const PLAYER_TIMEOUT_MS = 30_000;
18
+ /**
19
+ * Single source of truth for format -> temp-file-extension. Formats outside this
20
+ * playback-supported set (m4a, flac, webm, mp4, mpeg, mpga, pcm16) fall back to
21
+ * "mp3" — `playAudio` only ever receives the four formats below in practice.
22
+ */
23
+ const AUDIO_EXTENSIONS = {
24
+ mp3: "mp3",
25
+ wav: "wav",
26
+ ogg: "ogg",
27
+ opus: "opus",
28
+ };
15
29
  /**
16
30
  * Get the file extension for an audio format
17
31
  *
@@ -19,73 +33,145 @@ const execFileAsync = promisify(execFile);
19
33
  * @returns File extension string (e.g., "mp3", "wav")
20
34
  */
21
35
  export function getAudioExtension(format) {
22
- switch (format) {
23
- case "mp3":
24
- return "mp3";
25
- case "wav":
26
- return "wav";
27
- case "ogg":
28
- return "ogg";
29
- case "opus":
30
- return "opus";
31
- default:
32
- return "mp3";
33
- }
36
+ return AUDIO_EXTENSIONS[format] ?? "mp3";
37
+ }
38
+ /** ffmpeg's ffplay decodes every format we emit; quiet + auto-exit for CLI use. */
39
+ function ffplayCommand(filePath) {
40
+ return {
41
+ command: "ffplay",
42
+ args: ["-nodisp", "-autoexit", "-loglevel", "quiet", filePath],
43
+ };
34
44
  }
35
45
  /**
36
- * Get the platform-specific audio player command and arguments
46
+ * Escape a value for interpolation inside a single-quoted PowerShell string.
47
+ * PowerShell's single-quoted strings only treat `''` as a literal `'` — unlike
48
+ * shell/execFile argv, this string is parsed by powershell.exe itself, so an
49
+ * unescaped quote (e.g. a Windows username like `O'Brien` under `%TEMP%`)
50
+ * would break out of the string and let the remainder run as PS code.
51
+ */
52
+ export function escapePowerShellSingleQuoted(value) {
53
+ return value.replace(/'/g, "''");
54
+ }
55
+ /**
56
+ * Build the ordered list of player commands to try for a file.
57
+ *
58
+ * The list is tried in order until one succeeds; a missing binary (ENOENT) or a
59
+ * decode failure simply advances to the next candidate.
60
+ *
61
+ * The Linux ordering is the crux of the mp3-playback fix (#1138): `paplay`
62
+ * (PulseAudio) and `aplay` (ALSA) only decode libsndfile/PCM formats and
63
+ * **cannot** decode mp3/AAC. Since `--tts-format` defaults to mp3, compressed
64
+ * formats must lead with real decoders (ffplay/mpv/mpg123/cvlc); paplay/aplay
65
+ * remain only as last-resort fallbacks (and still work for wav/ogg).
37
66
  *
38
67
  * @param filePath - Path to the audio file
39
68
  * @param format - Audio format
40
- * @returns Object with command and args for execFile
69
+ * @param platform - Platform id (defaults to `process.platform`; injectable for tests)
70
+ * @returns Ordered player commands; empty for unsupported platforms
41
71
  */
42
- function getPlayerCommand(filePath, format) {
43
- const platform = process.platform;
72
+ export function getPlayerCandidates(filePath, format, platform = process.platform) {
73
+ const isWav = format === "wav";
44
74
  switch (platform) {
45
75
  case "darwin":
46
- return { command: "afplay", args: [filePath] };
76
+ // afplay decodes mp3/wav/aac/flac natively.
77
+ return [{ command: "afplay", args: [filePath] }];
47
78
  case "linux":
48
- if (format === "wav") {
49
- return { command: "aplay", args: [filePath] };
79
+ if (isWav) {
80
+ // WAV/PCM: ALSA and PulseAudio both decode it; ffplay as a backstop.
81
+ return [
82
+ { command: "aplay", args: [filePath] },
83
+ { command: "paplay", args: [filePath] },
84
+ ffplayCommand(filePath),
85
+ ];
86
+ }
87
+ // Compressed formats (mp3/ogg/opus): lead with decoders that actually
88
+ // handle them, then fall back to paplay/aplay (which work for some
89
+ // formats but never mp3).
90
+ return [
91
+ ffplayCommand(filePath),
92
+ {
93
+ command: "mpv",
94
+ args: ["--no-video", "--really-quiet", filePath],
95
+ },
96
+ ...(format === "mp3"
97
+ ? [{ command: "mpg123", args: ["-q", filePath] }]
98
+ : []),
99
+ {
100
+ command: "cvlc",
101
+ args: ["--play-and-exit", "--no-loop", "--intf", "dummy", filePath],
102
+ },
103
+ { command: "paplay", args: [filePath] },
104
+ { command: "aplay", args: [filePath] },
105
+ ];
106
+ case "win32": {
107
+ // filePath is embedded inside a single-quoted PS string below, so it must
108
+ // be escaped — a Windows username with an apostrophe (e.g. `O'Brien`,
109
+ // present in `%TEMP%`) would otherwise break out of the string.
110
+ const psPath = escapePowerShellSingleQuoted(filePath);
111
+ if (isWav) {
112
+ return [
113
+ {
114
+ command: "powershell",
115
+ args: [
116
+ "-NoProfile",
117
+ "-Command",
118
+ `(New-Object System.Media.SoundPlayer '${psPath}').PlaySync()`,
119
+ ],
120
+ },
121
+ ];
50
122
  }
51
- return { command: "paplay", args: [filePath] };
52
- case "win32":
53
- if (format === "wav") {
54
- return {
123
+ return [
124
+ {
55
125
  command: "powershell",
56
126
  args: [
57
127
  "-NoProfile",
58
128
  "-Command",
59
- `(New-Object System.Media.SoundPlayer '${filePath}').PlaySync()`,
129
+ `$player = New-Object -ComObject WMPlayer.OCX; $player.URL = '${psPath}'; $player.controls.play(); Start-Sleep -Seconds 1; while ($player.playState -eq 3) { Start-Sleep -Milliseconds 100 }; $player.close()`,
60
130
  ],
61
- };
62
- }
63
- return {
64
- command: "powershell",
65
- args: [
66
- "-NoProfile",
67
- "-Command",
68
- `$player = New-Object -ComObject WMPlayer.OCX; $player.URL = '${filePath}'; $player.controls.play(); Start-Sleep -Seconds 1; while ($player.playState -eq 3) { Start-Sleep -Milliseconds 100 }; $player.close()`,
69
- ],
70
- };
131
+ },
132
+ ];
133
+ }
71
134
  default:
72
- throw new Error(`Unsupported platform: ${platform}. Audio playback is supported on macOS, Linux, and Windows.`);
135
+ return [];
73
136
  }
74
137
  }
138
+ /**
139
+ * Build a helpful, format-aware error message when no player could decode the file.
140
+ *
141
+ * Exported for testing: the message a user sees when Linux mp3 playback finds no
142
+ * decoder must name the real fix (ffmpeg/mpv/mpg123/VLC or `--tts-format wav`),
143
+ * not the misleading "install PulseAudio" that paplay-only routing produced (#1138).
144
+ */
145
+ export function buildPlaybackErrorMessage(platform, format, attempts) {
146
+ const tried = attempts.length ? ` (tried: ${attempts.join("; ")})` : "";
147
+ if (platform === "linux") {
148
+ if (format === "wav") {
149
+ return `Could not play wav audio${tried}. Install ALSA (aplay) or PulseAudio (paplay).`;
150
+ }
151
+ // mpg123 only decodes mp3 (see getPlayerCandidates) — recommending it for
152
+ // ogg/opus failures would send users chasing a decoder that can't help.
153
+ if (format === "mp3") {
154
+ return `Could not play mp3 audio${tried}. paplay/aplay cannot decode mp3; install one of: ffmpeg (ffplay), mpv, mpg123, or VLC (cvlc) — or use --tts-format wav.`;
155
+ }
156
+ return `Could not play ${format} audio${tried}. Install ffmpeg (ffplay), mpv, or VLC (cvlc) — or use --tts-format wav.`;
157
+ }
158
+ return `Could not play ${format} audio${tried}. Ensure a system audio player is installed and available in PATH.`;
159
+ }
75
160
  /**
76
161
  * Play audio from a buffer using platform-specific CLI tools
77
162
  *
78
- * Writes the buffer to a temporary file, plays it using the appropriate
79
- * system audio player, and cleans up the temp file afterward.
163
+ * Writes the buffer to a temporary file, plays it using the first available
164
+ * system audio player that can decode the format, and cleans up the temp file.
80
165
  *
81
166
  * Supported platforms:
82
- * - macOS: uses `afplay` (built-in, supports mp3/wav/aac/flac)
83
- * - Linux: uses `paplay` for non-wav, `aplay` for wav
84
- * - Windows: uses PowerShell SoundPlayer (wav) or WMPlayer.OCX (mp3)
167
+ * - macOS: `afplay` (built-in; decodes mp3/wav/aac/flac)
168
+ * - Linux: real decoders (ffplay/mpv/mpg123/cvlc) for compressed formats,
169
+ * aplay/paplay for wav falling through the list until one works
170
+ * - Windows: PowerShell SoundPlayer (wav) or WMPlayer.OCX (compressed)
85
171
  *
86
172
  * @param buffer - Audio data buffer
87
173
  * @param format - Audio format (mp3, wav, ogg, opus)
88
- * @throws Error if playback fails or platform is unsupported
174
+ * @throws Error if no available player can play the audio, or platform is unsupported
89
175
  *
90
176
  * @example
91
177
  * ```typescript
@@ -94,36 +180,47 @@ function getPlayerCommand(filePath, format) {
94
180
  */
95
181
  export async function playAudio(buffer, format) {
96
182
  const ext = getAudioExtension(format);
97
- const tempFile = path.join(os.tmpdir(), `nl-tts-${Date.now()}.${ext}`);
183
+ // randomUUID (not just Date.now()) avoids two concurrent calls in the same
184
+ // millisecond racing on the same temp path.
185
+ const tempFile = path.join(os.tmpdir(), `nl-tts-${randomUUID()}.${ext}`);
98
186
  try {
99
187
  // Write audio buffer to temp file
100
188
  await fs.promises.writeFile(tempFile, buffer);
101
- const { command, args } = getPlayerCommand(tempFile, format);
102
- try {
103
- await execFileAsync(command, args);
189
+ const candidates = getPlayerCandidates(tempFile, format);
190
+ if (candidates.length === 0) {
191
+ throw new Error(`Unsupported platform: ${process.platform}. Audio playback is supported on macOS, Linux, and Windows.`);
104
192
  }
105
- catch (execError) {
106
- const err = execError;
107
- // Handle binary not found
108
- if (err.code === "ENOENT") {
109
- if (process.platform === "linux" && command === "paplay") {
110
- // Fallback to aplay on Linux
111
- try {
112
- await execFileAsync("aplay", [tempFile]);
113
- return;
114
- }
115
- catch (fallbackError) {
116
- const fbErr = fallbackError;
117
- if (fbErr.code === "ENOENT") {
118
- throw new Error("Neither paplay nor aplay found. Install PulseAudio (paplay) or ALSA (aplay) for audio playback.", { cause: fallbackError });
119
- }
120
- throw fallbackError;
121
- }
122
- }
123
- throw new Error(`Audio player '${command}' not found. Ensure it is installed and available in PATH.`, { cause: execError });
193
+ const attempts = [];
194
+ for (const { command, args } of candidates) {
195
+ try {
196
+ // `timeout` guards against a hung decoder (e.g. blocked on the audio
197
+ // device) so playback can't stall the CLI forever; SIGTERM lets the
198
+ // player shut down cleanly before Node force-kills it.
199
+ await execFileAsync(command, args, {
200
+ timeout: PLAYER_TIMEOUT_MS,
201
+ killSignal: "SIGTERM",
202
+ });
203
+ return; // Played successfully.
204
+ }
205
+ catch (execError) {
206
+ const err = execError;
207
+ // Every failure (missing binary, decode error, or timeout) is
208
+ // recorded and surfaced in the final error below — none are lost.
209
+ // We still advance to the next candidate rather than aborting on the
210
+ // first non-ENOENT failure: `command` existing but failing on this
211
+ // particular file (e.g. a corrupt stream, or one decoder lacking
212
+ // codec support) doesn't mean the *next* candidate will fail too,
213
+ // and failing fast here would defeat the multi-decoder fallback
214
+ // that #1138 exists to provide.
215
+ const detail = err.killed
216
+ ? `timed out after ${PLAYER_TIMEOUT_MS}ms`
217
+ : err.code === "ENOENT"
218
+ ? "not installed"
219
+ : (err.message ?? "failed");
220
+ attempts.push(`${command}: ${detail}`);
124
221
  }
125
- throw execError;
126
222
  }
223
+ throw new Error(buildPlaybackErrorMessage(process.platform, format, attempts));
127
224
  }
128
225
  finally {
129
226
  // Always clean up temp file
@@ -0,0 +1,3 @@
1
+ import type { RollingProxyServer, RollingProxyServerOptions } from "../types/index.js";
2
+ /** Keep one public listener stable while serving workers start and rotate. */
3
+ export declare function startRollingProxyServer(options: RollingProxyServerOptions): Promise<RollingProxyServer>;
@@ -0,0 +1,149 @@
1
+ import { createServer } from "node:net";
2
+ import { ErrorFactory } from "../utils/errorHandling.js";
3
+ import { RollingWorkerSupervisor } from "./rollingWorkerSupervisor.js";
4
+ const DEFAULT_RECOVERY_DELAY_MS = 250;
5
+ const DEFAULT_MAX_RECOVERY_DELAY_MS = 10_000;
6
+ /** Keep one public listener stable while serving workers start and rotate. */
7
+ export async function startRollingProxyServer(options) {
8
+ let desiredVersion = options.initialVersion;
9
+ let closing = false;
10
+ let listening = false;
11
+ let recoveryFailures = 0;
12
+ let recoveryTimer;
13
+ const recoveryDelayMs = Math.max(1, options.recoveryDelayMs ?? DEFAULT_RECOVERY_DELAY_MS);
14
+ const maxRecoveryDelayMs = Math.max(recoveryDelayMs, options.maxRecoveryDelayMs ?? DEFAULT_MAX_RECOVERY_DELAY_MS);
15
+ const scheduleRecovery = () => {
16
+ if (closing || !listening || recoveryTimer) {
17
+ return;
18
+ }
19
+ const delay = Math.min(maxRecoveryDelayMs, recoveryDelayMs * 2 ** Math.min(recoveryFailures, 8));
20
+ options.log?.(`[proxy-supervisor] scheduling worker recovery version=${desiredVersion} delayMs=${delay}`);
21
+ recoveryTimer = setTimeout(() => {
22
+ recoveryTimer = undefined;
23
+ // An explicit replace() may have started (or completed) a generation
24
+ // while this timer was pending. Re-validate before recovering so we never
25
+ // launch a duplicate generation that conflicts with the requested worker.
26
+ const snapshot = supervisor.snapshot();
27
+ if (closing || snapshot.active || snapshot.candidate) {
28
+ return;
29
+ }
30
+ void supervisor.replace(desiredVersion).then(() => {
31
+ recoveryFailures = 0;
32
+ }, (error) => {
33
+ recoveryFailures += 1;
34
+ options.log?.(`[proxy-supervisor] worker recovery failed: ${error instanceof Error ? error.message : String(error)}`);
35
+ scheduleRecovery();
36
+ });
37
+ }, delay);
38
+ recoveryTimer.unref?.();
39
+ };
40
+ const stateChanged = (snapshot) => {
41
+ try {
42
+ options.onStateChange?.(snapshot);
43
+ }
44
+ catch (error) {
45
+ options.log?.(`[proxy-supervisor] failed to publish server state: ${error instanceof Error ? error.message : String(error)}`);
46
+ }
47
+ if (listening && !closing && !snapshot.active && !snapshot.candidate) {
48
+ scheduleRecovery();
49
+ }
50
+ };
51
+ const supervisor = new RollingWorkerSupervisor({
52
+ spawnWorker: options.spawnWorker,
53
+ readyTimeoutMs: options.readyTimeoutMs,
54
+ socketQueueLimit: options.socketQueueLimit,
55
+ socketQueueTimeoutMs: options.socketQueueTimeoutMs,
56
+ shutdownTimeoutMs: options.shutdownTimeoutMs,
57
+ onStateChange: stateChanged,
58
+ log: options.log,
59
+ });
60
+ const listener = createServer({ pauseOnConnect: true }, (socket) => {
61
+ supervisor.acceptSocket(socket);
62
+ });
63
+ await new Promise((resolve, reject) => {
64
+ const onError = (error) => {
65
+ listener.off("listening", onListening);
66
+ reject(error);
67
+ };
68
+ const onListening = () => {
69
+ listener.off("error", onError);
70
+ resolve();
71
+ };
72
+ listener.once("error", onError);
73
+ listener.once("listening", onListening);
74
+ listener.listen(options.port, options.host);
75
+ }).catch(async (error) => {
76
+ await supervisor.close();
77
+ throw error;
78
+ });
79
+ listening = true;
80
+ try {
81
+ await supervisor.start(desiredVersion);
82
+ }
83
+ catch (error) {
84
+ recoveryFailures = 1;
85
+ options.log?.(`[proxy-supervisor] initial worker failed; listener will remain available during recovery: ${error instanceof Error ? error.message : String(error)}`);
86
+ }
87
+ if (!supervisor.snapshot().active) {
88
+ scheduleRecovery();
89
+ }
90
+ const address = listener.address();
91
+ if (!address || typeof address === "string") {
92
+ listener.close();
93
+ void supervisor.close();
94
+ throw ErrorFactory.proxyWorkerLifecycle("rolling proxy listener did not expose a TCP address");
95
+ }
96
+ return {
97
+ address: { host: options.host, port: address.port },
98
+ replace: async (expectedVersion) => {
99
+ // Adopt the requested version as the recovery target up front (once it is
100
+ // a valid version string) so the autonomous recovery loop converges on it
101
+ // even if this explicit call collides with an in-flight recovery
102
+ // replacement — otherwise recovery keeps re-targeting a stale version and
103
+ // starves the caller (e.g. an update rollback) for the whole retry window.
104
+ const isValidVersion = /^\d+\.\d+\.\d+$/.test(expectedVersion);
105
+ if (isValidVersion) {
106
+ desiredVersion = expectedVersion;
107
+ }
108
+ // Cancel any pending recovery so it cannot race this explicit
109
+ // replacement and spawn a second, conflicting generation.
110
+ if (recoveryTimer) {
111
+ clearTimeout(recoveryTimer);
112
+ recoveryTimer = undefined;
113
+ }
114
+ try {
115
+ const snapshot = await supervisor.replace(expectedVersion);
116
+ recoveryFailures = 0;
117
+ return snapshot;
118
+ }
119
+ catch (error) {
120
+ // The explicit replacement failed (invalid version, closed, or a
121
+ // conflicting replacement is already in progress). Since we cancelled
122
+ // the pending recovery timer above, re-schedule recovery when nothing
123
+ // is serving so a crashed worker is not left unreplaced.
124
+ const snapshot = supervisor.snapshot();
125
+ if (!snapshot.active && !snapshot.candidate) {
126
+ scheduleRecovery();
127
+ }
128
+ throw error;
129
+ }
130
+ },
131
+ snapshot: () => supervisor.snapshot(),
132
+ close: async () => {
133
+ if (closing) {
134
+ return;
135
+ }
136
+ closing = true;
137
+ listening = false;
138
+ if (recoveryTimer) {
139
+ clearTimeout(recoveryTimer);
140
+ recoveryTimer = undefined;
141
+ }
142
+ const listenerClosed = new Promise((resolve, reject) => {
143
+ listener.close((error) => (error ? reject(error) : resolve()));
144
+ });
145
+ await Promise.all([listenerClosed, supervisor.close()]);
146
+ },
147
+ };
148
+ }
149
+ //# sourceMappingURL=rollingProxyServer.js.map
@@ -0,0 +1,2 @@
1
+ import type { RollingWorkerHandle, SpawnProxySocketWorkerOptions } from "../types/index.js";
2
+ export declare function spawnProxySocketWorker(options: SpawnProxySocketWorkerOptions): RollingWorkerHandle;