spooder 5.1.12 → 6.0.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/README.md +441 -58
- package/bun.lock +6 -6
- package/package.json +1 -1
- package/src/api.ts +529 -51
- package/src/cli.ts +201 -52
- package/src/config.ts +10 -5
- package/src/dispatch.ts +3 -0
package/src/cli.ts
CHANGED
|
@@ -1,13 +1,35 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { get_config } from './config';
|
|
3
3
|
import { dispatch_report } from './dispatch';
|
|
4
|
-
import { log_create_logger } from './api';
|
|
4
|
+
import { log_create_logger, IPC_OP, IPC_TARGET, EXIT_CODE, EXIT_CODE_NAMES } from './api';
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
type Config = Awaited<ReturnType<typeof get_config>>;
|
|
7
|
+
type ProcessRef = ReturnType<typeof Bun.spawn>;
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
type InstanceConfig = {
|
|
10
|
+
id: string;
|
|
11
|
+
run: string;
|
|
12
|
+
run_dev?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type Instance = {
|
|
16
|
+
process: ProcessRef;
|
|
17
|
+
ipc_listeners: Set<number>;
|
|
18
|
+
restart_delay: number;
|
|
19
|
+
restart_attempts: number;
|
|
20
|
+
restart_success_timer: Timer | null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const log_cli = log_create_logger('spooder_cli', 'spooder');
|
|
24
|
+
const log_cli_err = log_create_logger('spooder_cli', 'red');
|
|
25
|
+
|
|
26
|
+
const argv = process.argv.slice(2);
|
|
27
|
+
const is_dev_mode = argv.includes('--dev');
|
|
28
|
+
|
|
29
|
+
const instances = new Map<string, Instance>();
|
|
30
|
+
const instance_ipc_listeners = new Map<ProcessRef, Set<number>>();
|
|
31
|
+
|
|
32
|
+
let last_instance_start_time = 0;
|
|
11
33
|
|
|
12
34
|
function strip_color_codes(str: string): string {
|
|
13
35
|
return str.replace(/\x1b\[[0-9;]*m/g, '');
|
|
@@ -53,21 +75,10 @@ function parse_command_line(command: string): string[] {
|
|
|
53
75
|
return args;
|
|
54
76
|
}
|
|
55
77
|
|
|
56
|
-
async function
|
|
57
|
-
log_cli('start_server');
|
|
58
|
-
|
|
59
|
-
const argv = process.argv.slice(2);
|
|
60
|
-
const is_dev_mode = argv.includes('--dev');
|
|
61
|
-
const skip_updates = argv.includes('--no-update');
|
|
62
|
-
|
|
63
|
-
if (is_dev_mode)
|
|
64
|
-
log_cli('[{dev}] spooder has been started in {dev mode}');
|
|
65
|
-
|
|
66
|
-
const config = await get_config();
|
|
67
|
-
|
|
78
|
+
async function apply_updates(config: Config) {
|
|
68
79
|
if (is_dev_mode) {
|
|
69
80
|
log_cli('[{update}] skipping update commands in {dev mode}');
|
|
70
|
-
} else if (
|
|
81
|
+
} else if (argv.includes('--no-update')) {
|
|
71
82
|
log_cli('[{update}] skipping update commands due to {--no-update} flag');
|
|
72
83
|
} else {
|
|
73
84
|
const update_commands = config.update;
|
|
@@ -92,25 +103,90 @@ async function start_server() {
|
|
|
92
103
|
log_cli(`[{${i}}] exited with code {${update_proc.exitCode}}`);
|
|
93
104
|
|
|
94
105
|
if (update_proc.exitCode !== 0) {
|
|
95
|
-
|
|
106
|
+
log_cli_err(`aborting update due to non-zero exit code from [${i}]`);
|
|
96
107
|
break;
|
|
97
108
|
}
|
|
98
109
|
}
|
|
99
110
|
}
|
|
100
111
|
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function handle_ipc(this: { instance_id: string, config: Config }, payload: any, proc: ProcessRef) {
|
|
115
|
+
if (payload.peer === IPC_TARGET.SPOODER) {
|
|
116
|
+
if (payload.op === IPC_OP.CMSG_TRIGGER_UPDATE) {
|
|
117
|
+
await apply_updates(this.config);
|
|
118
|
+
|
|
119
|
+
const payload = { op: IPC_OP.SMSG_UPDATE_READY };
|
|
120
|
+
for (const instance of instances.values())
|
|
121
|
+
instance.process.send(payload);
|
|
122
|
+
} else if (payload.op === IPC_OP.CMSG_REGISTER_LISTENER) {
|
|
123
|
+
instance_ipc_listeners.get(proc)?.add(payload.data.op);
|
|
124
|
+
}
|
|
125
|
+
} else if (payload.peer === IPC_TARGET.BROADCAST) {
|
|
126
|
+
payload.peer = this.instance_id;
|
|
127
|
+
for (const instance of instances.values()) {
|
|
128
|
+
if (instance.process === proc)
|
|
129
|
+
continue;
|
|
130
|
+
|
|
131
|
+
if (instance.ipc_listeners.has(payload.op))
|
|
132
|
+
instance.process.send(payload);
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
const target = instances.get(payload.peer);
|
|
136
|
+
if (target !== undefined && target.ipc_listeners.has(payload.op)) {
|
|
137
|
+
payload.peer = this.instance_id;
|
|
138
|
+
target.process.send(payload);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function start_instance(instance: InstanceConfig, config: Config, update = false) {
|
|
144
|
+
if (config.instance_stagger_interval > 0) {
|
|
145
|
+
const current_time = Date.now();
|
|
146
|
+
|
|
147
|
+
if (current_time > last_instance_start_time) {
|
|
148
|
+
last_instance_start_time = current_time + config.instance_stagger_interval;
|
|
149
|
+
} else {
|
|
150
|
+
const delta = last_instance_start_time - current_time;
|
|
151
|
+
last_instance_start_time += config.instance_stagger_interval;
|
|
152
|
+
|
|
153
|
+
log_cli(`delaying {${instance.id}} for {${delta}ms} to satisfy {${config.instance_stagger_interval}ms} instance stagger`);
|
|
154
|
+
await Bun.sleep(delta);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
log_cli(`starting server instance {${instance.id}}`);
|
|
159
|
+
|
|
160
|
+
if (update)
|
|
161
|
+
await apply_updates(config);
|
|
101
162
|
|
|
102
163
|
const crash_console_history = config.canary.crash_console_history;
|
|
103
164
|
const include_crash_history = crash_console_history > 0;
|
|
104
165
|
|
|
105
166
|
const std_mode = include_crash_history ? 'pipe' : 'inherit';
|
|
106
|
-
|
|
167
|
+
|
|
168
|
+
const run_command = is_dev_mode && instance.run_dev ? instance.run_dev : instance.run;
|
|
107
169
|
const proc = Bun.spawn(parse_command_line(run_command), {
|
|
108
170
|
cwd: process.cwd(),
|
|
109
171
|
env: { ...process.env, SPOODER_ENV: is_dev_mode ? 'dev' : 'prod' },
|
|
110
172
|
stdout: std_mode,
|
|
111
|
-
stderr: std_mode
|
|
173
|
+
stderr: std_mode,
|
|
174
|
+
ipc: handle_ipc.bind({ instance_id: instance.id, config })
|
|
112
175
|
});
|
|
113
176
|
|
|
177
|
+
const ipc_listeners = new Set<number>();
|
|
178
|
+
|
|
179
|
+
const instance_state: Instance = {
|
|
180
|
+
process: proc,
|
|
181
|
+
ipc_listeners,
|
|
182
|
+
restart_delay: 100,
|
|
183
|
+
restart_attempts: 0,
|
|
184
|
+
restart_success_timer: null
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
instances.set(instance.id, instance_state);
|
|
188
|
+
instance_ipc_listeners.set(proc, ipc_listeners);
|
|
189
|
+
|
|
114
190
|
const stream_history = new Array<string>();
|
|
115
191
|
if (include_crash_history) {
|
|
116
192
|
const text_decoder = new TextDecoder();
|
|
@@ -139,51 +215,74 @@ async function start_server() {
|
|
|
139
215
|
}
|
|
140
216
|
|
|
141
217
|
const proc_exit_code = await proc.exited;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
if (
|
|
218
|
+
const instance_data = instances.get(instance.id);
|
|
219
|
+
|
|
220
|
+
if (instance_data?.restart_success_timer) {
|
|
221
|
+
clearTimeout(instance_data.restart_success_timer);
|
|
222
|
+
instance_data.restart_success_timer = null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
instances.delete(instance.id);
|
|
226
|
+
instance_ipc_listeners.delete(proc);
|
|
227
|
+
|
|
228
|
+
log_cli(`server {${instance.id}} exited with code {${proc_exit_code}} ({${EXIT_CODE_NAMES[proc_exit_code] ?? 'UNKNOWN'}})`);
|
|
229
|
+
|
|
230
|
+
let is_safe_exit = proc_exit_code === EXIT_CODE.SUCCESS || proc_exit_code === EXIT_CODE.SPOODER_AUTO_UPDATE;
|
|
231
|
+
if (!is_safe_exit) {
|
|
145
232
|
const console_output = include_crash_history ? strip_color_codes(stream_history.join('\n')) : undefined;
|
|
146
233
|
|
|
147
234
|
if (is_dev_mode) {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
235
|
+
log_cli_err(`[{dev}] crash: server {${instance.id}} exited unexpectedly (exit code {${proc_exit_code}}`);
|
|
236
|
+
log_cli_err(`[{dev}] without {--dev}, this would raise a canary report`);
|
|
237
|
+
log_cli_err(`[{dev}] console output:\n${console_output}`);
|
|
151
238
|
} else {
|
|
152
|
-
dispatch_report(
|
|
153
|
-
proc_exit_code, console_output
|
|
239
|
+
dispatch_report(`crash: server ${instance.id} exited unexpectedly`, [{
|
|
240
|
+
proc_exit_code, console_output, instance
|
|
154
241
|
}]);
|
|
155
242
|
}
|
|
156
243
|
}
|
|
157
244
|
|
|
158
|
-
if (config.auto_restart) {
|
|
245
|
+
if (config.auto_restart.enabled) {
|
|
246
|
+
const max_attempts = config.auto_restart.max_attempts;
|
|
247
|
+
const backoff_max = config.auto_restart.backoff_max;
|
|
248
|
+
|
|
159
249
|
if (is_dev_mode) {
|
|
160
250
|
log_cli(`[{dev}] auto-restart is {disabled} in {dev mode}`);
|
|
161
251
|
process.exit(proc_exit_code ?? 0);
|
|
162
|
-
} else if (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
252
|
+
} else if (is_safe_exit) {
|
|
253
|
+
const should_apply_updates = proc_exit_code !== EXIT_CODE.SPOODER_AUTO_UPDATE;
|
|
254
|
+
setImmediate(() => start_instance(instance, config, should_apply_updates));
|
|
255
|
+
} else {
|
|
256
|
+
if (!instance_data) {
|
|
257
|
+
log_cli_err(`cannot restart instance {${instance.id}}, instance data not found`);
|
|
258
|
+
return;
|
|
166
259
|
}
|
|
167
|
-
|
|
168
|
-
if (
|
|
169
|
-
|
|
170
|
-
|
|
260
|
+
|
|
261
|
+
if (instance_data.restart_success_timer) {
|
|
262
|
+
clearTimeout(instance_data.restart_success_timer);
|
|
263
|
+
instance_data.restart_success_timer = null;
|
|
171
264
|
}
|
|
172
|
-
|
|
173
|
-
restart_attempts
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
265
|
+
|
|
266
|
+
if (max_attempts !== -1 && instance_data.restart_attempts >= max_attempts) {
|
|
267
|
+
log_cli_err(`instance {${instance.id}} maximum restart attempts ({${max_attempts}}) reached, stopping auto-restart`);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
instance_data.restart_attempts++;
|
|
272
|
+
const current_delay = Math.min(instance_data.restart_delay, backoff_max);
|
|
273
|
+
const max_attempt_str = max_attempts === -1 ? '∞' : max_attempts;
|
|
274
|
+
|
|
275
|
+
log_cli(`restarting server {${instance.id}} in {${current_delay}ms} (attempt {${instance_data.restart_attempts}}/{${max_attempt_str}}, delay capped at {${backoff_max}ms})`);
|
|
276
|
+
|
|
179
277
|
setTimeout(() => {
|
|
180
|
-
restart_delay = Math.min(restart_delay * 2,
|
|
181
|
-
restart_success_timer = setTimeout(() => {
|
|
182
|
-
restart_delay = 100;
|
|
183
|
-
restart_attempts = 0;
|
|
184
|
-
restart_success_timer = null;
|
|
185
|
-
}, config.
|
|
186
|
-
|
|
278
|
+
instance_data.restart_delay = Math.min(instance_data.restart_delay * 2, backoff_max);
|
|
279
|
+
instance_data.restart_success_timer = setTimeout(() => {
|
|
280
|
+
instance_data.restart_delay = 100;
|
|
281
|
+
instance_data.restart_attempts = 0;
|
|
282
|
+
instance_data.restart_success_timer = null;
|
|
283
|
+
}, config.auto_restart.backoff_grace);
|
|
284
|
+
|
|
285
|
+
start_instance(instance, config, true);
|
|
187
286
|
}, current_delay);
|
|
188
287
|
}
|
|
189
288
|
} else {
|
|
@@ -191,4 +290,54 @@ async function start_server() {
|
|
|
191
290
|
}
|
|
192
291
|
}
|
|
193
292
|
|
|
293
|
+
async function start_server() {
|
|
294
|
+
if (is_dev_mode)
|
|
295
|
+
log_cli('[{dev}] spooder has been started in {dev mode}');
|
|
296
|
+
|
|
297
|
+
const config = await get_config();
|
|
298
|
+
|
|
299
|
+
const instances = config.instances;
|
|
300
|
+
const n_instances = instances.length;
|
|
301
|
+
|
|
302
|
+
if (instances.length > 0) {
|
|
303
|
+
const instance_map = new Map<string, number>();
|
|
304
|
+
|
|
305
|
+
for (let i = 0; i < n_instances; i++) {
|
|
306
|
+
const instance = instances[i] as InstanceConfig;
|
|
307
|
+
|
|
308
|
+
if (typeof instance.run !== 'string') {
|
|
309
|
+
log_cli_err(`cannot start instance {${instance.id}}, missing {run} property`);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (typeof instance.id !== 'string')
|
|
314
|
+
instance.id = 'instance_' + (i + 1);
|
|
315
|
+
|
|
316
|
+
const used_idx = instance_map.get(instance.id);
|
|
317
|
+
if (used_idx !== undefined) {
|
|
318
|
+
log_cli_err(`cannot start instance {${instance.id}} (index {${i}}), instance ID already in use (index {${used_idx}})`);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (instance.id.startsWith('__') && instance.id.endsWith('__')) {
|
|
323
|
+
log_cli_err(`cannot start instance {${instance.id}} using internal naming syntax`);
|
|
324
|
+
log_cli_err(`instance names with {__} prefix and suffix are reserved`);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
instance_map.set(instance.id, i);
|
|
329
|
+
start_instance(instance, config);
|
|
330
|
+
}
|
|
331
|
+
} else {
|
|
332
|
+
if (config.run.length === 0)
|
|
333
|
+
return log_cli_err(`cannot start main instance, missing {run} property`);
|
|
334
|
+
|
|
335
|
+
start_instance({
|
|
336
|
+
id: 'main',
|
|
337
|
+
run: config.run,
|
|
338
|
+
run_dev: config.run_dev !== '' ? config.run_dev : undefined
|
|
339
|
+
}, config);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
194
343
|
await start_server();
|
package/src/config.ts
CHANGED
|
@@ -4,14 +4,19 @@ import { log_create_logger } from './api';
|
|
|
4
4
|
const log_config = log_create_logger('config', 'spooder');
|
|
5
5
|
|
|
6
6
|
const internal_config = {
|
|
7
|
-
run: '
|
|
7
|
+
run: '',
|
|
8
8
|
run_dev: '',
|
|
9
|
-
auto_restart:
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
auto_restart: {
|
|
10
|
+
enabled: false,
|
|
11
|
+
backoff_max: 5 * 60 * 1000, // 5min
|
|
12
|
+
backoff_grace: 30000, // 30s
|
|
13
|
+
max_attempts: -1,
|
|
14
|
+
},
|
|
15
|
+
instances: [],
|
|
16
|
+
instance_stagger_interval: 0,
|
|
13
17
|
update: [],
|
|
14
18
|
canary: {
|
|
19
|
+
enabled: false,
|
|
15
20
|
account: '',
|
|
16
21
|
repository: '',
|
|
17
22
|
labels: [],
|
package/src/dispatch.ts
CHANGED
|
@@ -279,6 +279,9 @@ export async function dispatch_report(report_title: string, report_body: Array<u
|
|
|
279
279
|
try {
|
|
280
280
|
const config = await get_config();
|
|
281
281
|
|
|
282
|
+
if (!config.canary.enabled)
|
|
283
|
+
return;
|
|
284
|
+
|
|
282
285
|
const canary_account = config.canary.account;
|
|
283
286
|
const canary_repostiory = config.canary.repository;
|
|
284
287
|
|