spooder 5.1.12 → 6.1.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/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
- const log_cli = log_create_logger('spooder_cli');
6
+ type Config = Awaited<ReturnType<typeof get_config>>;
7
+ type ProcessRef = ReturnType<typeof Bun.spawn>;
7
8
 
8
- let restart_delay = 100;
9
- let restart_attempts = 0;
10
- let restart_success_timer: Timer | null = null;
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 start_server() {
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 (skip_updates) {
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
- log_cli(`aborting update due to non-zero exit code from [${i}]`);
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
- const run_command = is_dev_mode && config.run_dev !== '' ? config.run_dev : config.run;
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
- log_cli(`server exited with code {${proc_exit_code}}`);
143
-
144
- if (proc_exit_code !== 0) {
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
- log_cli(`[{dev}] crash: server exited unexpectedly (exit code {${proc_exit_code}}`);
149
- log_cli(`[{dev}] without {--dev}, this would raise a canary report`);
150
- log_cli(`[{dev}] console output:\n${console_output}`);
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('crash: server exited unexpectedly', [{
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 (proc_exit_code !== 0) {
163
- if (restart_success_timer) {
164
- clearTimeout(restart_success_timer);
165
- restart_success_timer = null;
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 (config.auto_restart_attempts !== -1 && restart_attempts >= config.auto_restart_attempts) {
169
- log_cli(`maximum restart attempts ({${config.auto_restart_attempts}}) reached, stopping auto-restart`);
170
- process.exit(proc_exit_code ?? 0);
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
- const current_delay = Math.min(restart_delay, config.auto_restart_max);
175
-
176
- const max_attempt_str = config.auto_restart_attempts === -1 ? '∞' : config.auto_restart_attempts;
177
- log_cli(`restarting server in {${current_delay}ms} (attempt {${restart_attempts}}/{${max_attempt_str}}, delay capped at {${config.auto_restart_max}ms})`);
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, config.auto_restart_max);
181
- restart_success_timer = setTimeout(() => {
182
- restart_delay = 100;
183
- restart_attempts = 0;
184
- restart_success_timer = null;
185
- }, config.auto_restart_grace);
186
- start_server();
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: 'bun run index.ts',
7
+ run: '',
8
8
  run_dev: '',
9
- auto_restart: false,
10
- auto_restart_max: 30000,
11
- auto_restart_attempts: -1,
12
- auto_restart_grace: 30000,
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
 
package/test.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { SQL } from 'bun';
2
+ import * as spooder from 'spooder';
3
+
4
+ type TestRow = {
5
+ ID: number;
6
+ test: string;
7
+ };
8
+
9
+ const db = new SQL('mysql://test:1141483652@localhost:3306/test');
10
+ await spooder.db_schema(db, './db/revisions');