spooder 4.2.9 → 4.2.11
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 +139 -3
- package/package.json +2 -3
- package/src/api.ts +134 -16
- package/src/cli.ts +12 -12
- package/src/config.ts +5 -5
- package/src/dispatch.ts +6 -6
- package/src/github.ts +1 -1
- package/src/utils.ts +8 -1
- package/src/api.d.ts +0 -82
- package/src/cli.d.ts +0 -2
- package/src/config.d.ts +0 -16
- package/src/dispatch.d.ts +0 -1
- package/src/github.d.ts +0 -11
- package/src/utils.d.ts +0 -6
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)
|
|
@@ -69,6 +67,9 @@ The `CLI` component of `spooder` is a global command-line tool for running serve
|
|
|
69
67
|
- [API > State Management](#api-state-management)
|
|
70
68
|
- [`set_cookie(res: Response, name: string, value: string, options?: CookieOptions)`](#api-state-management-set-cookie)
|
|
71
69
|
- [`get_cookies(source: Request | Response): Record<string, string>`](#api-state-management-get-cookies)
|
|
70
|
+
- [API > Database Schema](#api-database-schema)
|
|
71
|
+
- [`db_update_schema_sqlite(db: Database, schema: string): Promise<void>`](#api-database-schema-db-update-schema-sqlite)
|
|
72
|
+
- [`db_init_schema_sqlite(db_path: string, schema: string): Promise<Database>`](#api-database-schema-db-init-schema-sqlite)
|
|
72
73
|
|
|
73
74
|
# Installation
|
|
74
75
|
|
|
@@ -663,6 +664,27 @@ server.error((err, req, url) => {
|
|
|
663
664
|
});
|
|
664
665
|
```
|
|
665
666
|
|
|
667
|
+
<a id="api-routing-server-on-slow-request"></a>
|
|
668
|
+
### 🔧 `server.on_slow_request(callback: SlowRequestCallback, threshold: number)`
|
|
669
|
+
|
|
670
|
+
`server.on_slow_request` can be used to register a callback for requests that take an undesirable amount of time to process.
|
|
671
|
+
|
|
672
|
+
By default requests that take longer than `1000ms` to process will trigger the callback, but this can be adjusted by providing a custom threshold.
|
|
673
|
+
|
|
674
|
+
> [!IMPORTANT]
|
|
675
|
+
> 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.
|
|
676
|
+
|
|
677
|
+
```ts
|
|
678
|
+
server.on_slow_request(async (req, time) => {
|
|
679
|
+
// avoid `time` in the title to avoid canary spam
|
|
680
|
+
// see caution() API for information
|
|
681
|
+
await caution('Slow request warning', { req, time });
|
|
682
|
+
}, 500);
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
> [!NOTE]
|
|
686
|
+
> The callback is not awaited internally, so you can use `async/await` freely without blocking the server/request.
|
|
687
|
+
|
|
666
688
|
<a id="api-routing-directory-serving"></a>
|
|
667
689
|
## API > Routing > Directory Serving
|
|
668
690
|
|
|
@@ -728,6 +750,16 @@ server.dir('/static', '/static', (file_path, file, stat, request, url) => {
|
|
|
728
750
|
> [!NOTE]
|
|
729
751
|
> The directory handler function is only called for files that exist on disk - including directories.
|
|
730
752
|
|
|
753
|
+
Asynchronous directory handlers are supported and will be awaited.
|
|
754
|
+
|
|
755
|
+
```js
|
|
756
|
+
server.dir('/static', '/static', async (file_path, file) => {
|
|
757
|
+
let file_contents = await file.text();
|
|
758
|
+
// do something with file_contents
|
|
759
|
+
return file_contents;
|
|
760
|
+
});
|
|
761
|
+
```
|
|
762
|
+
|
|
731
763
|
<a id="api-routing-server-sse"></a>
|
|
732
764
|
## API > Routing > Server-Sent Events
|
|
733
765
|
|
|
@@ -1207,6 +1239,110 @@ const cookies = get_cookies(req, true);
|
|
|
1207
1239
|
{ my_test_cookie: 'my cookie value' }
|
|
1208
1240
|
```
|
|
1209
1241
|
|
|
1242
|
+
<a id="api-database-schema"></a>
|
|
1243
|
+
## API > Database Schema
|
|
1244
|
+
|
|
1245
|
+
`spooder` provides a straightforward API to manage database schema in revisions through source control.
|
|
1246
|
+
|
|
1247
|
+
> [!NOTE]
|
|
1248
|
+
> Currently, only SQLite is supported. This may be expanded once Bun supports more database drivers.
|
|
1249
|
+
|
|
1250
|
+
<a id="api-database-schema-db-update-schema-sqlite"></a>
|
|
1251
|
+
### 🔧 `db_update_schema_sqlite(db: Database, schema: string): Promise<void>`
|
|
1252
|
+
|
|
1253
|
+
`db_update_schema_sqlite` takes a [`Database`](https://bun.sh/docs/api/sqlite) instance and a schema directory.
|
|
1254
|
+
|
|
1255
|
+
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.
|
|
1256
|
+
|
|
1257
|
+
> [!NOTE]
|
|
1258
|
+
> Files without the `.sql` extension (case-insensitive) will be ignored.
|
|
1259
|
+
|
|
1260
|
+
```
|
|
1261
|
+
- database.sqlite
|
|
1262
|
+
- schema/
|
|
1263
|
+
- users.sql
|
|
1264
|
+
- posts.sql
|
|
1265
|
+
- comments.sql
|
|
1266
|
+
```
|
|
1267
|
+
|
|
1268
|
+
```ts
|
|
1269
|
+
import { db_update_schema_sqlite } from 'spooder';
|
|
1270
|
+
import { Database } from 'bun:sqlite';
|
|
1271
|
+
|
|
1272
|
+
const db = new Database('./database.sqlite');
|
|
1273
|
+
await db_update_schema_sqlite(db, './schema');
|
|
1274
|
+
```
|
|
1275
|
+
|
|
1276
|
+
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.
|
|
1277
|
+
|
|
1278
|
+
```sql
|
|
1279
|
+
-- [1] Table creation.
|
|
1280
|
+
CREATE TABLE users (
|
|
1281
|
+
id INTEGER PRIMARY KEY,
|
|
1282
|
+
username TEXT NOT NULL,
|
|
1283
|
+
password TEXT NOT NULL
|
|
1284
|
+
);
|
|
1285
|
+
|
|
1286
|
+
-- [2] Add email column.
|
|
1287
|
+
ALTER TABLE users ADD COLUMN email TEXT;
|
|
1288
|
+
|
|
1289
|
+
-- [3] Cleanup invalid usernames.
|
|
1290
|
+
DELETE FROM users WHERE username = 'admin';
|
|
1291
|
+
DELETE FROM users WHERE username = 'root';
|
|
1292
|
+
```
|
|
1293
|
+
|
|
1294
|
+
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.
|
|
1295
|
+
|
|
1296
|
+
>[!NOTE]
|
|
1297
|
+
> The exact revision header syntax is `^--\s*\[(\d+)\]`.
|
|
1298
|
+
|
|
1299
|
+
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.
|
|
1300
|
+
|
|
1301
|
+
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.
|
|
1302
|
+
|
|
1303
|
+
```sql
|
|
1304
|
+
CREATE TABLE db_schema (
|
|
1305
|
+
db_schema_table_name TEXT PRIMARY KEY,
|
|
1306
|
+
db_schema_version INTEGER
|
|
1307
|
+
);
|
|
1308
|
+
```
|
|
1309
|
+
|
|
1310
|
+
>[!IMPORTANT]
|
|
1311
|
+
> 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.
|
|
1312
|
+
|
|
1313
|
+
>[!IMPORTANT]
|
|
1314
|
+
> `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.
|
|
1315
|
+
|
|
1316
|
+
```ts
|
|
1317
|
+
try {
|
|
1318
|
+
const db = new Database('./database.sqlite');
|
|
1319
|
+
await db_update_schema_sqlite(db, './schema');
|
|
1320
|
+
} catch (e) {
|
|
1321
|
+
// panic (crash) or gracefully continue, etc.
|
|
1322
|
+
await panic(e);
|
|
1323
|
+
}
|
|
1324
|
+
```
|
|
1325
|
+
|
|
1326
|
+
<a id="api-database-schema-db-init-schema-sqlite"></a>
|
|
1327
|
+
### 🔧 `db_init_schema_sqlite(db_path: string, schema: string): Promise<Database>`
|
|
1328
|
+
|
|
1329
|
+
`db_init_schema_sqlite` exists as a convenience function to create a new database and apply the schema in one step.
|
|
1330
|
+
|
|
1331
|
+
```ts
|
|
1332
|
+
import { db_init_schema_sqlite } from 'spooder';
|
|
1333
|
+
const db = await db_init_schema_sqlite('./database.sqlite', './schema');
|
|
1334
|
+
```
|
|
1335
|
+
|
|
1336
|
+
The above is equivalent to the following.
|
|
1337
|
+
|
|
1338
|
+
```ts
|
|
1339
|
+
import { db_update_schema_sqlite } from 'spooder';
|
|
1340
|
+
import { Database } from 'bun:sqlite';
|
|
1341
|
+
|
|
1342
|
+
const db = new Database('./database.sqlite', { create: true });
|
|
1343
|
+
await db_update_schema_sqlite(db, './schema');
|
|
1344
|
+
```
|
|
1345
|
+
|
|
1210
1346
|
## Legal
|
|
1211
1347
|
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.
|
|
1212
1348
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spooder",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "4.2.
|
|
4
|
+
"version": "4.2.11",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
7
7
|
"bun": "./src/api.ts",
|
|
@@ -10,8 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"module": "./src/api.ts",
|
|
12
12
|
"devDependencies": {
|
|
13
|
-
"@types/
|
|
14
|
-
"bun-types": "^0.5.0"
|
|
13
|
+
"@types/bun": "^1.0.5"
|
|
15
14
|
},
|
|
16
15
|
"bin": {
|
|
17
16
|
"spooder": "./src/cli.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
|
}
|
|
@@ -196,6 +197,97 @@ export async function generate_hash_subs(length = 7, prefix = 'hash='): Promise<
|
|
|
196
197
|
return hash_map;
|
|
197
198
|
}
|
|
198
199
|
|
|
200
|
+
type Row_DBSchema = { db_schema_table_name: string, db_schema_version: number };
|
|
201
|
+
|
|
202
|
+
export async function db_update_schema_sqlite(db: Database, schema_dir: string) {
|
|
203
|
+
log('[{db}] updating database schema for {%s}', db.filename);
|
|
204
|
+
|
|
205
|
+
const schema_versions = new Map();
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const query = db.query('SELECT db_schema_table_name, db_schema_version FROM db_schema');
|
|
209
|
+
for (const row of query.all() as Array<Row_DBSchema>)
|
|
210
|
+
schema_versions.set(row.db_schema_table_name, row.db_schema_version);
|
|
211
|
+
} catch (e) {
|
|
212
|
+
log('[{db}] creating {db_schema} table');
|
|
213
|
+
db.run('CREATE TABLE db_schema (db_schema_table_name TEXT PRIMARY KEY, db_schema_version INTEGER)');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
db.transaction(async () => {
|
|
217
|
+
const update_schema_query = db.prepare(`
|
|
218
|
+
INSERT INTO db_schema (db_schema_version, db_schema_table_name) VALUES (?1, ?2)
|
|
219
|
+
ON CONFLICT(db_schema_table_name) DO UPDATE SET db_schema_version = EXCLUDED.db_schema_version
|
|
220
|
+
`);
|
|
221
|
+
|
|
222
|
+
const schema_files = await fs.readdir(schema_dir);
|
|
223
|
+
for (const schema_file of schema_files) {
|
|
224
|
+
const schema_file_lower = schema_file.toLowerCase();
|
|
225
|
+
if (!schema_file_lower.endsWith('.sql'))
|
|
226
|
+
continue;
|
|
227
|
+
|
|
228
|
+
log('[{db}] parsing schema file {%s}', schema_file_lower);
|
|
229
|
+
|
|
230
|
+
const schema_name = path.basename(schema_file_lower, '.sql');
|
|
231
|
+
const schema_path = path.join(schema_dir, schema_file);
|
|
232
|
+
const schema = await fs.readFile(schema_path, 'utf8');
|
|
233
|
+
|
|
234
|
+
const revisions = new Map();
|
|
235
|
+
let current_rev_id = 0;
|
|
236
|
+
let current_rev = '';
|
|
237
|
+
|
|
238
|
+
for (const line of schema.split(/\r?\n/)) {
|
|
239
|
+
const rev_start = line.match(/^--\s*\[(\d+)\]/);
|
|
240
|
+
if (rev_start !== null) {
|
|
241
|
+
// New chunk definition detected, store the current chunk and start a new one.
|
|
242
|
+
if (current_rev_id > 0) {
|
|
243
|
+
revisions.set(current_rev_id, current_rev);
|
|
244
|
+
current_rev = '';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const rev_number = parseInt(rev_start[1]);
|
|
248
|
+
if (isNaN(rev_number) || rev_number < 1)
|
|
249
|
+
throw new Error(rev_number + ' is not a valid revision number in ' + schema_file_lower);
|
|
250
|
+
current_rev_id = rev_number;
|
|
251
|
+
} else {
|
|
252
|
+
// Append to existing revision.
|
|
253
|
+
current_rev += line + '\n';
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// There may be something left in current_chunk once we reach end of the file.
|
|
258
|
+
if (current_rev_id > 0)
|
|
259
|
+
revisions.set(current_rev_id, current_rev);
|
|
260
|
+
|
|
261
|
+
if (revisions.size === 0) {
|
|
262
|
+
log('[{db}] {%s} contains no valid revisions', schema_file);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const current_schema_version = schema_versions.get(schema_name) ?? 0;
|
|
267
|
+
const chunk_keys = Array.from(revisions.keys()).filter(chunk_id => chunk_id > current_schema_version).sort((a, b) => a - b);
|
|
268
|
+
|
|
269
|
+
let newest_schema_version = current_schema_version;
|
|
270
|
+
for (const rev_id of chunk_keys) {
|
|
271
|
+
const revision = revisions.get(rev_id);
|
|
272
|
+
log('[{db}] applying revision {%d} to {%s}', rev_id, schema_name);
|
|
273
|
+
db.transaction(() => db.run(revision))();
|
|
274
|
+
newest_schema_version = rev_id;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (newest_schema_version > current_schema_version) {
|
|
278
|
+
log('[{db}] updated table {%s} to revision {%d}', schema_name, newest_schema_version);
|
|
279
|
+
update_schema_query.run(newest_schema_version, schema_name);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
})();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function db_init_schema_sqlite(db_path: string, schema_dir: string): Promise<Database> {
|
|
286
|
+
const db = new Database(db_path, { create: true });
|
|
287
|
+
await db_update_schema_sqlite(db, schema_dir);
|
|
288
|
+
return db;
|
|
289
|
+
}
|
|
290
|
+
|
|
199
291
|
type CookieOptions = {
|
|
200
292
|
same_site?: 'Strict' | 'Lax' | 'None',
|
|
201
293
|
secure?: boolean,
|
|
@@ -251,7 +343,6 @@ export function get_cookies(source: Request | Response, decode: boolean = false)
|
|
|
251
343
|
export function apply_range(file: BunFile, request: Request): BunFile {
|
|
252
344
|
const range_header = request.headers.get('range');
|
|
253
345
|
if (range_header !== null) {
|
|
254
|
-
console.log(range_header);
|
|
255
346
|
const regex = /bytes=(\d*)-(\d*)/;
|
|
256
347
|
const match = range_header.match(regex);
|
|
257
348
|
|
|
@@ -331,7 +422,7 @@ function route_directory(route_path: string, dir: string, handler: DirHandler):
|
|
|
331
422
|
const file_stat = await fs.stat(file_path);
|
|
332
423
|
const bun_file = Bun.file(file_path);
|
|
333
424
|
|
|
334
|
-
return handler(file_path, bun_file, file_stat, req, url);
|
|
425
|
+
return await handler(file_path, bun_file, file_stat, req, url);
|
|
335
426
|
} catch (e) {
|
|
336
427
|
const err = e as NodeJS.ErrnoException;
|
|
337
428
|
if (err?.code === 'ENOENT')
|
|
@@ -348,13 +439,21 @@ function format_query_parameters(search_params: URLSearchParams): string {
|
|
|
348
439
|
for (let [key, value] of search_params)
|
|
349
440
|
result_parts.push(`${key}: ${value}`);
|
|
350
441
|
|
|
351
|
-
return '
|
|
442
|
+
return '\x1b[90m( ' + result_parts.join(', ') + ' )\x1b[0m';
|
|
352
443
|
}
|
|
353
444
|
|
|
354
|
-
function print_request_info(req: Request, res: Response, url: URL,
|
|
355
|
-
const request_time = Date.now() - request_start;
|
|
445
|
+
function print_request_info(req: Request, res: Response, url: URL, request_time: number): Response {
|
|
356
446
|
const search_params = url.search.length > 0 ? format_query_parameters(url.searchParams) : '';
|
|
357
|
-
|
|
447
|
+
|
|
448
|
+
// format status code based on range (2xx is green, 4xx is yellow, 5xx is red), use ansi colors.
|
|
449
|
+
const status_fmt = res.status < 300 ? '\x1b[32m' : res.status < 500 ? '\x1b[33m' : '\x1b[31m';
|
|
450
|
+
const status_code = status_fmt + res.status + '\x1b[0m';
|
|
451
|
+
|
|
452
|
+
// format request time based on range (0-100ms is green, 100-500ms is yellow, 500ms+ is red), use ansi colors.
|
|
453
|
+
const time_fmt = request_time < 100 ? '\x1b[32m' : request_time < 500 ? '\x1b[33m' : '\x1b[31m';
|
|
454
|
+
const request_time_str = time_fmt + request_time + 'ms\x1b[0m';
|
|
455
|
+
|
|
456
|
+
log('[%s] {%s} %s %s [{%s}]', status_code, req.method, url.pathname, search_params, request_time_str);
|
|
358
457
|
return res;
|
|
359
458
|
}
|
|
360
459
|
|
|
@@ -385,7 +484,8 @@ export function serve(port: number) {
|
|
|
385
484
|
|
|
386
485
|
// Content-type/content-length are automatically set for blobs.
|
|
387
486
|
if (response instanceof Blob)
|
|
388
|
-
|
|
487
|
+
// @ts-ignore Response does accept Blob in Bun, typing disagrees.
|
|
488
|
+
return new Response(response, { status: status_code });
|
|
389
489
|
|
|
390
490
|
// Status codes can be returned from some handlers.
|
|
391
491
|
if (return_status_code && typeof response === 'number')
|
|
@@ -477,6 +577,11 @@ export function serve(port: number) {
|
|
|
477
577
|
}
|
|
478
578
|
}
|
|
479
579
|
|
|
580
|
+
type SlowRequestCallback = (req: Request, request_time: number) => void;
|
|
581
|
+
|
|
582
|
+
let slow_request_callback: SlowRequestCallback | null = null;
|
|
583
|
+
let slow_request_threshold: number = 1000;
|
|
584
|
+
|
|
480
585
|
const server = Bun.serve({
|
|
481
586
|
port,
|
|
482
587
|
development: false,
|
|
@@ -486,11 +591,16 @@ export function serve(port: number) {
|
|
|
486
591
|
const request_start = Date.now();
|
|
487
592
|
|
|
488
593
|
const response = await generate_response(req, url);
|
|
489
|
-
|
|
594
|
+
const request_time = Date.now() - request_start;
|
|
595
|
+
|
|
596
|
+
if (slow_request_callback !== null && request_time > slow_request_threshold)
|
|
597
|
+
slow_request_callback(req, request_time);
|
|
598
|
+
|
|
599
|
+
return print_request_info(req, response, url, request_time);
|
|
490
600
|
}
|
|
491
601
|
});
|
|
492
602
|
|
|
493
|
-
log('server started on port '
|
|
603
|
+
log('server started on port {%d}', port);
|
|
494
604
|
|
|
495
605
|
return {
|
|
496
606
|
/** Register a handler for a specific route. */
|
|
@@ -526,6 +636,12 @@ export function serve(port: number) {
|
|
|
526
636
|
}, 'POST']);
|
|
527
637
|
},
|
|
528
638
|
|
|
639
|
+
/** Register a callback for slow requests. */
|
|
640
|
+
on_slow_request: (callback: SlowRequestCallback, threshold = 1000): void => {
|
|
641
|
+
slow_request_callback = callback;
|
|
642
|
+
slow_request_threshold = threshold;
|
|
643
|
+
},
|
|
644
|
+
|
|
529
645
|
/** Register a default handler for all status codes. */
|
|
530
646
|
default: (handler: DefaultHandler): void => {
|
|
531
647
|
default_handler = handler;
|
|
@@ -559,14 +675,16 @@ export function serve(port: number) {
|
|
|
559
675
|
|
|
560
676
|
const queue = Array<string>();
|
|
561
677
|
const stream = new ReadableStream({
|
|
678
|
+
// @ts-ignore Bun implements a "direct" mode which does not exist in the spec.
|
|
562
679
|
type: 'direct',
|
|
563
680
|
|
|
564
|
-
async pull(controller
|
|
565
|
-
|
|
681
|
+
async pull(controller) {
|
|
682
|
+
// @ts-ignore `controller` in "direct" mode is ReadableStreamDirectController.
|
|
683
|
+
stream_controller = controller as ReadableStreamDirectController;
|
|
566
684
|
while (!req.signal.aborted) {
|
|
567
685
|
if (queue.length > 0) {
|
|
568
|
-
|
|
569
|
-
|
|
686
|
+
stream_controller.write(queue.shift()!);
|
|
687
|
+
stream_controller.flush();
|
|
570
688
|
} else {
|
|
571
689
|
await Bun.sleep(50);
|
|
572
690
|
}
|
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;
|
|
@@ -64,7 +64,7 @@ async function start_server() {
|
|
|
64
64
|
function capture_stream(stream: ReadableStream, output: NodeJS.WritableStream) {
|
|
65
65
|
const reader = stream.getReader();
|
|
66
66
|
|
|
67
|
-
reader.read().then(function read_chunk(chunk
|
|
67
|
+
reader.read().then(function read_chunk(chunk) {
|
|
68
68
|
if (chunk.done)
|
|
69
69
|
return;
|
|
70
70
|
|
|
@@ -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
|
|
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
|
|
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
|
|
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 '
|
|
97
|
-
log('error: ' + (e as Error).message);
|
|
98
|
-
log('
|
|
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: '
|
|
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
|
|
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
|
-
|
|
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,82 +0,0 @@
|
|
|
1
|
-
/// <reference types="node" />
|
|
2
|
-
/// <reference types="bun-types" />
|
|
3
|
-
/// <reference types="node" />
|
|
4
|
-
/// <reference types="node" />
|
|
5
|
-
import fs from 'node:fs/promises';
|
|
6
|
-
import { Blob } from 'node:buffer';
|
|
7
|
-
export declare const HTTP_STATUS_CODE: {
|
|
8
|
-
[errorCode: number]: string | undefined;
|
|
9
|
-
[errorCode: string]: string | undefined;
|
|
10
|
-
};
|
|
11
|
-
type HTTP_METHOD = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'CONNECT' | 'TRACE';
|
|
12
|
-
type HTTP_METHODS = HTTP_METHOD | HTTP_METHOD[];
|
|
13
|
-
export declare class ErrorWithMetadata extends Error {
|
|
14
|
-
metadata: Record<string, unknown>;
|
|
15
|
-
constructor(message: string, metadata: Record<string, unknown>);
|
|
16
|
-
resolve_metadata(): Promise<object>;
|
|
17
|
-
}
|
|
18
|
-
export declare function panic(err_message_or_obj: string | object, ...err: object[]): Promise<void>;
|
|
19
|
-
export declare function caution(err_message_or_obj: string | object, ...err: object[]): Promise<void>;
|
|
20
|
-
type CallableFunction = (...args: any[]) => any;
|
|
21
|
-
type Callable = Promise<any> | CallableFunction;
|
|
22
|
-
export declare function safe(target_fn: Callable): Promise<void>;
|
|
23
|
-
type ReplacerFn = (key: string) => string | Array<string>;
|
|
24
|
-
type Replacements = Record<string, string | Array<string>> | ReplacerFn;
|
|
25
|
-
export declare function parse_template(template: string, replacements: Replacements, drop_missing?: boolean): string;
|
|
26
|
-
export declare function generate_hash_subs(length?: number, prefix?: string): Promise<Record<string, string>>;
|
|
27
|
-
type CookieOptions = {
|
|
28
|
-
same_site?: 'Strict' | 'Lax' | 'None';
|
|
29
|
-
secure?: boolean;
|
|
30
|
-
http_only?: boolean;
|
|
31
|
-
path?: string;
|
|
32
|
-
expires?: number;
|
|
33
|
-
encode?: boolean;
|
|
34
|
-
};
|
|
35
|
-
export declare function set_cookie(res: Response, name: string, value: string, options?: CookieOptions): void;
|
|
36
|
-
export declare function get_cookies(source: Request | Response, decode?: boolean): Record<string, string>;
|
|
37
|
-
export declare function apply_range(file: BunFile, request: Request): BunFile;
|
|
38
|
-
type Resolvable<T> = T | Promise<T>;
|
|
39
|
-
type PromiseType<T extends Promise<any>> = T extends Promise<infer U> ? U : never;
|
|
40
|
-
type JsonPrimitive = string | number | boolean | null;
|
|
41
|
-
type JsonArray = JsonSerializable[];
|
|
42
|
-
interface JsonObject {
|
|
43
|
-
[key: string]: JsonSerializable;
|
|
44
|
-
}
|
|
45
|
-
interface ToJson {
|
|
46
|
-
toJSON(): any;
|
|
47
|
-
}
|
|
48
|
-
type JsonSerializable = JsonPrimitive | JsonObject | JsonArray | ToJson;
|
|
49
|
-
type HandlerReturnType = Resolvable<string | number | BunFile | Response | JsonSerializable | Blob>;
|
|
50
|
-
type RequestHandler = (req: Request, url: URL) => HandlerReturnType;
|
|
51
|
-
type WebhookHandler = (payload: JsonSerializable) => HandlerReturnType;
|
|
52
|
-
type ErrorHandler = (err: Error, req: Request, url: URL) => Resolvable<Response>;
|
|
53
|
-
type DefaultHandler = (req: Request, status_code: number) => HandlerReturnType;
|
|
54
|
-
type StatusCodeHandler = (req: Request) => HandlerReturnType;
|
|
55
|
-
type ServerSentEventClient = {
|
|
56
|
-
message: (message: string) => void;
|
|
57
|
-
event: (event_name: string, message: string) => void;
|
|
58
|
-
close: () => void;
|
|
59
|
-
closed: Promise<void>;
|
|
60
|
-
};
|
|
61
|
-
type ServerSentEventHandler = (req: Request, url: URL, client: ServerSentEventClient) => void;
|
|
62
|
-
type BunFile = ReturnType<typeof Bun.file>;
|
|
63
|
-
type DirStat = PromiseType<ReturnType<typeof fs.stat>>;
|
|
64
|
-
type DirHandler = (file_path: string, file: BunFile, stat: DirStat, request: Request, url: URL) => HandlerReturnType;
|
|
65
|
-
export declare function serve(port: number): {
|
|
66
|
-
/** Register a handler for a specific route. */
|
|
67
|
-
route: (path: string, handler: RequestHandler, method?: HTTP_METHODS) => void;
|
|
68
|
-
/** Serve a directory for a specific route. */
|
|
69
|
-
dir: (path: string, dir: string, handler?: DirHandler, method?: HTTP_METHODS) => void;
|
|
70
|
-
webhook: (secret: string, path: string, handler: WebhookHandler) => void;
|
|
71
|
-
/** Register a default handler for all status codes. */
|
|
72
|
-
default: (handler: DefaultHandler) => void;
|
|
73
|
-
/** Register a handler for a specific status code. */
|
|
74
|
-
handle: (status_code: number, handler: StatusCodeHandler) => void;
|
|
75
|
-
/** Register a handler for uncaught errors. */
|
|
76
|
-
error: (handler: ErrorHandler) => void;
|
|
77
|
-
/** Stops the server. */
|
|
78
|
-
stop: (immediate?: boolean) => void;
|
|
79
|
-
/** Register a handler for server-sent events. */
|
|
80
|
-
sse: (path: string, handler: ServerSentEventHandler) => void;
|
|
81
|
-
};
|
|
82
|
-
export {};
|
package/src/cli.d.ts
DELETED
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[];
|