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 +155 -5
- package/package.json +1 -1
- package/src/api.ts +141 -17
- package/src/cli.ts +11 -11
- 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 -80
- 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)
|
|
@@ -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
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
|
|
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('
|
|
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
|
-
|
|
191
|
-
|
|
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 '
|
|
451
|
+
return '\x1b[90m( ' + result_parts.join(', ') + ' )\x1b[0m';
|
|
352
452
|
}
|
|
353
453
|
|
|
354
|
-
function print_request_info(req: Request, res: Response, url: URL,
|
|
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
|
-
|
|
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
|
-
|
|
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 '
|
|
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
|
|
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,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
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[];
|