spooder 3.2.7 → 4.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/src/cli.ts CHANGED
@@ -36,35 +36,56 @@ async function start_server() {
36
36
  }
37
37
  }
38
38
 
39
+ const crash_console_history = config.canary.crash_console_history;
40
+ const include_crash_history = crash_console_history > 0;
41
+
42
+ const std_mode = include_crash_history ? 'pipe' : 'inherit';
39
43
  const proc = Bun.spawn(parse_command_line(config.run), {
40
44
  cwd: process.cwd(),
41
- stdout: 'inherit',
42
- stderr: 'pipe'
45
+ stdout: std_mode,
46
+ stderr: std_mode
43
47
  });
44
48
 
45
- await proc.exited;
49
+ const stream_history = new Array<string>();
50
+ if (include_crash_history) {
51
+ const text_decoder = new TextDecoder();
46
52
 
47
- const proc_exit_code = proc.exitCode;
48
- log('server exited with code %s', proc_exit_code);
53
+ function capture_stream(stream: ReadableStream, output: NodeJS.WritableStream) {
54
+ const reader = stream.getReader();
49
55
 
50
- if (proc_exit_code !== 0) {
51
- if (proc.stderr !== undefined) {
52
- const res = new Response(proc.stderr as ReadableStream);
53
-
54
- res.text().then(async stderr => {
55
- await dispatch_report('crash: server exited unexpectedly', [{
56
- proc_exit_code,
57
- stderr: strip_color_codes(stderr).split(/\r?\n/)
58
- }]);
56
+ reader.read().then(function read_chunk(chunk: ReadableStreamDefaultReadResult<Uint8Array>) {
57
+ if (chunk.done)
58
+ return;
59
+
60
+ const chunk_str = text_decoder.decode(chunk.value);
61
+ stream_history.push(...chunk_str.split(/\r?\n/));
62
+
63
+ if (stream_history.length > crash_console_history)
64
+ stream_history.splice(0, stream_history.length - crash_console_history);
65
+
66
+ output.write(chunk.value);
67
+ reader.read().then(read_chunk);
59
68
  });
60
- } else {
61
- dispatch_report('crash: service exited unexpectedly', [{
62
- proc_exit_code
63
- }]);
64
69
  }
70
+
71
+ capture_stream(proc.stdout as ReadableStream, process.stdout);
72
+ capture_stream(proc.stderr as ReadableStream, process.stderr);
73
+ }
74
+
75
+ await proc.exited;
76
+
77
+ const proc_exit_code = proc.exitCode;
78
+ log('server exited with code %s', proc_exit_code);
79
+
80
+ if (proc_exit_code !== 0) {
81
+ const console_output = include_crash_history ? strip_color_codes(stream_history.join('\n')) : undefined;
82
+ dispatch_report('crash: server exited unexpectedly', [{
83
+ proc_exit_code, console_output
84
+ }]);
65
85
  }
86
+
66
87
 
67
- const auto_restart_ms = config.autoRestart;
88
+ const auto_restart_ms = config.auto_restart;
68
89
  if (auto_restart_ms > -1) {
69
90
  log('restarting server in %dms', auto_restart_ms);
70
91
  setTimeout(start_server, auto_restart_ms);
package/src/config.d.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  declare const internal_config: {
2
2
  run: string;
3
- autoRestart: number;
3
+ auto_restart: number;
4
4
  update: never[];
5
5
  canary: {
6
6
  account: string;
7
7
  repository: string;
8
8
  labels: never[];
9
+ crash_console_history: number;
9
10
  throttle: number;
10
11
  sanitize: boolean;
11
12
  };
package/src/config.ts CHANGED
@@ -3,12 +3,13 @@ import { log } from './utils';
3
3
 
4
4
  const internal_config = {
5
5
  run: 'bun run index.ts',
6
- autoRestart: -1,
6
+ auto_restart: -1,
7
7
  update: [],
8
8
  canary: {
9
9
  account: '',
10
10
  repository: '',
11
11
  labels: [],
12
+ crash_console_history: 64,
12
13
  throttle: 86400,
13
14
  sanitize: true
14
15
  }
@@ -17,6 +18,8 @@ const internal_config = {
17
18
  type Config = typeof internal_config;
18
19
  type ConfigObject = Record<string, unknown>;
19
20
 
21
+ let cached_config: Config | null = null;
22
+
20
23
  function validate_config_option(source: ConfigObject, target: ConfigObject, root_name: string) {
21
24
  for (const [key, value] of Object.entries(target)) {
22
25
  const key_name = `${root_name}.${key}`;
@@ -59,7 +62,7 @@ function validate_config_option(source: ConfigObject, target: ConfigObject, root
59
62
  }
60
63
  }
61
64
 
62
- export async function get_config(): Promise<Config> {
65
+ async function load_config(): Promise<Config> {
63
66
  try {
64
67
  const config_file = Bun.file(path.join(process.cwd(), 'package.json'));
65
68
  const json = await config_file.json();
@@ -75,4 +78,11 @@ export async function get_config(): Promise<Config> {
75
78
  }
76
79
 
77
80
  return internal_config;
81
+ }
82
+
83
+ export async function get_config(): Promise<Config> {
84
+ if (cached_config === null)
85
+ cached_config = await load_config();
86
+
87
+ return cached_config;
78
88
  }
package/src/dispatch.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { App } from '@octokit/app';
2
1
  import { get_config } from './config';
2
+ import { create_github_issue } from './github';
3
3
  import { log } from './utils';
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
@@ -93,9 +93,9 @@ async function check_cache_table(key: string, repository: string, expiry: number
93
93
  }
94
94
  }
95
95
  } catch (e) {
96
- log('Failed to read canary cache file ' + cache_file_path);
97
- log('Error: ' + (e as Error).message);
98
- log('You should resolve this issue to prevent spamming GitHub with canary reports.');
96
+ log('failed to read canary cache file ' + cache_file_path);
97
+ log('error: ' + (e as Error).message);
98
+ log('you should resolve this issue to prevent spamming GitHub with canary reports');
99
99
  }
100
100
 
101
101
  if (cache_table.has(key_hash)) {
@@ -142,6 +142,12 @@ function generate_diagnostics(): object {
142
142
  'platform': os.platform(),
143
143
  'uptime': os.uptime(),
144
144
  'versions': process.versions,
145
+ 'bun': {
146
+ 'version': Bun.version,
147
+ 'rev': Bun.revision,
148
+ 'memory_usage': process.memoryUsage(),
149
+ 'cpu_usage': process.cpuUsage()
150
+ }
145
151
  }
146
152
  }
147
153
 
@@ -149,16 +155,17 @@ export async function dispatch_report(report_title: string, report_body: Array<u
149
155
  try {
150
156
  const config = await get_config();
151
157
 
152
- const canary_account = config.canary.account.toLowerCase();
153
- const canary_repostiory = config.canary.repository.toLowerCase();
154
- const canary_labels = config.canary.labels;
158
+ const canary_account = config.canary.account;
159
+ const canary_repostiory = config.canary.repository;
155
160
 
156
- if (canary_account.length === 0|| canary_repostiory.length === 0)
161
+ if (canary_account.length === 0|| canary_repostiory.length === 0) {
162
+ log('[canary] report dispatch failed; no account/repository configured');
157
163
  return;
164
+ }
158
165
 
159
166
  const is_cached = await check_cache_table(report_title, canary_repostiory, config.canary.throttle);
160
167
  if (is_cached) {
161
- log('Throttled canary report: ' + report_title);
168
+ log('[canary] throttled canary report: ' + report_title);
162
169
  return;
163
170
  }
164
171
 
@@ -166,58 +173,44 @@ export async function dispatch_report(report_title: string, report_body: Array<u
166
173
  const canary_app_key = process.env.SPOODER_CANARY_KEY as string;
167
174
 
168
175
  if (canary_app_id === undefined)
169
- throw new Error('dispatch_report() called without SPOODER_CANARY_APP_ID environment variable set');
176
+ throw new Error('SPOODER_CANARY_APP_ID environment variable is not set');
170
177
 
171
178
  if (canary_app_key === undefined)
172
- throw new Error('dispatch_report() called without SPOODER_CANARY_KEY environment variable set');
179
+ throw new Error('SPOODER_CANARY_KEY environment variable is not set');
173
180
 
174
181
  const key_file = Bun.file(canary_app_key);
175
182
  if (key_file.size === 0)
176
- throw new Error('dispatch_report() failed to read canary private key file');
183
+ throw new Error('Unable to read private key file defined by SPOODER_CANARY_KEY environment variable');
177
184
 
178
185
  const app_id = parseInt(canary_app_id, 10);
179
186
  if (isNaN(app_id))
180
- throw new Error('dispatch_report() failed to parse SPOODER_CANARY_APP_ID environment variable as integer');
181
-
182
- const app = new App({
183
- appId: app_id,
184
- privateKey: await key_file.text(),
185
- });
186
-
187
- await app.octokit.request('GET /app');
187
+ throw new Error('Invalid app ID defined by SPOODER_CANARY_APP_ID environment variable');
188
188
 
189
- const post_object = {
190
- title: report_title,
191
- body: '',
192
- labels: canary_labels
193
- };
189
+ let issue_title = report_title;
190
+ let issue_body = '';
194
191
 
195
192
  report_body.push(generate_diagnostics());
196
193
 
197
194
  if (config.canary.sanitize) {
198
195
  const local_env = await load_local_env();
199
- post_object.body = sanitize_string(JSON.stringify(report_body, null, 4), local_env);
200
- post_object.title = sanitize_string(report_title, local_env);
196
+ issue_body = sanitize_string(JSON.stringify(report_body, null, 4), local_env);
197
+ issue_title = sanitize_string(report_title, local_env);
201
198
  } else {
202
- post_object.body = JSON.stringify(report_body, null, 4);
199
+ issue_body = JSON.stringify(report_body, null, 4);
203
200
  }
204
201
 
205
- post_object.body = '```json\n' + post_object.body + '\n```\n\nℹ️ *This issue has been created automatically in response to a server panic, caution or crash.*';
202
+ issue_body = '```json\n' + issue_body + '\n```\n\nℹ️ *This issue has been created automatically in response to a server panic, caution or crash.*';
206
203
 
207
- for await (const { installation } of app.eachInstallation.iterator()) {
208
- const login = (installation?.account as { login: string })?.login;
209
- if (login?.toLowerCase() !== canary_account)
210
- continue;
211
-
212
- for await (const { octokit, repository } of app.eachRepository.iterator({ installationId: installation.id })) {
213
- if (repository.full_name.toLowerCase() !== canary_repostiory)
214
- continue;
215
-
216
- await octokit.request('POST /repos/' + canary_repostiory + '/issues', post_object);
217
- log('Dispatched canary report to %s: %s', canary_repostiory, report_title);
218
- }
219
- }
204
+ await create_github_issue({
205
+ app_id,
206
+ private_key: await key_file.text(),
207
+ repository_name: canary_repostiory,
208
+ login_name: canary_account,
209
+ issue_title,
210
+ issue_body,
211
+ issue_labels: config.canary.labels
212
+ });
220
213
  } catch (e) {
221
- log('Failed to dispatch canary report: ' + (e as Error)?.message ?? 'unspecified error');
214
+ log('[canary error] ' + (e as Error)?.message ?? 'unspecified error');
222
215
  }
223
216
  }
@@ -0,0 +1,11 @@
1
+ type Issue = {
2
+ app_id: number;
3
+ private_key: string;
4
+ login_name: string;
5
+ repository_name: string;
6
+ issue_title: string;
7
+ issue_body: string;
8
+ issue_labels?: Array<string>;
9
+ };
10
+ export declare function create_github_issue(issue: Issue): Promise<void>;
11
+ export {};
package/src/github.ts ADDED
@@ -0,0 +1,121 @@
1
+ import crypto from 'node:crypto';
2
+ import { log } from './utils';
3
+
4
+ type InstallationResponse = Array<{
5
+ id: number,
6
+ account: {
7
+ login: string
8
+ },
9
+ access_tokens_url: string,
10
+ repositories_url: string
11
+ }>;
12
+
13
+ type AccessTokenResponse = {
14
+ token: string;
15
+ };
16
+
17
+ type RepositoryResponse = {
18
+ repositories: Array<{
19
+ full_name: string,
20
+ name: string,
21
+ url: string,
22
+ owner: {
23
+ login: string
24
+ }
25
+ }>
26
+ };
27
+
28
+ type IssueResponse = {
29
+ number: number,
30
+ url: string
31
+ };
32
+
33
+ type Issue = {
34
+ app_id: number,
35
+ private_key: string,
36
+ login_name: string,
37
+ repository_name: string,
38
+ issue_title: string,
39
+ issue_body: string
40
+ issue_labels?: Array<string>
41
+ };
42
+
43
+ function generate_jwt(app_id: number, private_key: string): string {
44
+ const encoded_header = Buffer.from(JSON.stringify({
45
+ alg: 'RS256',
46
+ typ: 'JWT'
47
+ })).toString('base64');
48
+
49
+ const encoded_payload = Buffer.from(JSON.stringify({
50
+ iat: Math.floor(Date.now() / 1000),
51
+ exp: Math.floor(Date.now() / 1000) + 60,
52
+ iss: app_id
53
+ })).toString('base64');
54
+
55
+ const sign = crypto.createSign('RSA-SHA256');
56
+ sign.update(encoded_header + '.' + encoded_payload);
57
+
58
+ return encoded_header + '.' + encoded_payload + '.' + sign.sign(private_key, 'base64');
59
+ }
60
+
61
+ async function request_endpoint(url: string, bearer: string, method: string = 'GET', body?: object): Promise<Response> {
62
+ return fetch(url, {
63
+ method,
64
+ body: body ? JSON.stringify(body) : undefined,
65
+ headers: {
66
+ Authorization: 'Bearer ' + bearer,
67
+ Accept: 'application/vnd.github.v3+json'
68
+ }
69
+ });
70
+ }
71
+
72
+ function check_response_is_ok(res: Response, message: string): void {
73
+ if (!res.ok)
74
+ throw new Error(message + ' (' + res.status + ' ' + res.statusText + ')');
75
+ }
76
+
77
+ export async function create_github_issue(issue: Issue): Promise<void> {
78
+ const jwt = generate_jwt(issue.app_id, issue.private_key);
79
+ const app_res = await request_endpoint('https://api.github.com/app', jwt);
80
+
81
+ check_response_is_ok(app_res, 'cannot authenticate GitHub app ' + issue.app_id);
82
+
83
+ const res_installs = await request_endpoint('https://api.github.com/app/installations', jwt);
84
+ check_response_is_ok(res_installs, 'cannot fetch GitHub app installations');
85
+
86
+ const json_installs = await res_installs.json() as InstallationResponse;
87
+
88
+ const login_name = issue.login_name.toLowerCase();
89
+ const install = json_installs.find((install) => install.account.login.toLowerCase() === login_name);
90
+
91
+ if (!install)
92
+ throw new Error('spooder-bot is not installed on account ' + login_name);
93
+
94
+ const res_access_token = await request_endpoint(install.access_tokens_url, jwt, 'POST');
95
+ check_response_is_ok(res_access_token, 'cannot fetch GitHub app access token');
96
+
97
+ const json_access_token = await res_access_token.json() as AccessTokenResponse;
98
+ const access_token = json_access_token.token;
99
+
100
+ const repositories = await request_endpoint(install.repositories_url, access_token);
101
+ check_response_is_ok(repositories, 'cannot fetch GitHub app repositories');
102
+
103
+ const repositories_json = await repositories.json() as RepositoryResponse;
104
+
105
+ const repository_name = issue.repository_name.toLowerCase();
106
+ const repository = repositories_json.repositories.find((repository) => repository.full_name.toLowerCase() === repository_name);
107
+
108
+ if (!repository)
109
+ throw new Error('spooder-bot is not installed on repository ' + repository_name);
110
+
111
+ const issue_res = await request_endpoint(repository.url + '/issues', access_token, 'POST', {
112
+ title: issue.issue_title,
113
+ body: issue.issue_body,
114
+ labels: issue.issue_labels
115
+ });
116
+
117
+ check_response_is_ok(issue_res, 'cannot create GitHub issue');
118
+
119
+ const json_issue = await issue_res.json() as IssueResponse;
120
+ log('raised canary issue #%d in %s: %s', json_issue.number, repository.full_name, json_issue.url);
121
+ }