@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.
- package/CHANGELOG.md +13 -0
- package/dist/browser/neurolink.min.js +263 -263
- package/dist/cli/commands/proxy.js +672 -182
- package/dist/cli/utils/audioPlayer.d.ts +44 -9
- package/dist/cli/utils/audioPlayer.js +162 -65
- package/dist/lib/proxy/rollingProxyServer.d.ts +3 -0
- package/dist/lib/proxy/rollingProxyServer.js +149 -0
- package/dist/lib/proxy/rollingWorkerProcess.d.ts +2 -0
- package/dist/lib/proxy/rollingWorkerProcess.js +194 -0
- package/dist/lib/proxy/rollingWorkerProtocol.d.ts +5 -0
- package/dist/lib/proxy/rollingWorkerProtocol.js +43 -0
- package/dist/lib/proxy/rollingWorkerSupervisor.d.ts +39 -0
- package/dist/lib/proxy/rollingWorkerSupervisor.js +402 -0
- package/dist/lib/proxy/socketWorkerRuntime.d.ts +14 -0
- package/dist/lib/proxy/socketWorkerRuntime.js +294 -0
- package/dist/lib/skills/skillMatcher.d.ts +7 -1
- package/dist/lib/skills/skillMatcher.js +23 -8
- package/dist/lib/types/cli.d.ts +47 -0
- package/dist/lib/types/proxy.d.ts +156 -0
- package/dist/lib/utils/errorHandling.d.ts +8 -0
- package/dist/lib/utils/errorHandling.js +20 -0
- package/dist/proxy/rollingProxyServer.d.ts +3 -0
- package/dist/proxy/rollingProxyServer.js +148 -0
- package/dist/proxy/rollingWorkerProcess.d.ts +2 -0
- package/dist/proxy/rollingWorkerProcess.js +193 -0
- package/dist/proxy/rollingWorkerProtocol.d.ts +5 -0
- package/dist/proxy/rollingWorkerProtocol.js +42 -0
- package/dist/proxy/rollingWorkerSupervisor.d.ts +39 -0
- package/dist/proxy/rollingWorkerSupervisor.js +401 -0
- package/dist/proxy/socketWorkerRuntime.d.ts +14 -0
- package/dist/proxy/socketWorkerRuntime.js +293 -0
- package/dist/skills/skillMatcher.d.ts +7 -1
- package/dist/skills/skillMatcher.js +23 -8
- package/dist/types/cli.d.ts +47 -0
- package/dist/types/proxy.d.ts +156 -0
- package/dist/utils/errorHandling.d.ts +8 -0
- package/dist/utils/errorHandling.js +20 -0
- package/package.json +3 -2
|
@@ -6,32 +6,67 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @module cli/utils/audioPlayer
|
|
8
8
|
*/
|
|
9
|
-
import type {
|
|
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:
|
|
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
|
|
21
|
-
* system audio player, and cleans up the temp file
|
|
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:
|
|
25
|
-
* - Linux:
|
|
26
|
-
*
|
|
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
|
|
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:
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
43
|
-
const
|
|
72
|
+
export function getPlayerCandidates(filePath, format, platform = process.platform) {
|
|
73
|
+
const isWav = format === "wav";
|
|
44
74
|
switch (platform) {
|
|
45
75
|
case "darwin":
|
|
46
|
-
|
|
76
|
+
// afplay decodes mp3/wav/aac/flac natively.
|
|
77
|
+
return [{ command: "afplay", args: [filePath] }];
|
|
47
78
|
case "linux":
|
|
48
|
-
if (
|
|
49
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
if (format === "wav") {
|
|
54
|
-
return {
|
|
123
|
+
return [
|
|
124
|
+
{
|
|
55
125
|
command: "powershell",
|
|
56
126
|
args: [
|
|
57
127
|
"-NoProfile",
|
|
58
128
|
"-Command",
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
79
|
-
* system audio player, and cleans up the temp file
|
|
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:
|
|
83
|
-
* - Linux:
|
|
84
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|