spooder 3.0.8 → 3.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/README.md CHANGED
@@ -2,13 +2,25 @@
2
2
 
3
3
  # Spooder · ![typescript](https://img.shields.io/badge/language-typescript-blue) [![license badge](https://img.shields.io/github/license/Kruithne/spooder?color=yellow)](LICENSE) ![npm version](https://img.shields.io/npm/v/spooder?color=c53635) ![bun](https://img.shields.io/badge/runtime-bun-f9f1e1)
4
4
 
5
- `spooder` is a purpose-built web server solution written in [TypeScript](https://www.typescriptlang.org/) for [Bun](https://bun.sh/). It is designed to be highly opinionated with minimal configuration.
5
+ `spooder` is a purpose-built server solution written using the [Bun](https://bun.sh/) runtime.
6
6
 
7
- > **Warning** - This project is built with specific use-cases in mind and is not intended to be a general-purpose web server. The authors of this project are not responsible for any damage caused by using this software.
7
+ ### What does it do?
8
8
 
9
- > **Warning** - This project is developed for [Bun](https://bun.sh/), which at the time of writing is still experimental. It is not recommended to use this project in production environments unless you understand the risks.
9
+ `spooder` consists of a command-line tool which provides automatic updating/restarting and canary functionality, and a building-block API for creating servers.
10
10
 
11
- ## Installation
11
+ ### Should I use it?
12
+
13
+ Probably not. You are free to use `spooder` if you fully understand the risks and limitations of doing so, however here is a list of things you should consider before using it:
14
+
15
+ ⚠️ This is not a Node.js package. It is built using the [Bun](https://bun.sh/) runtime, which is still experimental as of writing.
16
+
17
+ ⚠️ It is designed to be highly opinionated and is not intended to be a general-purpose server, so configuration is limited.
18
+
19
+ ⚠️ It is not a full-featured web server and only provides the functionality as required for the projects it has been built for.
20
+
21
+ ⚠️ It has not been battle-tested and may contain bugs or security issues. The authors of this project are not responsible for any problems caused by using this software.
22
+
23
+ # Installation
12
24
 
13
25
  ```bash
14
26
  # Installing globally for CLI runner usage.
@@ -18,7 +30,28 @@ bun add spooder --global
18
30
  bun add spooder
19
31
  ```
20
32
 
21
- ## Runner
33
+ # Configuration
34
+
35
+ Both the runner and the API are configured in the same way by providing a `spooder` object in your `package.json` file.
36
+
37
+ ```json
38
+ {
39
+ "spooder": {
40
+ "autoRestart": 5000,
41
+ "run": "bun run index.ts",
42
+ "update": [
43
+ "git pull",
44
+ "bun install"
45
+ ]
46
+ }
47
+ }
48
+ ```
49
+
50
+ If there are any issues with the provided configuration, a warning will be printed to the console but will not halt execution. `spooder` will always fall back to default values where invalid configuration is provided.
51
+
52
+ Configuration warnings **do not** raise `caution` events with the `spooder` canary functionality.
53
+
54
+ # Runner
22
55
 
23
56
  `spooder` includes a global command-line tool for running servers. It is recommended that you run this in a `screen` session.
24
57
 
@@ -28,9 +61,9 @@ cd /var/www/my_server/
28
61
  spooder
29
62
  ```
30
63
 
31
- While the intended use of this runner is for web servers, it can be used to run anything. It provides two primary features: automatic updating and restarting.
64
+ While the intended use of this runner is for web servers, it can be used to run anything. It provides two primary features: automatic updating and automatic restarting.
32
65
 
33
- ### Entry Point
66
+ ## Entry Point
34
67
 
35
68
  `spooder` will attempt to launch the server from the current working directory using the command `bun run index.ts` as a default.
36
69
 
@@ -46,13 +79,9 @@ To customize this, provide an alternative command via the `run` configuration.
46
79
 
47
80
  While `spooder` uses a `bun run` command by default, it is possible to use any command string.
48
81
 
49
- It is possible to chain commands, such as updating your source with `git pull && bun run index.ts`, however it is recommended that `run` is only used to launch the service. Instead, use the `update` property for updating which will fail gracefully and will not block the server from starting.
82
+ ## Auto Restart
50
83
 
51
- ### Auto Restart
52
-
53
- In the event that the server exits (regardless of exit code), `spooder` will automatically restart it after a short delay.
54
-
55
- This feature is enabled by default with a delay of `5000` milliseconds. The delay can be changed by providing a value for `autoRestart` in the configuration.
84
+ In the event that the server exits (regardless of exit code), `spooder` can automatically restart it after a short delay. To enable this feature specify the restart delay in milliseconds as `autoRestart` in the configuration.
56
85
 
57
86
  ```json
58
87
  {
@@ -64,18 +93,9 @@ This feature is enabled by default with a delay of `5000` milliseconds. The dela
64
93
 
65
94
  If set to `0`, the server will be restarted immediately without delay. If set to `-1`, the server will not be restarted at all.
66
95
 
67
- ### Auto Update
96
+ ## Auto Update
68
97
 
69
- When starting your server, `spooder` can automatically update the source code in the working directory. To enable this feature, provide an update command as `update` in the configuration.
70
-
71
- ```json
72
- {
73
- "spooder": {
74
- "update": "git pull"
75
- }
76
- }
77
- ```
78
- To execute multiple commands in sequence, provide an array rather than using the `&&` operator.
98
+ When starting your server, `spooder` can automatically update the source code in the working directory. To enable this feature, the necessary update commands can be provided in the configuration as an array of strings.
79
99
 
80
100
  ```json
81
101
  {
@@ -88,7 +108,11 @@ To execute multiple commands in sequence, provide an array rather than using the
88
108
  }
89
109
  ```
90
110
 
91
- If a command in the sequence fails, the remaining commands will not be executed. However, if the update fails, the server will still be started. This is preferred over entering a restart loop or failing to start the server at all.
111
+ Commands will be executed in sequence, and the server will not be started until after the commands have resolved.
112
+
113
+ Each command should be a separate item in the array. Chaining commands in a single string using the `&&` or `||` operators will not work.
114
+
115
+ If a command in the sequence fails, the remaining commands will not be executed, however the server will still be started. This is preferred over entering a restart loop or failing to start the server at all.
92
116
 
93
117
  As well as being executed when the server is first started, the `update` commands are also run when `spooder` automatically restarts the server after it exits.
94
118
 
@@ -101,13 +125,246 @@ events.on('receive-webhook', () => {
101
125
  });
102
126
  ```
103
127
 
104
- ## API
128
+ ## Canary
129
+
130
+ `canary` is a feature in `spooder` which allows server problems to be raised as issues in your repository on GitHub.
131
+
132
+ To enable this feature, there are a couple of steps you need to take.
133
+
134
+ ### 1. Create a GitHub App
135
+
136
+ Create a new GitHub App either on your personal account or on an organization. The app will need the following permissions:
137
+
138
+ - **Issues** - Read & Write
139
+ - **Metadata** - Read-only
140
+
141
+ Once created, install the GitHub App to your account. The app will need to be given access to the repositories you want to use the canary feature with.
142
+
143
+ In addition to the **App ID** that is assigned automatically, you will also need to generate a **Private Key** for the app. This can be done by clicking the **Generate a private key** button on the app page.
144
+
145
+ > Note: The private keys provided by GitHub are in PKCS#1 format, but only PKCS#8 is supported. You can convert the key file with the following command.
146
+
147
+ ```bash
148
+ openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private-key.pem -out private-key-pkcs8.key
149
+ ```
150
+
151
+ Each server that intends to use the canary feature will need to have the private key installed somewhere the server process can access it.
152
+
153
+ ### 2. Add package.json configuration
154
+
155
+ ```json
156
+ "spooder": {
157
+ "canary": {
158
+ "account": "<GITHUB_ACCOUNT_NAME>",
159
+ "repository": "<GITHUB_REPOSITORY>",
160
+ "labels": ["some-label"]
161
+ }
162
+ }
163
+ ```
105
164
 
106
- `spooder` exposes a simple API which can be imported into your project for bootstrapping a server in Bun. The API is designed to be minimal to leave control in the hands of the developer and not add overhead for features you may not need.
165
+ Replace `<GITHUB_ACCOUNT_NAME>` with the account name you have installed the GitHub App to, and `<GITHUB_REPOSITORY>` with the repository name you want to use for issues.
166
+
167
+ The repository name must in the format `owner/repo` (e.g. `facebook/react`).
168
+
169
+ The `labels` property can be used to provide a list of labels to automatically add to the issue. This property is optional and can be omitted.
170
+
171
+ ### 3. Setup environment variables
172
+
173
+ The following two environment variables must be defined on the server.
174
+
175
+ ```
176
+ SPOODER_CANARY_APP_ID=1234
177
+ SPOODER_CANARY_KEY=/home/bond/.ssh/id_007_pcks8.key
178
+ ```
179
+
180
+ `SPOODER_CANARY_APP_ID` is the **App ID** as shown on the GitHub App page.
181
+ `SPOODER_CANARY_KEY` is the path to the private key file in PKCS#8 format.
182
+
183
+ ### 4. Use canary
184
+
185
+ Once configured, `spooder` will automatically raise an issue when the server exits with a non-zero exit code.
186
+
187
+ In addition, you can manually raise issues using the `spooder` API by calling `caution()` or `panic()`. More information about these functions can be found in the `API` section.
188
+
189
+ ## Crash
190
+
191
+ It is recommended that you harden your server code against unexpected exceptions and use `panic()` and `caution()` to raise issues with selected diagnostic information.
192
+
193
+ In the event that the server does encounter an unexpected exception which causes it to exit with a non-zero exit code, `spooder` will automatically raise an issue on GitHub using the canary feature, if configured.
194
+
195
+ Since this issue has been caught externally, `spooder` has no context of the exception which was raised. Instead, the canary report will contain the output from `stderr`.
196
+
197
+ ```json
198
+ {
199
+ "exitCode": 1,
200
+ "stderr": [
201
+ "[2.48ms] \".env.local\"",
202
+ "Test output",
203
+ "Test output",
204
+ "4 | console.warn('Test output');",
205
+ "5 | ",
206
+ "6 | // Create custom error class.",
207
+ "7 | class TestError extends Error {",
208
+ "8 | constructor(message: string) {",
209
+ "9 | super(message);",
210
+ " ^",
211
+ "TestError: Home is [IPv4 address]",
212
+ " at new TestError (/mnt/i/spooder/test.ts:9:2)",
213
+ " at /mnt/i/spooder/test.ts:13:6",
214
+ ""
215
+ ]
216
+ }
217
+ ```
218
+
219
+ This information is subject to sanitization, as described in the `Sanitization` section, however you should be aware that stack traces may contain sensitive information.
220
+
221
+ Additionally, Bun includes a relevant code snippet from the source file where the exception was raised. This is intended to help you identify the source of the problem.
222
+
223
+ ## Sanitization
224
+
225
+ All reports sent via the canary feature are sanitized to prevent sensitive information from being leaked. This includes:
226
+
227
+ - Environment variables from `.env.local`
228
+ - IPv4 / IPv6 addresses.
229
+ - E-mail addresses.
230
+
231
+ ```bash
232
+ # .env.local
233
+ DB_PASSWORD=secret
234
+ ```
107
235
 
108
236
  ```ts
109
- import serve from 'spooder'; // WIP
237
+ await panic({
238
+ a: 'foo',
239
+ b: process.env.DB_PASSWORD,
240
+ c: 'Hello person@place.net',
241
+ d: 'Client: 192.168.1.1'
242
+ });
243
+ ```
244
+
245
+ ```json
246
+ [
247
+ {
248
+ "a": "foo",
249
+ "b": "[redacted]",
250
+ "c": "Hello [e-mail address]",
251
+ "d": "Client: [IPv4 address]"
252
+ }
253
+ ]
110
254
  ```
111
255
 
256
+ The sanitization behavior can be disabled by setting `spooder.canary.sanitize` to `false` in the configuration. This is not recommended as it may leak sensitive information.
257
+
258
+ ```json
259
+ {
260
+ "spooder": {
261
+ "canary": {
262
+ "sanitize": false
263
+ }
264
+ }
265
+ }
266
+ ```
267
+
268
+ While this sanitization adds a layer of protection against information leaking, it does not catch everything. You should pay special attention to messages and objects provided to the canary to not unintentionally leak sensitive information.
269
+
270
+ ## System Information
271
+
272
+ In addition to the information provided by the developer, `spooder` also includes some system information in the canary reports.
273
+
274
+ ```json
275
+ {
276
+ "loadavg": [
277
+ 0,
278
+ 0,
279
+ 0
280
+ ],
281
+ "memory": {
282
+ "free": 7620907008,
283
+ "total": 8261840896
284
+ },
285
+ "platform": "linux",
286
+ "uptime": 7123,
287
+ "versions": {
288
+ "node": "18.15.0",
289
+ "bun": "0.6.5",
290
+ "webkit": "60d11703a533fd694cd1d6ddda04813eecb5d69f",
291
+ "boringssl": "b275c5ce1c88bc06f5a967026d3c0ce1df2be815",
292
+ "libarchive": "dc321febde83dd0f31158e1be61a7aedda65e7a2",
293
+ "mimalloc": "3c7079967a269027e438a2aac83197076d9fe09d",
294
+ "picohttpparser": "066d2b1e9ab820703db0837a7255d92d30f0c9f5",
295
+ "uwebsockets": "70b1b9fc1341e8b791b42c5447f90505c2abe156",
296
+ "zig": "0.11.0-dev.2571+31738de28",
297
+ "zlib": "885674026394870b7e7a05b7bf1ec5eb7bd8a9c0",
298
+ "tinycc": "2d3ad9e0d32194ad7fd867b66ebe218dcc8cb5cd",
299
+ "lolhtml": "2eed349dcdfa4ff5c19fe7c6e501cfd687601033",
300
+ "ares": "0e7a5dee0fbb04080750cf6eabbe89d8bae87faa",
301
+ "usockets": "fafc241e8664243fc0c51d69684d5d02b9805134",
302
+ "v8": "10.8.168.20-node.8",
303
+ "uv": "1.44.2",
304
+ "napi": "8",
305
+ "modules": "108"
306
+ }
307
+ }
308
+ ```
309
+
310
+ # API
311
+
312
+ `spooder` exposes a build-block style API for developing servers. The API is designed to be minimal to leave control in the hands of the developer and not add overhead for features you may not need.
313
+
314
+ ```ts
315
+ import { ... } from 'spooder';
316
+ ```
317
+
318
+ #### `caution(err_message_or_obj: string | object, ...err: object[]): Promise<void>`
319
+ Raise a warning issue on GitHub. This is useful for non-fatal errors which you want to be notified about.
320
+
321
+ ```ts
322
+ try {
323
+ // connect to database
324
+ } catch (e) {
325
+ await caution('Failed to connect to database', e);
326
+ }
327
+ ```
328
+
329
+ Providing a custom error message is optional and can be omitted. Additionally you can also provide additional error objects which will be serialized to JSON and included in the report.
330
+
331
+ ```ts
332
+ caution(e); // provide just the error
333
+ caution(e, { foo: 42 }); // additional data
334
+ caution('Custom error', e, { foo: 42 }); // all
335
+ ```
336
+
337
+ To prevent spam, issues raised with `caution()` are rate-limited based on a configurable threshold in seconds. By default, the threshold is set to 24 hours per unique issue.
338
+
339
+ ```json
340
+ {
341
+ "spooder": {
342
+ "canary": {
343
+ "throttle": 86400
344
+ }
345
+ }
346
+ }
347
+ ```
348
+
349
+ Issues are considered unique by the `err_message` parameter, so it is recommended that you do not include any dynamic information in this parameter that would prevent the issue from being unique.
350
+
351
+ If you need to provide unique information, you can use the `err` parameter to provide an object which will be serialized to JSON and included in the issue body.
352
+
353
+ ```ts
354
+ const some_important_value = Math.random();
355
+
356
+ // Bad: Do not use dynamic information in err_message.
357
+ await caution('Error with number ' + some_important_value);
358
+
359
+ // Good: Use err parameter to provide dynamic information.
360
+ await caution('Error with number', { some_important_value });
361
+ ```
362
+ It is not required that you `await` the `caution()`, and in situations where parallel processing is required, it is recommended that you do not.
363
+
364
+ #### `panic(err_message_or_obj: string | object, ...err: object[]): Promise<void>`
365
+ This behaves the same as `caution()` with the difference that once `panic()` has raised the issue, it will exit the process with a non-zero exit code.
366
+
367
+ This should only be called in worst-case scenarios where the server cannot continue to run. Since the process will exit, it is recommended that you `await` the `panic()` call.
368
+
112
369
  ## License
113
370
  The code in this repository is licensed under the ISC license. See the [LICENSE](LICENSE) file for more information.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "spooder",
3
3
  "type": "module",
4
- "version": "3.0.8",
4
+ "version": "3.1.0",
5
5
  "exports": {
6
6
  ".": {
7
7
  "bun": "./src/api.ts",
@@ -15,5 +15,8 @@
15
15
  },
16
16
  "bin": {
17
17
  "spooder": "./src/cli.ts"
18
+ },
19
+ "dependencies": {
20
+ "@octokit/app": "^13.1.5"
18
21
  }
19
22
  }
package/src/api.d.ts CHANGED
@@ -1 +1,2 @@
1
- export declare function test_api(): void;
1
+ export declare function panic(err_message_or_obj: string | object, ...err: object[]): Promise<void>;
2
+ export declare function caution(err_message_or_obj: string | object, ...err: object[]): Promise<void>;
package/src/api.ts CHANGED
@@ -1,3 +1,39 @@
1
- export function test_api() {
2
- console.log('test_api :: hello world!');
1
+ import { dispatch_report } from './dispatch';
2
+
3
+ async function handle_error(prefix: string, err_message_or_obj: string | object, ...err: unknown[]): Promise<void> {
4
+ let error_message = 'unknown error';
5
+
6
+ if (typeof err_message_or_obj === 'string') {
7
+ error_message = err_message_or_obj;
8
+ err.unshift(error_message);
9
+ } else {
10
+ if (err_message_or_obj instanceof Error)
11
+ error_message = err_message_or_obj.message;
12
+
13
+ err.push(err_message_or_obj);
14
+ }
15
+
16
+ // Serialize error objects.
17
+ err = err.map(e => {
18
+ if (e instanceof Error) {
19
+ return {
20
+ name: e.name,
21
+ message: e.message,
22
+ stack: e.stack?.split('\n') ?? []
23
+ }
24
+ }
25
+
26
+ return e;
27
+ })
28
+
29
+ await dispatch_report(prefix + error_message, err);
30
+ }
31
+
32
+ export async function panic(err_message_or_obj: string | object, ...err: object[]): Promise<void> {
33
+ await handle_error('panic: ', err_message_or_obj, ...err);
34
+ process.exit(1);
35
+ }
36
+
37
+ export async function caution(err_message_or_obj: string | object, ...err: object[]): Promise<void> {
38
+ await handle_error('caution: ', err_message_or_obj, ...err);
3
39
  }
package/src/cli.ts CHANGED
@@ -1,87 +1,25 @@
1
1
  #!/usr/bin/env bun
2
- import path from 'node:path';
3
-
4
- type Config = Record<string, unknown>;
5
-
6
- async function load_config(): Promise<Config> {
7
- try {
8
- const config_file = Bun.file(path.join(process.cwd(), 'package.json'));
9
- const json = await config_file.json();
10
-
11
- return json?.spooder ?? {};
12
- } catch (e) {
13
- return {};
14
- }
15
- }
16
-
17
- function parse_command(command: string): string[] {
18
- const args = [];
19
- let current_arg = '';
20
- let in_quotes = false;
21
- let in_escape = false;
22
-
23
- for (let i = 0; i < command.length; i++) {
24
- const char = command[i];
25
-
26
- if (in_escape) {
27
- current_arg += char;
28
- in_escape = false;
29
- continue;
30
- }
31
-
32
- if (char === '\\') {
33
- in_escape = true;
34
- continue;
35
- }
36
-
37
- if (char === '"') {
38
- in_quotes = !in_quotes;
39
- continue;
40
- }
41
-
42
- if (char === ' ' && !in_quotes) {
43
- args.push(current_arg);
44
- current_arg = '';
45
- continue;
46
- }
47
-
48
- current_arg += char;
49
- }
50
-
51
- if (current_arg.length > 0)
52
- args.push(current_arg);
53
-
54
- return args;
55
- }
56
-
57
- function log(message: string, ...args: unknown[]): void {
58
- console.log('[spooder] ' + message, ...args);
59
- }
60
-
61
- const config = await load_config();
62
- const config_run_command = config.run as string ?? 'bun run index.ts';
63
- const config_auto_restart_ms = config.autoRestart as number ?? 5000;
64
-
65
- let config_update_commands = [] as string[];
66
- if (config.update) {
67
- if (typeof config.update === 'string')
68
- config_update_commands = [config.update]
69
- else if (Array.isArray(config.update))
70
- config_update_commands = config.update;
71
- }
2
+ import { get_config } from './config';
3
+ import { parse_command_line, log, strip_color_codes } from './utils';
4
+ import { dispatch_report } from './dispatch';
72
5
 
73
6
  async function start_server() {
74
7
  log('start_server');
75
8
 
76
- if (config_update_commands.length > 0) {
77
- log('running %d update commands', config_update_commands.length);
9
+ const config = await get_config();
10
+
11
+ const update_commands = config.update;
12
+ const n_update_commands = update_commands.length;
78
13
 
79
- for (let i = 0; i < config_update_commands.length; i++) {
80
- const config_update_command = config_update_commands[i];
14
+ if (n_update_commands > 0) {
15
+ log('running %d update commands', n_update_commands);
16
+
17
+ for (let i = 0; i < n_update_commands; i++) {
18
+ const config_update_command = update_commands[i];
81
19
 
82
20
  log('[%d] %s', i, config_update_command);
83
21
 
84
- const update_proc = Bun.spawn(parse_command(config_update_command), {
22
+ const update_proc = Bun.spawn(parse_command_line(config_update_command), {
85
23
  cwd: process.cwd(),
86
24
  stdout: 'inherit',
87
25
  stderr: 'inherit'
@@ -98,17 +36,35 @@ async function start_server() {
98
36
  }
99
37
  }
100
38
 
101
- Bun.spawn(parse_command(config_run_command), {
39
+ Bun.spawn(parse_command_line(config.run), {
102
40
  cwd: process.cwd(),
103
41
  stdout: 'inherit',
104
- stderr: 'inherit',
42
+ stderr: 'pipe',
105
43
 
106
44
  onExit: (proc, exitCode, signal) => {
107
45
  log('server exited with code %d', exitCode);
108
46
 
109
- if (config_auto_restart_ms > -1) {
110
- log('restarting server in %dms', config_auto_restart_ms);
111
- setTimeout(start_server, config_auto_restart_ms);
47
+ if (exitCode !== null && exitCode > 0) {
48
+ if (proc.stderr !== undefined) {
49
+ const res = new Response(proc.stderr as ReadableStream);
50
+
51
+ res.text().then(async stderr => {
52
+ await dispatch_report('crash: server exited unexpectedly', [{
53
+ exitCode,
54
+ stderr: strip_color_codes(stderr).split(/\r?\n/)
55
+ }]);
56
+ });
57
+ } else {
58
+ dispatch_report('crash: service exited unexpectedly', [{
59
+ exitCode
60
+ }]);
61
+ }
62
+ }
63
+
64
+ const auto_restart_ms = config.autoRestart;
65
+ if (auto_restart_ms > -1) {
66
+ log('restarting server in %dms', auto_restart_ms);
67
+ setTimeout(start_server, auto_restart_ms);
112
68
  }
113
69
  }
114
70
  });
@@ -0,0 +1,15 @@
1
+ declare const internal_config: {
2
+ run: string;
3
+ autoRestart: number;
4
+ update: never[];
5
+ canary: {
6
+ account: string;
7
+ repository: string;
8
+ labels: never[];
9
+ throttle: number;
10
+ sanitize: boolean;
11
+ };
12
+ };
13
+ type Config = typeof internal_config;
14
+ export declare function get_config(): Promise<Config>;
15
+ export {};
package/src/config.ts ADDED
@@ -0,0 +1,78 @@
1
+ import path from 'node:path';
2
+ import { warn } from './utils';
3
+
4
+ const internal_config = {
5
+ run: 'bun run index.ts',
6
+ autoRestart: -1,
7
+ update: [],
8
+ canary: {
9
+ account: '',
10
+ repository: '',
11
+ labels: [],
12
+ throttle: 86400,
13
+ sanitize: true
14
+ }
15
+ };
16
+
17
+ type Config = typeof internal_config;
18
+ type ConfigObject = Record<string, unknown>;
19
+
20
+ function validate_config_option(source: ConfigObject, target: ConfigObject, root_name: string) {
21
+ for (const [key, value] of Object.entries(target)) {
22
+ const key_name = `${root_name}.${key}`;
23
+ if (key in source) {
24
+ const default_value = source[key as keyof Config];
25
+ const expected_type = typeof default_value;
26
+
27
+ const actual_type = typeof value;
28
+
29
+ if (actual_type !== expected_type) {
30
+ warn('ignoring invalid configuration value `%s` (expected %s, got %s)', key_name, expected_type, actual_type);
31
+ continue;
32
+ }
33
+
34
+ if (actual_type === 'object') {
35
+ const is_default_array = Array.isArray(default_value);
36
+ const is_actual_array = Array.isArray(value);
37
+
38
+ if (is_default_array) {
39
+ if (!is_actual_array) {
40
+ warn('ignoring invalid configuration value `%s` (expected array)', key_name);
41
+ continue;
42
+ }
43
+
44
+ source[key as keyof Config] = value as Config[keyof Config];
45
+ } else {
46
+ if (is_actual_array) {
47
+ warn('ignoring invalid configuration value `%s` (expected object)', key_name);
48
+ continue;
49
+ }
50
+
51
+ validate_config_option(default_value as ConfigObject, value as ConfigObject, key_name);
52
+ }
53
+ } else {
54
+ source[key as keyof Config] = value as Config[keyof Config];
55
+ }
56
+ } else {
57
+ warn('ignoring unknown configuration key `%s`', key_name);
58
+ }
59
+ }
60
+ }
61
+
62
+ export async function get_config(): Promise<Config> {
63
+ try {
64
+ const config_file = Bun.file(path.join(process.cwd(), 'package.json'));
65
+ const json = await config_file.json();
66
+
67
+ if (json.spooder === null || typeof json.spooder !== 'object') {
68
+ warn('failed to parse spooder configuration in package.json, using defaults');
69
+ return internal_config;
70
+ }
71
+
72
+ validate_config_option(internal_config, json.spooder, 'spooder');
73
+ } catch (e) {
74
+ warn('failed to read package.json, using configuration defaults');
75
+ }
76
+
77
+ return internal_config;
78
+ }
@@ -0,0 +1 @@
1
+ export declare function dispatch_report(report_title: string, report_body: Array<unknown>): Promise<void>;
@@ -0,0 +1,223 @@
1
+ import { App } from '@octokit/app';
2
+ import { get_config } from './config';
3
+ import { warn, log } from './utils';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+
8
+ async function load_local_env(): Promise<Map<string, string>> {
9
+ const env = new Map<string, string>();
10
+
11
+ const env_file = Bun.file(path.join(process.cwd(), '.env.local'));
12
+
13
+ if (env_file.size > 0) {
14
+ const env_text = await env_file.text();
15
+ const env_lines = env_text.split(/\r?\n/);
16
+
17
+ for (const line of env_lines) {
18
+ // Empty lines / comments
19
+ if (line.length === 0 || line.startsWith('#'))
20
+ continue;
21
+
22
+ const separator_index = line.indexOf('=');
23
+ if (separator_index === -1)
24
+ continue;
25
+
26
+ const key = line.slice(0, separator_index).trim();
27
+ let value = line.slice(separator_index + 1).trim();
28
+
29
+ // Strip quotes.
30
+ if (value.startsWith('"') && value.endsWith('"'))
31
+ value = value.slice(1, -1);
32
+ else if (value.startsWith("'") && value.endsWith("'"))
33
+ value = value.slice(1, -1);
34
+
35
+ env.set(key, value);
36
+ }
37
+ }
38
+
39
+ return env;
40
+ }
41
+
42
+ async function save_cache_table(table: Map<bigint, number>, cache_file_path: string): Promise<void> {
43
+ const data = Buffer.alloc(4 + (table.size * 12));
44
+
45
+ let offset = 4;
46
+ data.writeUInt32LE(table.size, 0);
47
+
48
+ for (const [key, value] of table.entries()) {
49
+ data.writeBigUint64LE(key, offset);
50
+ offset += 8;
51
+
52
+ data.writeUInt32LE(value, offset);
53
+ offset += 4;
54
+ }
55
+
56
+ await new Promise(resolve => fs.mkdir(path.dirname(cache_file_path), { recursive: true }, resolve));
57
+ await Bun.write(cache_file_path, data);
58
+ }
59
+
60
+ async function check_cache_table(key: string, repository: string, expiry: number): Promise<boolean> {
61
+ if (expiry === 0)
62
+ return false;
63
+
64
+ const [owner, repo] = repository.split('/');
65
+ const cache_file_path = path.join(os.tmpdir(), 'spooder_canary', owner, repo, 'cache.bin');
66
+
67
+ const cache_table = new Map<bigint, number>();
68
+ const key_hash = BigInt(Bun.hash.wyhash(key));
69
+
70
+ const time_now = Math.floor(Date.now() / 1000);
71
+ const expiry_threshold = time_now - expiry;
72
+
73
+ let changed = false;
74
+ try {
75
+ const cache_file = Bun.file(cache_file_path);
76
+
77
+ if (cache_file.size > 0) {
78
+ const data = Buffer.from(await cache_file.arrayBuffer());
79
+ const entry_count = data.readUInt32LE(0);
80
+
81
+ let offset = 4;
82
+ for (let i = 0; i < entry_count; i++) {
83
+ const hash = data.readBigUInt64LE(offset);
84
+ offset += 8;
85
+
86
+ const expiry = data.readUInt32LE(offset);
87
+ offset += 4;
88
+
89
+ if (expiry >= expiry_threshold)
90
+ cache_table.set(hash, expiry);
91
+ else
92
+ changed = true;
93
+ }
94
+ }
95
+ } catch (e) {
96
+ warn('Failed to read canary cache file ' + cache_file_path);
97
+ warn('Error: ' + (e as Error).message);
98
+ warn('You should resolve this issue to prevent spamming GitHub with canary reports.');
99
+ }
100
+
101
+ if (cache_table.has(key_hash)) {
102
+ if (changed)
103
+ await save_cache_table(cache_table, cache_file_path);
104
+
105
+ return true;
106
+ }
107
+
108
+ cache_table.set(key_hash, time_now);
109
+ await save_cache_table(cache_table, cache_file_path);
110
+
111
+ return false;
112
+ }
113
+
114
+ function sanitize_string(input: string, local_env?: Map<string, string>): string {
115
+ // Strip all potential e-mail addresses.
116
+ input = input.replaceAll(/([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+)/g, '[e-mail address]');
117
+
118
+ // Strip IPv4 addresses.
119
+ input = input.replaceAll(/([0-9]{1,3}\.){3}[0-9]{1,3}/g, '[IPv4 address]');
120
+
121
+ // Strip IPv6 addresses.
122
+ input = input.replaceAll(/([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}/g, '[IPv6 address]');
123
+
124
+ // Strip local environment variables.
125
+ if (local_env !== undefined) {
126
+ // Do not expose the name of the key redacted, as this may inadvertently expose the key/value
127
+ // if the value coincidentally appears in some other context.
128
+ for (const value of local_env.values())
129
+ input = input.replaceAll(value, '[redacted]');
130
+ }
131
+
132
+ return input;
133
+ }
134
+
135
+ function generate_diagnostics(): object {
136
+ return {
137
+ 'loadavg': os.loadavg(),
138
+ 'memory': {
139
+ 'free': os.freemem(),
140
+ 'total': os.totalmem(),
141
+ },
142
+ 'platform': os.platform(),
143
+ 'uptime': os.uptime(),
144
+ 'versions': process.versions,
145
+ }
146
+ }
147
+
148
+ export async function dispatch_report(report_title: string, report_body: Array<unknown>): Promise<void> {
149
+ try {
150
+ const config = await get_config();
151
+
152
+ const canary_account = config.canary.account.toLowerCase();
153
+ const canary_repostiory = config.canary.repository.toLowerCase();
154
+ const canary_labels = config.canary.labels;
155
+
156
+ if (canary_account.length === 0|| canary_repostiory.length === 0)
157
+ return;
158
+
159
+ const is_cached = await check_cache_table(report_title, canary_repostiory, config.canary.throttle);
160
+ if (is_cached) {
161
+ warn('Throttled canary report: ' + report_title);
162
+ return;
163
+ }
164
+
165
+ const canary_app_id = process.env.SPOODER_CANARY_APP_ID as string;
166
+ const canary_app_key = process.env.SPOODER_CANARY_KEY as string;
167
+
168
+ if (canary_app_id === undefined)
169
+ throw new Error('dispatch_report() called without SPOODER_CANARY_APP_ID environment variable set');
170
+
171
+ if (canary_app_key === undefined)
172
+ throw new Error('dispatch_report() called without SPOODER_CANARY_KEY environment variable set');
173
+
174
+ const key_file = Bun.file(canary_app_key);
175
+ if (key_file.size === 0)
176
+ throw new Error('dispatch_report() failed to read canary private key file');
177
+
178
+ const app_id = parseInt(canary_app_id, 10);
179
+ 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');
188
+
189
+ const post_object = {
190
+ title: report_title,
191
+ body: '',
192
+ labels: canary_labels
193
+ };
194
+
195
+ report_body.push(generate_diagnostics());
196
+
197
+ if (config.canary.sanitize) {
198
+ 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);
201
+ } else {
202
+ post_object.body = JSON.stringify(report_body, null, 4);
203
+ }
204
+
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.*';
206
+
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
+ }
220
+ } catch (e) {
221
+ warn('Failed to dispatch canary report: ' + (e as Error)?.message ?? 'unspecified error');
222
+ }
223
+ }
package/src/utils.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ /** Logs a message to stdout with the prefix `[spooder] ` */
2
+ export declare function log(message: string, ...args: unknown[]): void;
3
+ /** Logs a message to stderr with the prefix `[spooder] ` */
4
+ export declare function warn(message: string, ...args: unknown[]): void;
5
+ /** Strips ANSI color codes from a string */
6
+ export declare function strip_color_codes(str: string): string;
7
+ /** Converts a command line string into an array of arguments */
8
+ export declare function parse_command_line(command: string): string[];
package/src/utils.ts ADDED
@@ -0,0 +1,55 @@
1
+ /** Logs a message to stdout with the prefix `[spooder] ` */
2
+ export function log(message: string, ...args: unknown[]): void {
3
+ console.log('[spooder] ' + message, ...args);
4
+ }
5
+
6
+ /** Logs a message to stderr with the prefix `[spooder] ` */
7
+ export function warn(message: string, ...args: unknown[]): void {
8
+ console.error('[spooder] ' + message, ...args);
9
+ }
10
+
11
+ /** Strips ANSI color codes from a string */
12
+ export function strip_color_codes(str: string): string {
13
+ return str.replace(/\x1b\[[0-9;]*m/g, '');
14
+ }
15
+
16
+ /** Converts a command line string into an array of arguments */
17
+ export function parse_command_line(command: string): string[] {
18
+ const args = [];
19
+ let current_arg = '';
20
+ let in_quotes = false;
21
+ let in_escape = false;
22
+
23
+ for (let i = 0; i < command.length; i++) {
24
+ const char = command[i];
25
+
26
+ if (in_escape) {
27
+ current_arg += char;
28
+ in_escape = false;
29
+ continue;
30
+ }
31
+
32
+ if (char === '\\') {
33
+ in_escape = true;
34
+ continue;
35
+ }
36
+
37
+ if (char === '"') {
38
+ in_quotes = !in_quotes;
39
+ continue;
40
+ }
41
+
42
+ if (char === ' ' && !in_quotes) {
43
+ args.push(current_arg);
44
+ current_arg = '';
45
+ continue;
46
+ }
47
+
48
+ current_arg += char;
49
+ }
50
+
51
+ if (current_arg.length > 0)
52
+ args.push(current_arg);
53
+
54
+ return args;
55
+ }