spooder 4.2.10 → 4.2.12

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
@@ -17,9 +17,6 @@ It consists of two components, the `CLI` and the `API`.
17
17
  - The `CLI` is responsible for keeping the server process running, applying updates in response to source control changes, and automatically raising issues on GitHub via the canary feature.
18
18
  - The `API` provides a minimal building-block style API for developing servers, with a focus on simplicity and performance.
19
19
 
20
- > [!WARNING]
21
- > `spooder` is stable but still in active development. Backwards compatibility between versions is not guaranteed and breaking changes may be introduced. Consider pinning a specific version in your `package.json`.
22
-
23
20
  # CLI
24
21
 
25
22
  The `CLI` component of `spooder` is a global command-line tool for running server processes.
@@ -49,6 +46,7 @@ The `CLI` component of `spooder` is a global command-line tool for running serve
49
46
  - [`server.handle(status_code: number, handler: RequestHandler)`](#api-routing-server-handle)
50
47
  - [`server.default(handler: DefaultHandler)`](#api-routing-server-default)
51
48
  - [`server.error(handler: ErrorHandler)`](#api-routing-server-error)
49
+ - [`server.on_slow_request(callback: SlowRequestCallback, threshold: number)`](#api-routing-server-on-slow-request)
52
50
  - [API > Routing > Directory Serving](#api-routing-directory-serving)
53
51
  - [`server.dir(path: string, dir: string, handler?: DirHandler, method: HTTP_METHODS)`](#api-routing-server-dir)
54
52
  - [API > Routing > Server-Sent Events](#api-routing-server-sent-events)
@@ -64,11 +62,15 @@ The `CLI` component of `spooder` is a global command-line tool for running serve
64
62
  - [`safe(fn: Callable): Promise<void>`](#api-error-handling-safe)
65
63
  - [API > Content](#api-content)
66
64
  - [`parse_template(template: string, replacements: Record<string, string>, drop_missing: boolean): string`](#api-content-parse-template)
67
- - [`generate_hash_subs(length: number, prefix: string): Promise<Record<string, string>>`](#api-content-generate-hash-subs)
65
+ - [`generate_hash_subs(length: number, prefix: string, hashes?: Record<string, string>): Promise<Record<string, string>>`](#api-content-generate-hash-subs)
66
+ - [`get_git_hashes(length: number): Promise<Record<string, string>>`](#api-content-get-git-hashes)
68
67
  - [`apply_range(file: BunFile, request: Request): HandlerReturnType`](#api-content-apply-range)
69
68
  - [API > State Management](#api-state-management)
70
69
  - [`set_cookie(res: Response, name: string, value: string, options?: CookieOptions)`](#api-state-management-set-cookie)
71
70
  - [`get_cookies(source: Request | Response): Record<string, string>`](#api-state-management-get-cookies)
71
+ - [API > Database Schema](#api-database-schema)
72
+ - [`db_update_schema_sqlite(db: Database, schema: string): Promise<void>`](#api-database-schema-db-update-schema-sqlite)
73
+ - [`db_init_schema_sqlite(db_path: string, schema: string): Promise<Database>`](#api-database-schema-db-init-schema-sqlite)
72
74
 
73
75
  # Installation
74
76
 
@@ -663,6 +665,27 @@ server.error((err, req, url) => {
663
665
  });
664
666
  ```
665
667
 
668
+ <a id="api-routing-server-on-slow-request"></a>
669
+ ### 🔧 `server.on_slow_request(callback: SlowRequestCallback, threshold: number)`
670
+
671
+ `server.on_slow_request` can be used to register a callback for requests that take an undesirable amount of time to process.
672
+
673
+ By default requests that take longer than `1000ms` to process will trigger the callback, but this can be adjusted by providing a custom threshold.
674
+
675
+ > [!IMPORTANT]
676
+ > If your canary reports to a public repository, be cautious about directly including the `req` object in the callback. This can lead to sensitive information being leaked.
677
+
678
+ ```ts
679
+ server.on_slow_request(async (req, time) => {
680
+ // avoid `time` in the title to avoid canary spam
681
+ // see caution() API for information
682
+ await caution('Slow request warning', { req, time });
683
+ }, 500);
684
+ ```
685
+
686
+ > [!NOTE]
687
+ > The callback is not awaited internally, so you can use `async/await` freely without blocking the server/request.
688
+
666
689
  <a id="api-routing-directory-serving"></a>
667
690
  ## API > Routing > Directory Serving
668
691
 
@@ -1076,7 +1099,7 @@ parse_template(..., {
1076
1099
  ```
1077
1100
 
1078
1101
  <a id="api-content-generate-hash-subs"></a>
1079
- ### 🔧 `generate_hash_subs(prefix: string): Promise<Record<string, string>>`
1102
+ ### 🔧 `generate_hash_subs(length: number, prefix: string, hashes?: Record<string, string>): Promise<Record<string, string>>`
1080
1103
 
1081
1104
  Generate a replacement table for mapping file paths to hashes in templates. This is useful for cache-busting static assets.
1082
1105
 
@@ -1120,6 +1143,29 @@ server.route('/test', (req, url) => {
1120
1143
  });
1121
1144
  ```
1122
1145
 
1146
+ <a id="api-content-get-git-hashes"></a>
1147
+ ### 🔧 ``get_git_hashes(length: number): Promise<Record<string, string>>``
1148
+
1149
+ Internally, `generate_hash_subs()` uses `get_git_hashes()` to retrieve the hash table from git. This function is exposed for convenience.
1150
+
1151
+ > [!IMPORTANT]
1152
+ > Internally `get_git_hashes()` uses `git ls-tree -r HEAD`, so the working directory must be a git repository.
1153
+
1154
+ ```ts
1155
+ const hashes = await get_git_hashes(7);
1156
+ // { 'docs/project-logo.png': '754d9ea' }
1157
+ ```
1158
+
1159
+ If you're using `generate_hash_subs()` and `get_git_hashes()` at the same time, it is more efficient to pass the result of `get_git_hashes()` directly to `generate_hash_subs()` to prevent redundant calls to git.
1160
+
1161
+ ```ts
1162
+ const hashes = await get_git_hashes(7);
1163
+ const subs = await generate_hash_subs(7, undefined, hashes);
1164
+
1165
+ // hashes[0] -> { 'docs/project-logo.png': '754d9ea' }
1166
+ // subs[0] -> { 'hash=docs/project-logo.png': '754d9ea' }
1167
+ ```
1168
+
1123
1169
  <a id="api-apply-range"></a>
1124
1170
  ### 🔧 `apply_range(file: BunFile, request: Request): HandlerReturnType`
1125
1171
 
@@ -1217,6 +1263,110 @@ const cookies = get_cookies(req, true);
1217
1263
  { my_test_cookie: 'my cookie value' }
1218
1264
  ```
1219
1265
 
1266
+ <a id="api-database-schema"></a>
1267
+ ## API > Database Schema
1268
+
1269
+ `spooder` provides a straightforward API to manage database schema in revisions through source control.
1270
+
1271
+ > [!NOTE]
1272
+ > Currently, only SQLite is supported. This may be expanded once Bun supports more database drivers.
1273
+
1274
+ <a id="api-database-schema-db-update-schema-sqlite"></a>
1275
+ ### 🔧 `db_update_schema_sqlite(db: Database, schema: string): Promise<void>`
1276
+
1277
+ `db_update_schema_sqlite` takes a [`Database`](https://bun.sh/docs/api/sqlite) instance and a schema directory.
1278
+
1279
+ The schema directory is expected to contain an SQL file for each table in the database with the file name matching the name of the table.
1280
+
1281
+ > [!NOTE]
1282
+ > Files without the `.sql` extension (case-insensitive) will be ignored.
1283
+
1284
+ ```
1285
+ - database.sqlite
1286
+ - schema/
1287
+ - users.sql
1288
+ - posts.sql
1289
+ - comments.sql
1290
+ ```
1291
+
1292
+ ```ts
1293
+ import { db_update_schema_sqlite } from 'spooder';
1294
+ import { Database } from 'bun:sqlite';
1295
+
1296
+ const db = new Database('./database.sqlite');
1297
+ await db_update_schema_sqlite(db, './schema');
1298
+ ```
1299
+
1300
+ Each of the SQL files should contain all of the revisions for the table, with the first revision being table creation and subsequent revisions being table modifications.
1301
+
1302
+ ```sql
1303
+ -- [1] Table creation.
1304
+ CREATE TABLE users (
1305
+ id INTEGER PRIMARY KEY,
1306
+ username TEXT NOT NULL,
1307
+ password TEXT NOT NULL
1308
+ );
1309
+
1310
+ -- [2] Add email column.
1311
+ ALTER TABLE users ADD COLUMN email TEXT;
1312
+
1313
+ -- [3] Cleanup invalid usernames.
1314
+ DELETE FROM users WHERE username = 'admin';
1315
+ DELETE FROM users WHERE username = 'root';
1316
+ ```
1317
+
1318
+ Each revision should be clearly marked with a comment containing the revision number in square brackets. Anything proceeding the revision number is treated as a comment and ignored.
1319
+
1320
+ >[!NOTE]
1321
+ > The exact revision header syntax is `^--\s*\[(\d+)\]`.
1322
+
1323
+ Everything following a revision header is considered part of that revision until the next revision header or the end of the file, allowing for multiple SQL statements to be included in a single revision.
1324
+
1325
+ When calling `db_update_schema_sqlite`, unapplied revisions will be applied in ascending order (regardless of order within the file) until the schema is up-to-date. Schema revisions are tracked in a table called `db_schema` which is created automatically if it does not exist with the following schema.
1326
+
1327
+ ```sql
1328
+ CREATE TABLE db_schema (
1329
+ db_schema_table_name TEXT PRIMARY KEY,
1330
+ db_schema_version INTEGER
1331
+ );
1332
+ ```
1333
+
1334
+ >[!IMPORTANT]
1335
+ > The entire process is transactional. If an error occurs during the application of **any** revision for **any** table, the entire process will be rolled back and the database will be left in the state it was before the update was attempted.
1336
+
1337
+ >[!IMPORTANT]
1338
+ > `db_update_schema_sqlite` will throw an error if the revisions cannot be parsed or applied for any reason. It is important you catch and handle appropriately.
1339
+
1340
+ ```ts
1341
+ try {
1342
+ const db = new Database('./database.sqlite');
1343
+ await db_update_schema_sqlite(db, './schema');
1344
+ } catch (e) {
1345
+ // panic (crash) or gracefully continue, etc.
1346
+ await panic(e);
1347
+ }
1348
+ ```
1349
+
1350
+ <a id="api-database-schema-db-init-schema-sqlite"></a>
1351
+ ### 🔧 `db_init_schema_sqlite(db_path: string, schema: string): Promise<Database>`
1352
+
1353
+ `db_init_schema_sqlite` exists as a convenience function to create a new database and apply the schema in one step.
1354
+
1355
+ ```ts
1356
+ import { db_init_schema_sqlite } from 'spooder';
1357
+ const db = await db_init_schema_sqlite('./database.sqlite', './schema');
1358
+ ```
1359
+
1360
+ The above is equivalent to the following.
1361
+
1362
+ ```ts
1363
+ import { db_update_schema_sqlite } from 'spooder';
1364
+ import { Database } from 'bun:sqlite';
1365
+
1366
+ const db = new Database('./database.sqlite', { create: true });
1367
+ await db_update_schema_sqlite(db, './schema');
1368
+ ```
1369
+
1220
1370
  ## Legal
1221
1371
  This software is provided as-is with no warranty or guarantee. The authors of this project are not responsible or liable for any problems caused by using this software or any part thereof. Use of this software does not entitle you to any support or assistance from the authors of this project.
1222
1372
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "spooder",
3
3
  "type": "module",
4
- "version": "4.2.10",
4
+ "version": "4.2.12",
5
5
  "exports": {
6
6
  ".": {
7
7
  "bun": "./src/api.ts",
package/src/api.ts CHANGED
@@ -5,6 +5,7 @@ import fs from 'node:fs/promises';
5
5
  import { log } from './utils';
6
6
  import crypto from 'crypto';
7
7
  import { Blob } from 'node:buffer';
8
+ import { Database } from 'bun:sqlite';
8
9
 
9
10
  export const HTTP_STATUS_CODE = http.STATUS_CODES;
10
11
 
@@ -76,9 +77,9 @@ async function handle_error(prefix: string, err_message_or_obj: string | object,
76
77
  }
77
78
 
78
79
  if (process.env.SPOODER_ENV === 'dev') {
79
- log('[dev] dispatch_report %s', prefix + error_message);
80
- log('[dev] without --dev, this would raise a canary report');
81
- log('[dev] %o', final_err);
80
+ log('[{dev}] dispatch_report %s', prefix + error_message);
81
+ log('[{dev}] without {--dev}, this would raise a canary report');
82
+ log('[{dev}] %o', final_err);
82
83
  } else {
83
84
  await dispatch_report(prefix + error_message, final_err);
84
85
  }
@@ -169,7 +170,7 @@ export function parse_template(template: string, replacements: Replacements, dro
169
170
  return result;
170
171
  }
171
172
 
172
- export async function generate_hash_subs(length = 7, prefix = 'hash='): Promise<Record<string, string>> {
173
+ export async function get_git_hashes(length = 7): Promise<Record<string, string>> {
173
174
  const cmd = ['git', 'ls-tree', '-r', 'HEAD'];
174
175
  const process = Bun.spawn(cmd, {
175
176
  stdout: 'pipe',
@@ -179,7 +180,7 @@ export async function generate_hash_subs(length = 7, prefix = 'hash='): Promise<
179
180
  await process.exited;
180
181
 
181
182
  if (process.exitCode as number > 0)
182
- throw new Error('generate_hash_subs() failed, `' + cmd.join(' ') + '` exited with non-zero exit code.');
183
+ throw new Error('get_git_hashes() failed, `' + cmd.join(' ') + '` exited with non-zero exit code.');
183
184
 
184
185
  const stdout = await Bun.readableStreamToText(process.stdout as ReadableStream);
185
186
  const hash_map: Record<string, string> = {};
@@ -187,15 +188,115 @@ export async function generate_hash_subs(length = 7, prefix = 'hash='): Promise<
187
188
  const regex = /([^\s]+)\s([^\s]+)\s([^\s]+)\t(.+)/g;
188
189
  let match: RegExpExecArray | null;
189
190
 
190
- let hash_count = 0;
191
- while (match = regex.exec(stdout)) {
192
- hash_map[prefix + match[4]] = match[3].substring(0, length);
193
- hash_count++;
194
- }
191
+ while (match = regex.exec(stdout))
192
+ hash_map[match[4]] = match[3].substring(0, length);
195
193
 
196
194
  return hash_map;
197
195
  }
198
196
 
197
+ export async function generate_hash_subs(length = 7, prefix = 'hash=', hashes?: Record<string, string>): Promise<Record<string, string>> {
198
+ const hash_map: Record<string, string> = {};
199
+
200
+ if (!hashes)
201
+ hashes = await get_git_hashes(length);
202
+
203
+ for (const [file, hash] of Object.entries(hashes))
204
+ hash_map[prefix + file] = hash;
205
+
206
+ return hash_map;
207
+ }
208
+
209
+ type Row_DBSchema = { db_schema_table_name: string, db_schema_version: number };
210
+
211
+ export async function db_update_schema_sqlite(db: Database, schema_dir: string) {
212
+ log('[{db}] updating database schema for {%s}', db.filename);
213
+
214
+ const schema_versions = new Map();
215
+
216
+ try {
217
+ const query = db.query('SELECT db_schema_table_name, db_schema_version FROM db_schema');
218
+ for (const row of query.all() as Array<Row_DBSchema>)
219
+ schema_versions.set(row.db_schema_table_name, row.db_schema_version);
220
+ } catch (e) {
221
+ log('[{db}] creating {db_schema} table');
222
+ db.run('CREATE TABLE db_schema (db_schema_table_name TEXT PRIMARY KEY, db_schema_version INTEGER)');
223
+ }
224
+
225
+ db.transaction(async () => {
226
+ const update_schema_query = db.prepare(`
227
+ INSERT INTO db_schema (db_schema_version, db_schema_table_name) VALUES (?1, ?2)
228
+ ON CONFLICT(db_schema_table_name) DO UPDATE SET db_schema_version = EXCLUDED.db_schema_version
229
+ `);
230
+
231
+ const schema_files = await fs.readdir(schema_dir);
232
+ for (const schema_file of schema_files) {
233
+ const schema_file_lower = schema_file.toLowerCase();
234
+ if (!schema_file_lower.endsWith('.sql'))
235
+ continue;
236
+
237
+ log('[{db}] parsing schema file {%s}', schema_file_lower);
238
+
239
+ const schema_name = path.basename(schema_file_lower, '.sql');
240
+ const schema_path = path.join(schema_dir, schema_file);
241
+ const schema = await fs.readFile(schema_path, 'utf8');
242
+
243
+ const revisions = new Map();
244
+ let current_rev_id = 0;
245
+ let current_rev = '';
246
+
247
+ for (const line of schema.split(/\r?\n/)) {
248
+ const rev_start = line.match(/^--\s*\[(\d+)\]/);
249
+ if (rev_start !== null) {
250
+ // New chunk definition detected, store the current chunk and start a new one.
251
+ if (current_rev_id > 0) {
252
+ revisions.set(current_rev_id, current_rev);
253
+ current_rev = '';
254
+ }
255
+
256
+ const rev_number = parseInt(rev_start[1]);
257
+ if (isNaN(rev_number) || rev_number < 1)
258
+ throw new Error(rev_number + ' is not a valid revision number in ' + schema_file_lower);
259
+ current_rev_id = rev_number;
260
+ } else {
261
+ // Append to existing revision.
262
+ current_rev += line + '\n';
263
+ }
264
+ }
265
+
266
+ // There may be something left in current_chunk once we reach end of the file.
267
+ if (current_rev_id > 0)
268
+ revisions.set(current_rev_id, current_rev);
269
+
270
+ if (revisions.size === 0) {
271
+ log('[{db}] {%s} contains no valid revisions', schema_file);
272
+ continue;
273
+ }
274
+
275
+ const current_schema_version = schema_versions.get(schema_name) ?? 0;
276
+ const chunk_keys = Array.from(revisions.keys()).filter(chunk_id => chunk_id > current_schema_version).sort((a, b) => a - b);
277
+
278
+ let newest_schema_version = current_schema_version;
279
+ for (const rev_id of chunk_keys) {
280
+ const revision = revisions.get(rev_id);
281
+ log('[{db}] applying revision {%d} to {%s}', rev_id, schema_name);
282
+ db.transaction(() => db.run(revision))();
283
+ newest_schema_version = rev_id;
284
+ }
285
+
286
+ if (newest_schema_version > current_schema_version) {
287
+ log('[{db}] updated table {%s} to revision {%d}', schema_name, newest_schema_version);
288
+ update_schema_query.run(newest_schema_version, schema_name);
289
+ }
290
+ }
291
+ })();
292
+ }
293
+
294
+ export async function db_init_schema_sqlite(db_path: string, schema_dir: string): Promise<Database> {
295
+ const db = new Database(db_path, { create: true });
296
+ await db_update_schema_sqlite(db, schema_dir);
297
+ return db;
298
+ }
299
+
199
300
  type CookieOptions = {
200
301
  same_site?: 'Strict' | 'Lax' | 'None',
201
302
  secure?: boolean,
@@ -251,7 +352,6 @@ export function get_cookies(source: Request | Response, decode: boolean = false)
251
352
  export function apply_range(file: BunFile, request: Request): BunFile {
252
353
  const range_header = request.headers.get('range');
253
354
  if (range_header !== null) {
254
- console.log(range_header);
255
355
  const regex = /bytes=(\d*)-(\d*)/;
256
356
  const match = range_header.match(regex);
257
357
 
@@ -348,13 +448,21 @@ function format_query_parameters(search_params: URLSearchParams): string {
348
448
  for (let [key, value] of search_params)
349
449
  result_parts.push(`${key}: ${value}`);
350
450
 
351
- return '{ ' + result_parts.join(', ') + ' }';
451
+ return '\x1b[90m( ' + result_parts.join(', ') + ' )\x1b[0m';
352
452
  }
353
453
 
354
- function print_request_info(req: Request, res: Response, url: URL, request_start: number): Response {
355
- const request_time = Date.now() - request_start;
454
+ function print_request_info(req: Request, res: Response, url: URL, request_time: number): Response {
356
455
  const search_params = url.search.length > 0 ? format_query_parameters(url.searchParams) : '';
357
- console.log(`[${res.status}] ${req.method} ${url.pathname} ${search_params} [${request_time}ms]`);
456
+
457
+ // format status code based on range (2xx is green, 4xx is yellow, 5xx is red), use ansi colors.
458
+ const status_fmt = res.status < 300 ? '\x1b[32m' : res.status < 500 ? '\x1b[33m' : '\x1b[31m';
459
+ const status_code = status_fmt + res.status + '\x1b[0m';
460
+
461
+ // format request time based on range (0-100ms is green, 100-500ms is yellow, 500ms+ is red), use ansi colors.
462
+ const time_fmt = request_time < 100 ? '\x1b[32m' : request_time < 500 ? '\x1b[33m' : '\x1b[31m';
463
+ const request_time_str = time_fmt + request_time + 'ms\x1b[0m';
464
+
465
+ log('[%s] {%s} %s %s [{%s}]', status_code, req.method, url.pathname, search_params, request_time_str);
358
466
  return res;
359
467
  }
360
468
 
@@ -478,6 +586,11 @@ export function serve(port: number) {
478
586
  }
479
587
  }
480
588
 
589
+ type SlowRequestCallback = (req: Request, request_time: number) => void;
590
+
591
+ let slow_request_callback: SlowRequestCallback | null = null;
592
+ let slow_request_threshold: number = 1000;
593
+
481
594
  const server = Bun.serve({
482
595
  port,
483
596
  development: false,
@@ -487,11 +600,16 @@ export function serve(port: number) {
487
600
  const request_start = Date.now();
488
601
 
489
602
  const response = await generate_response(req, url);
490
- return print_request_info(req, response, url, request_start);
603
+ const request_time = Date.now() - request_start;
604
+
605
+ if (slow_request_callback !== null && request_time > slow_request_threshold)
606
+ slow_request_callback(req, request_time);
607
+
608
+ return print_request_info(req, response, url, request_time);
491
609
  }
492
610
  });
493
611
 
494
- log('server started on port ' + port);
612
+ log('server started on port {%d}', port);
495
613
 
496
614
  return {
497
615
  /** Register a handler for a specific route. */
@@ -527,6 +645,12 @@ export function serve(port: number) {
527
645
  }, 'POST']);
528
646
  },
529
647
 
648
+ /** Register a callback for slow requests. */
649
+ on_slow_request: (callback: SlowRequestCallback, threshold = 1000): void => {
650
+ slow_request_callback = callback;
651
+ slow_request_threshold = threshold;
652
+ },
653
+
530
654
  /** Register a default handler for all status codes. */
531
655
  default: (handler: DefaultHandler): void => {
532
656
  default_handler = handler;
package/src/cli.ts CHANGED
@@ -10,7 +10,7 @@ async function start_server() {
10
10
  const is_dev_mode = argv.includes('--dev');
11
11
 
12
12
  if (is_dev_mode)
13
- log('[dev] spooder has been started in dev mode');
13
+ log('[{dev}] spooder has been started in {dev mode}');
14
14
 
15
15
  const config = await get_config();
16
16
 
@@ -19,12 +19,12 @@ async function start_server() {
19
19
  const n_update_commands = update_commands.length;
20
20
 
21
21
  if (n_update_commands > 0) {
22
- log('running %d update commands', n_update_commands);
22
+ log('running {%d} update commands', n_update_commands);
23
23
 
24
24
  for (let i = 0; i < n_update_commands; i++) {
25
25
  const config_update_command = update_commands[i];
26
26
 
27
- log('[%d] %s', i, config_update_command);
27
+ log('[{%d}] %s', i, config_update_command);
28
28
 
29
29
  const update_proc = Bun.spawn(parse_command_line(config_update_command), {
30
30
  cwd: process.cwd(),
@@ -34,7 +34,7 @@ async function start_server() {
34
34
 
35
35
  await update_proc.exited;
36
36
 
37
- log('[%d] exited with code %d', i, update_proc.exitCode);
37
+ log('[{%d}] exited with code {%d}', i, update_proc.exitCode);
38
38
 
39
39
  if (update_proc.exitCode !== 0) {
40
40
  log('aborting update due to non-zero exit code from [%d]', i);
@@ -43,7 +43,7 @@ async function start_server() {
43
43
  }
44
44
  }
45
45
  } else {
46
- log('[dev] skipping update commands in dev mode');
46
+ log('[{dev}] skipping update commands in {dev mode}');
47
47
  }
48
48
 
49
49
  const crash_console_history = config.canary.crash_console_history;
@@ -86,15 +86,15 @@ async function start_server() {
86
86
  await proc.exited;
87
87
 
88
88
  const proc_exit_code = proc.exitCode;
89
- log('server exited with code %s', proc_exit_code);
89
+ log('server exited with code {%s}', proc_exit_code);
90
90
 
91
91
  if (proc_exit_code !== 0) {
92
92
  const console_output = include_crash_history ? strip_color_codes(stream_history.join('\n')) : undefined;
93
93
 
94
94
  if (is_dev_mode) {
95
- log('[dev] crash: server exited unexpectedly (exit code %d)', proc_exit_code);
96
- log('[dev] without --dev, this would raise a canary report');
97
- log('[dev] console output:\n%s', console_output);
95
+ log('[{dev}] crash: server exited unexpectedly (exit code {%d})', proc_exit_code);
96
+ log('[{dev}] without {--dev}, this would raise a canary report');
97
+ log('[{dev}] console output:\n%s', console_output);
98
98
  } else {
99
99
  dispatch_report('crash: server exited unexpectedly', [{
100
100
  proc_exit_code, console_output
@@ -105,10 +105,10 @@ async function start_server() {
105
105
  const auto_restart_ms = config.auto_restart;
106
106
  if (auto_restart_ms > -1) {
107
107
  if (is_dev_mode) {
108
- log('[dev] auto-restart is disabled in dev mode');
108
+ log('[{dev}] auto-restart is {disabled} in {dev mode}');
109
109
  process.exit(proc_exit_code ?? 0);
110
110
  } else {
111
- log('restarting server in %dms', auto_restart_ms);
111
+ log('restarting server in {%dms}', auto_restart_ms);
112
112
  setTimeout(start_server, auto_restart_ms);
113
113
  }
114
114
  }
package/src/config.ts CHANGED
@@ -30,7 +30,7 @@ function validate_config_option(source: ConfigObject, target: ConfigObject, root
30
30
  const actual_type = typeof value;
31
31
 
32
32
  if (actual_type !== expected_type) {
33
- log('ignoring invalid configuration value `%s` (expected %s, got %s)', key_name, expected_type, actual_type);
33
+ log('ignoring invalid configuration value {%s} (expected {%s}, got {%s})', key_name, expected_type, actual_type);
34
34
  continue;
35
35
  }
36
36
 
@@ -40,7 +40,7 @@ function validate_config_option(source: ConfigObject, target: ConfigObject, root
40
40
 
41
41
  if (is_default_array) {
42
42
  if (!is_actual_array) {
43
- log('ignoring invalid configuration value `%s` (expected array)', key_name);
43
+ log('ignoring invalid configuration value {%s} (expected array)', key_name);
44
44
  continue;
45
45
  }
46
46
 
@@ -57,7 +57,7 @@ function validate_config_option(source: ConfigObject, target: ConfigObject, root
57
57
  source[key as keyof Config] = value as Config[keyof Config];
58
58
  }
59
59
  } else {
60
- log('ignoring unknown configuration key `%s`', key_name);
60
+ log('ignoring unknown configuration key {%s}', key_name);
61
61
  }
62
62
  }
63
63
  }
@@ -68,13 +68,13 @@ async function load_config(): Promise<Config> {
68
68
  const json = await config_file.json();
69
69
 
70
70
  if (json.spooder === null || typeof json.spooder !== 'object') {
71
- log('failed to parse spooder configuration in package.json, using defaults');
71
+ log('failed to parse spooder configuration in {package.json}, using defaults');
72
72
  return internal_config;
73
73
  }
74
74
 
75
75
  validate_config_option(internal_config, json.spooder, 'spooder');
76
76
  } catch (e) {
77
- log('failed to read package.json, using configuration defaults');
77
+ log('failed to read {package.json}, using configuration defaults');
78
78
  }
79
79
 
80
80
  return internal_config;
package/src/dispatch.ts CHANGED
@@ -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('[{canary}] failed to read canary cache file {%s}', cache_file_path);
97
+ log('[{canary}] error: ' + (e as Error).message);
98
+ log('[{canary}] resolve this issue to prevent spamming GitHub with canary reports');
99
99
  }
100
100
 
101
101
  if (cache_table.has(key_hash)) {
@@ -159,13 +159,13 @@ export async function dispatch_report(report_title: string, report_body: Array<u
159
159
  const canary_repostiory = config.canary.repository;
160
160
 
161
161
  if (canary_account.length === 0|| canary_repostiory.length === 0) {
162
- log('[canary] report dispatch failed; no account/repository configured');
162
+ log('[{canary}] report dispatch failed; no account/repository configured');
163
163
  return;
164
164
  }
165
165
 
166
166
  const is_cached = await check_cache_table(report_title, canary_repostiory, config.canary.throttle);
167
167
  if (is_cached) {
168
- log('[canary] throttled canary report: ' + report_title);
168
+ log('[{canary}] throttled canary report: {%s}', report_title);
169
169
  return;
170
170
  }
171
171
 
@@ -211,6 +211,6 @@ export async function dispatch_report(report_title: string, report_body: Array<u
211
211
  issue_labels: config.canary.labels
212
212
  });
213
213
  } catch (e) {
214
- log('[canary error] ' + (e as Error)?.message ?? 'unspecified error');
214
+ log('[{canary error}] ' + (e as Error)?.message ?? 'unspecified error');
215
215
  }
216
216
  }
package/src/github.ts CHANGED
@@ -117,5 +117,5 @@ export async function create_github_issue(issue: Issue): Promise<void> {
117
117
  check_response_is_ok(issue_res, 'cannot create GitHub issue');
118
118
 
119
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);
120
+ log('[{canary}] raised issue {#%d} in {%s}: %s', json_issue.number, repository.full_name, json_issue.url);
121
121
  }
package/src/utils.ts CHANGED
@@ -1,6 +1,13 @@
1
+ import { format } from 'node:util';
2
+
1
3
  /** Logs a message to stdout with the prefix `[spooder] ` */
2
4
  export function log(message: string, ...args: unknown[]): void {
3
- console.log('[spooder] ' + message, ...args);
5
+ let formatted_message = format('[{spooder}] ' + message, ...args);
6
+
7
+ // Replace all {...} with text wrapped in ANSI color code 6.
8
+ formatted_message = formatted_message.replace(/\{([^}]+)\}/g, '\x1b[38;5;6m$1\x1b[0m');
9
+
10
+ process.stdout.write(formatted_message + '\n');
4
11
  }
5
12
 
6
13
  /** Strips ANSI color codes from a string */
package/src/api.d.ts DELETED
@@ -1,80 +0,0 @@
1
- /// <reference types="node" />
2
- /// <reference types="node" />
3
- import fs from 'node:fs/promises';
4
- import { Blob } from 'node:buffer';
5
- export declare const HTTP_STATUS_CODE: {
6
- [errorCode: number]: string | undefined;
7
- [errorCode: string]: string | undefined;
8
- };
9
- type HTTP_METHOD = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'CONNECT' | 'TRACE';
10
- type HTTP_METHODS = HTTP_METHOD | HTTP_METHOD[];
11
- export declare class ErrorWithMetadata extends Error {
12
- metadata: Record<string, unknown>;
13
- constructor(message: string, metadata: Record<string, unknown>);
14
- resolve_metadata(): Promise<object>;
15
- }
16
- export declare function panic(err_message_or_obj: string | object, ...err: object[]): Promise<void>;
17
- export declare function caution(err_message_or_obj: string | object, ...err: object[]): Promise<void>;
18
- type CallableFunction = (...args: any[]) => any;
19
- type Callable = Promise<any> | CallableFunction;
20
- export declare function safe(target_fn: Callable): Promise<void>;
21
- type ReplacerFn = (key: string) => string | Array<string>;
22
- type Replacements = Record<string, string | Array<string>> | ReplacerFn;
23
- export declare function parse_template(template: string, replacements: Replacements, drop_missing?: boolean): string;
24
- export declare function generate_hash_subs(length?: number, prefix?: string): Promise<Record<string, string>>;
25
- type CookieOptions = {
26
- same_site?: 'Strict' | 'Lax' | 'None';
27
- secure?: boolean;
28
- http_only?: boolean;
29
- path?: string;
30
- expires?: number;
31
- encode?: boolean;
32
- };
33
- export declare function set_cookie(res: Response, name: string, value: string, options?: CookieOptions): void;
34
- export declare function get_cookies(source: Request | Response, decode?: boolean): Record<string, string>;
35
- export declare function apply_range(file: BunFile, request: Request): BunFile;
36
- type Resolvable<T> = T | Promise<T>;
37
- type PromiseType<T extends Promise<any>> = T extends Promise<infer U> ? U : never;
38
- type JsonPrimitive = string | number | boolean | null;
39
- type JsonArray = JsonSerializable[];
40
- interface JsonObject {
41
- [key: string]: JsonSerializable;
42
- }
43
- interface ToJson {
44
- toJSON(): any;
45
- }
46
- type JsonSerializable = JsonPrimitive | JsonObject | JsonArray | ToJson;
47
- type HandlerReturnType = Resolvable<string | number | BunFile | Response | JsonSerializable | Blob>;
48
- type RequestHandler = (req: Request, url: URL) => HandlerReturnType;
49
- type WebhookHandler = (payload: JsonSerializable) => HandlerReturnType;
50
- type ErrorHandler = (err: Error, req: Request, url: URL) => Resolvable<Response>;
51
- type DefaultHandler = (req: Request, status_code: number) => HandlerReturnType;
52
- type StatusCodeHandler = (req: Request) => HandlerReturnType;
53
- type ServerSentEventClient = {
54
- message: (message: string) => void;
55
- event: (event_name: string, message: string) => void;
56
- close: () => void;
57
- closed: Promise<void>;
58
- };
59
- type ServerSentEventHandler = (req: Request, url: URL, client: ServerSentEventClient) => void;
60
- type BunFile = ReturnType<typeof Bun.file>;
61
- type DirStat = PromiseType<ReturnType<typeof fs.stat>>;
62
- type DirHandler = (file_path: string, file: BunFile, stat: DirStat, request: Request, url: URL) => HandlerReturnType;
63
- export declare function serve(port: number): {
64
- /** Register a handler for a specific route. */
65
- route: (path: string, handler: RequestHandler, method?: HTTP_METHODS) => void;
66
- /** Serve a directory for a specific route. */
67
- dir: (path: string, dir: string, handler?: DirHandler, method?: HTTP_METHODS) => void;
68
- webhook: (secret: string, path: string, handler: WebhookHandler) => void;
69
- /** Register a default handler for all status codes. */
70
- default: (handler: DefaultHandler) => void;
71
- /** Register a handler for a specific status code. */
72
- handle: (status_code: number, handler: StatusCodeHandler) => void;
73
- /** Register a handler for uncaught errors. */
74
- error: (handler: ErrorHandler) => void;
75
- /** Stops the server. */
76
- stop: (immediate?: boolean) => void;
77
- /** Register a handler for server-sent events. */
78
- sse: (path: string, handler: ServerSentEventHandler) => void;
79
- };
80
- export {};
package/src/cli.d.ts DELETED
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env bun
2
- export {};
package/src/config.d.ts DELETED
@@ -1,16 +0,0 @@
1
- declare const internal_config: {
2
- run: string;
3
- auto_restart: number;
4
- update: never[];
5
- canary: {
6
- account: string;
7
- repository: string;
8
- labels: never[];
9
- crash_console_history: number;
10
- throttle: number;
11
- sanitize: boolean;
12
- };
13
- };
14
- type Config = typeof internal_config;
15
- export declare function get_config(): Promise<Config>;
16
- export {};
package/src/dispatch.d.ts DELETED
@@ -1 +0,0 @@
1
- export declare function dispatch_report(report_title: string, report_body: Array<unknown>): Promise<void>;
package/src/github.d.ts DELETED
@@ -1,11 +0,0 @@
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/utils.d.ts DELETED
@@ -1,6 +0,0 @@
1
- /** Logs a message to stdout with the prefix `[spooder] ` */
2
- export declare function log(message: string, ...args: unknown[]): void;
3
- /** Strips ANSI color codes from a string */
4
- export declare function strip_color_codes(str: string): string;
5
- /** Converts a command line string into an array of arguments */
6
- export declare function parse_command_line(command: string): string[];