spooder 4.6.2 → 5.0.1
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 +1119 -342
- package/bun.lock +9 -5
- package/package.json +2 -2
- package/src/api.ts +976 -531
- package/src/api_db.ts +670 -0
- package/src/cli.ts +93 -19
- package/src/config.ts +13 -8
- package/src/dispatch.ts +136 -11
- package/src/template/directory_index.html +303 -0
- package/src/github.ts +0 -121
- package/src/utils.ts +0 -57
package/README.md
CHANGED
|
@@ -17,66 +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
|
-
# CLI
|
|
21
|
-
|
|
22
|
-
The `CLI` component of `spooder` is a global command-line tool for running server processes.
|
|
23
|
-
|
|
24
|
-
- [CLI > Usage](#cli-usage)
|
|
25
|
-
- [CLI > Dev Mode](#cli-dev-mode)
|
|
26
|
-
- [CLI > Auto Restart](#cli-auto-restart)
|
|
27
|
-
- [CLI > Auto Update](#cli-auto-update)
|
|
28
|
-
- [CLI > Canary](#cli-canary)
|
|
29
|
-
- [CLI > Canary > Crash](#cli-canary-crash)
|
|
30
|
-
- [CLI > Canary > Sanitization](#cli-canary-sanitization)
|
|
31
|
-
- [CLI > Canary > System Information](#cli-canary-system-information)
|
|
32
|
-
|
|
33
|
-
# API
|
|
34
|
-
|
|
35
|
-
`spooder` exposes a simple yet powerful API for developing servers. The API is designed to be minimal to leave control in the hands of the developer and not add overhead for features you may not need.
|
|
36
|
-
|
|
37
|
-
- [API > Serving](#api-serving)
|
|
38
|
-
- [`serve(port: number, hostname?: string): Server`](#api-serving-serve)
|
|
39
|
-
- [API > Routing](#api-routing)
|
|
40
|
-
- [`server.route(path: string, handler: RequestHandler, method: HTTP_METHODS)`](#api-routing-server-route)
|
|
41
|
-
- [`server.unroute(path: string)`](#api-routing-server-unroute)
|
|
42
|
-
- [HTTP Methods](#api-routing-methods)
|
|
43
|
-
- [Redirection Routes](#api-routing-redirection-routes)
|
|
44
|
-
- [Status Code Text](#api-routing-status-code-text)
|
|
45
|
-
- [API > Routing > RequestHandler](#api-routing-request-handler)
|
|
46
|
-
- [API > Routing > Fallback Handling](#api-routing-fallback-handlers)
|
|
47
|
-
- [`server.handle(status_code: number, handler: RequestHandler)`](#api-routing-server-handle)
|
|
48
|
-
- [`server.default(handler: DefaultHandler)`](#api-routing-server-default)
|
|
49
|
-
- [`server.error(handler: ErrorHandler)`](#api-routing-server-error)
|
|
50
|
-
- [API > Routing > Slow Requests](#api-routing-slow-requests)
|
|
51
|
-
- [`server.on_slow_request(callback: SlowRequestCallback, threshold: number)`](#api-routing-server-on-slow-request)
|
|
52
|
-
- [`server.allow_slow_request(req: Request)`](#api-routing-server-allow-slow-request)
|
|
53
|
-
- [API > Routing > Validation](#api-routing-validation)
|
|
54
|
-
- [`validate_req_json(handler: JSONRequestHandler)`](#api-routing-validate-req-json)
|
|
55
|
-
- [API > Routing > Directory Serving](#api-routing-directory-serving)
|
|
56
|
-
- [`server.dir(path: string, dir: string, handler?: DirHandler, method: HTTP_METHODS)`](#api-routing-server-dir)
|
|
57
|
-
- [API > Routing > Server-Sent Events](#api-routing-server-sent-events)
|
|
58
|
-
- [`server.sse(path: string, handler: ServerSentEventHandler)`](#api-routing-server-sse)
|
|
59
|
-
- [API > Routing > Webhooks](#api-routing-webhooks)
|
|
60
|
-
- [`server.webhook(secret: string, path: string, handler: WebhookHandler)`](#api-routing-server-webhook)
|
|
61
|
-
- [API > Routing > WebSockets](#api-routing-websockets)
|
|
62
|
-
- [`server.websocket(path: string, handlers: WebsocketHandlers)`](#api-routing-server-websocket)
|
|
63
|
-
- [API > Server Control](#api-server-control)
|
|
64
|
-
- [`server.stop(immediate: boolean)`](#api-server-control-server-stop)
|
|
65
|
-
- [API > Error Handling](#api-error-handling)
|
|
66
|
-
- [`ErrorWithMetadata(message: string, metadata: object)`](#api-error-handling-error-with-metadata)
|
|
67
|
-
- [`caution(err_message_or_obj: string | object, ...err: object[]): Promise<void>`](#api-error-handling-caution)
|
|
68
|
-
- [`panic(err_message_or_obj: string | object, ...err: object[]): Promise<void>`](#api-error-handling-panic)
|
|
69
|
-
- [`safe(fn: Callable): Promise<void>`](#api-error-handling-safe)
|
|
70
|
-
- [API > Content](#api-content)
|
|
71
|
-
- [`parse_template(template: string, replacements: Record<string, string>, drop_missing: boolean): string`](#api-content-parse-template)
|
|
72
|
-
- [`generate_hash_subs(length: number, prefix: string, hashes?: Record<string, string>): Promise<Record<string, string>>`](#api-content-generate-hash-subs)
|
|
73
|
-
- [`get_git_hashes(length: number): Promise<Record<string, string>>`](#api-content-get-git-hashes)
|
|
74
|
-
- [`apply_range(file: BunFile, request: Request): HandlerReturnType`](#api-content-apply-range)
|
|
75
|
-
- [API > State Management](#api-state-management)
|
|
76
|
-
- [`set_cookie(res: Response, name: string, value: string, options?: CookieOptions)`](#api-state-management-set-cookie)
|
|
77
|
-
- [`get_cookies(source: Request | Response): Record<string, string>`](#api-state-management-get-cookies)
|
|
78
|
-
- [API > Database Schema](#api-database-schema)
|
|
79
|
-
|
|
80
20
|
# Installation
|
|
81
21
|
|
|
82
22
|
```bash
|
|
@@ -91,14 +31,25 @@ bun add spooder
|
|
|
91
31
|
|
|
92
32
|
Both the `CLI` and the API are configured in the same way by providing a `spooder` object in your `package.json` file.
|
|
93
33
|
|
|
94
|
-
|
|
34
|
+
Below is a full map of the available configuration options in their default states. All configuration options are **optional**.
|
|
35
|
+
|
|
36
|
+
```jsonc
|
|
95
37
|
{
|
|
96
38
|
"spooder": {
|
|
97
|
-
|
|
39
|
+
|
|
40
|
+
// see CLI > Auto Restart
|
|
41
|
+
"auto_restart": true,
|
|
42
|
+
"auto_restart_max": 30000,
|
|
43
|
+
"auto_restart_attempts": 10,
|
|
44
|
+
"auto_restart_grace": 30000,
|
|
45
|
+
|
|
46
|
+
// see CLI > Auto Update
|
|
98
47
|
"update": [
|
|
99
48
|
"git pull",
|
|
100
49
|
"bun install"
|
|
101
50
|
],
|
|
51
|
+
|
|
52
|
+
// see CLI > Canary
|
|
102
53
|
"canary": {
|
|
103
54
|
"account": "",
|
|
104
55
|
"repository": "",
|
|
@@ -116,6 +67,44 @@ If there are any issues with the provided configuration, a warning will be print
|
|
|
116
67
|
> [!NOTE]
|
|
117
68
|
> Configuration warnings **do not** raise `caution` events with the `spooder` canary functionality.
|
|
118
69
|
|
|
70
|
+
# CLI
|
|
71
|
+
|
|
72
|
+
The `CLI` component of `spooder` is a global command-line tool for running server processes.
|
|
73
|
+
|
|
74
|
+
- [CLI > Usage](#cli-usage)
|
|
75
|
+
- [CLI > Dev Mode](#cli-dev-mode)
|
|
76
|
+
- [CLI > Auto Restart](#cli-auto-restart)
|
|
77
|
+
- [CLI > Auto Update](#cli-auto-update)
|
|
78
|
+
- [CLI > Canary](#cli-canary)
|
|
79
|
+
- [CLI > Canary > Crash](#cli-canary-crash)
|
|
80
|
+
- [CLI > Canary > Sanitization](#cli-canary-sanitization)
|
|
81
|
+
- [CLI > Canary > System Information](#cli-canary-system-information)
|
|
82
|
+
|
|
83
|
+
# API
|
|
84
|
+
|
|
85
|
+
`spooder` exposes a simple yet powerful API for developing servers. The API is designed to be minimal to leave control in the hands of the developer and not add overhead for features you may not need.
|
|
86
|
+
|
|
87
|
+
- [API > Cheatsheet](#api-cheatsheet)
|
|
88
|
+
- [API > Logging](#api-logging)
|
|
89
|
+
- [API > HTTP](#api-http)
|
|
90
|
+
- [API > HTTP > Directory Serving](#api-http-directory)
|
|
91
|
+
- [API > HTTP > Server-Sent Events (SSE)](#api-http-sse)
|
|
92
|
+
- [API > HTTP > Webhooks](#api-http-webhooks)
|
|
93
|
+
- [API > HTTP > Websocket Server](#api-http-websockets)
|
|
94
|
+
- [API > HTTP > Bootstrap](#api-http-bootstrap)
|
|
95
|
+
- [API > Error Handling](#api-error-handling)
|
|
96
|
+
- [API > Workers](#api-workers)
|
|
97
|
+
- [API > Caching](#api-caching)
|
|
98
|
+
- [API > Templating](#api-templating)
|
|
99
|
+
- [API > Database](#api-database)
|
|
100
|
+
- [API > Database > Schema](#api-database-schema)
|
|
101
|
+
- [API > Database > Interface](#api-database-interface)
|
|
102
|
+
- [API > Database > Interface > SQLite](#api-database-interface-sqlite)
|
|
103
|
+
- [API > Database > Interface > MySQL](#api-database-interface-mysql)
|
|
104
|
+
- [API > Utilities](#api-utilities)
|
|
105
|
+
|
|
106
|
+
# CLI
|
|
107
|
+
|
|
119
108
|
<a id="cli-usage"></a>
|
|
120
109
|
## CLI > Usage
|
|
121
110
|
|
|
@@ -179,17 +168,27 @@ if (process.env.SPOODER_ENV === 'dev') {
|
|
|
179
168
|
> [!NOTE]
|
|
180
169
|
> This feature is not enabled by default.
|
|
181
170
|
|
|
182
|
-
In the event that the server process exits
|
|
171
|
+
In the event that the server process exits with a non-zero exit code, `spooder` can automatically restart it using an exponential backoff strategy. To enable this feature set `auto_restart` to `true` in the configuration.
|
|
183
172
|
|
|
184
173
|
```json
|
|
185
174
|
{
|
|
186
175
|
"spooder": {
|
|
187
|
-
"auto_restart":
|
|
176
|
+
"auto_restart": true,
|
|
177
|
+
"auto_restart_max": 30000,
|
|
178
|
+
"auto_restart_attempts": 10,
|
|
179
|
+
"auto_restart_grace": 30000
|
|
188
180
|
}
|
|
189
181
|
}
|
|
190
182
|
```
|
|
191
183
|
|
|
192
|
-
|
|
184
|
+
### Configuration Options
|
|
185
|
+
|
|
186
|
+
- **`auto_restart`** (boolean, default: `false`): Enable or disable the auto-restart feature
|
|
187
|
+
- **`auto_restart_max`** (number, default: `30000`): Maximum delay in milliseconds between restart attempts
|
|
188
|
+
- **`auto_restart_attempts`** (number, default: `-1`): Maximum number of restart attempts before giving up. Set to `-1` for unlimited attempts
|
|
189
|
+
- **`auto_restart_grace`** (number, default: `30000`): Period of time after which the backoff protocol disables if the server remains stable.
|
|
190
|
+
|
|
191
|
+
If the server exits with a zero exit code (successful termination), auto-restart will not trigger.
|
|
193
192
|
|
|
194
193
|
<a id="cli-auto-update"></a>
|
|
195
194
|
## CLI > Auto Update
|
|
@@ -227,7 +226,7 @@ server.webhook(process.env.WEBHOOK_SECRET, '/webhook', payload => {
|
|
|
227
226
|
await server.stop(false);
|
|
228
227
|
process.exit();
|
|
229
228
|
});
|
|
230
|
-
return
|
|
229
|
+
return HTTP_STATUS_CODE.OK_200;
|
|
231
230
|
});
|
|
232
231
|
```
|
|
233
232
|
|
|
@@ -243,9 +242,10 @@ In addition to being skipped in [dev mode](#cli-dev-mode), updates can also be s
|
|
|
243
242
|
|
|
244
243
|
`canary` is a feature in `spooder` which allows server problems to be raised as issues in your repository on GitHub.
|
|
245
244
|
|
|
246
|
-
To enable this feature, you will need to
|
|
245
|
+
To enable this feature, you will need a GitHub app which has access to your repository and a corresponding private key. If you do not already have those, instructions can be found below.
|
|
247
246
|
|
|
248
|
-
|
|
247
|
+
<details>
|
|
248
|
+
<summary>GitHub App Setup</summary>
|
|
249
249
|
|
|
250
250
|
Create a new GitHub App either on your personal account or on an organization. The app will need the following permissions:
|
|
251
251
|
|
|
@@ -264,8 +264,9 @@ openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private-key.pem -out
|
|
|
264
264
|
```
|
|
265
265
|
|
|
266
266
|
Each server that intends to use the canary feature will need to have the private key installed somewhere the server process can access it.
|
|
267
|
+
</details>
|
|
267
268
|
|
|
268
|
-
###
|
|
269
|
+
### Configure Canary
|
|
269
270
|
|
|
270
271
|
```json
|
|
271
272
|
"spooder": {
|
|
@@ -283,7 +284,7 @@ The repository name must in the full-name format `owner/repo` (e.g. `facebook/re
|
|
|
283
284
|
|
|
284
285
|
The `labels` property can be used to provide a list of labels to automatically add to the issue. This property is optional and can be omitted.
|
|
285
286
|
|
|
286
|
-
###
|
|
287
|
+
### Setup Environment Variables
|
|
287
288
|
|
|
288
289
|
The following two environment variables must be defined on the server.
|
|
289
290
|
|
|
@@ -299,7 +300,7 @@ SPOODER_CANARY_KEY=/home/bond/.ssh/id_007_pcks8.key
|
|
|
299
300
|
> [!NOTE]
|
|
300
301
|
> Since `spooder` uses the Bun runtime, you can use the `.env.local` file in the project root directory to set these environment variables per-project.
|
|
301
302
|
|
|
302
|
-
###
|
|
303
|
+
### Using Canary
|
|
303
304
|
|
|
304
305
|
Once configured, `spooder` will automatically raise an issue when the server exits with a non-zero exit code.
|
|
305
306
|
|
|
@@ -464,19 +465,173 @@ In addition to the information provided by the developer, `spooder` also include
|
|
|
464
465
|
}
|
|
465
466
|
```
|
|
466
467
|
|
|
467
|
-
|
|
468
|
-
|
|
468
|
+
# API
|
|
469
|
+
|
|
470
|
+
<a id="api-cheatsheet"></a>
|
|
471
|
+
## API > Cheatsheet
|
|
472
|
+
|
|
473
|
+
```ts
|
|
474
|
+
// logging
|
|
475
|
+
log(message: string);
|
|
476
|
+
log_error(message: string);
|
|
477
|
+
log_create_logger(prefix: string, color: ColorInput);
|
|
478
|
+
log_list(input: any[], delimiter = ', ');
|
|
479
|
+
|
|
480
|
+
// http
|
|
481
|
+
http_serve(port: number, hostname?: string): Server;
|
|
482
|
+
server.stop(immediate: boolean): Promise<void>;
|
|
483
|
+
|
|
484
|
+
// routing
|
|
485
|
+
server.route(path: string, handler: RequestHandler, method?: HTTP_METHODS);
|
|
486
|
+
server.json(path: string, handler: JSONRequestHandler, method?: HTTP_METHODS);
|
|
487
|
+
server.unroute(path: string);
|
|
488
|
+
|
|
489
|
+
// fallback handlers
|
|
490
|
+
server.handle(status_code: number, handler: RequestHandler);
|
|
491
|
+
server.default(handler: DefaultHandler);
|
|
492
|
+
server.error(handler: ErrorHandler);
|
|
493
|
+
server.on_slow_request(callback: SlowRequestCallback, threshold?: number);
|
|
494
|
+
server.allow_slow_request(req: Request);
|
|
495
|
+
|
|
496
|
+
// http generics
|
|
497
|
+
http_apply_range(file: BunFile, request: Request): HandlerReturnType;
|
|
498
|
+
|
|
499
|
+
// directory serving
|
|
500
|
+
server.dir(path: string, dir: string, options?: DirOptions | DirHandler, method?: HTTP_METHODS);
|
|
501
|
+
|
|
502
|
+
// server-sent events
|
|
503
|
+
server.sse(path: string, handler: ServerSentEventHandler);
|
|
504
|
+
|
|
505
|
+
// webhooks
|
|
506
|
+
server.webhook(secret: string, path: string, handler: WebhookHandler, branches?: string | string[]);
|
|
507
|
+
|
|
508
|
+
// websockets
|
|
509
|
+
server.websocket(path: string, handlers: WebsocketHandlers);
|
|
510
|
+
|
|
511
|
+
// bootstrap
|
|
512
|
+
server.bootstrap(options: BootstrapOptions): Promise<void>;
|
|
513
|
+
|
|
514
|
+
// error handling
|
|
515
|
+
ErrorWithMetadata(message: string, metadata: object);
|
|
516
|
+
caution(err_message_or_obj: string | object, ...err: object[]): Promise<void>;
|
|
517
|
+
panic(err_message_or_obj: string | object, ...err: object[]): Promise<void>;
|
|
518
|
+
safe(fn: Callable): Promise<void>;
|
|
519
|
+
|
|
520
|
+
// worker
|
|
521
|
+
worker_event_pipe(worker: Worker, options?: WorkerEventPipeOptions): WorkerEventPipe;
|
|
522
|
+
pipe.send(id: string, data?: object): void;
|
|
523
|
+
pipe.on(event: string, callback: (data: object) => void | Promise<void>): void;
|
|
524
|
+
pipe.once(event: string, callback: (data: object) => void | Promise<void>): void;
|
|
525
|
+
pipe.off(event: string): void;
|
|
526
|
+
|
|
527
|
+
// templates
|
|
528
|
+
parse_template(template: string, replacements: Record<string, string>, drop_missing?: boolean): Promise<string>;
|
|
529
|
+
generate_hash_subs(length?: number, prefix?: string, hashes?: Record<string, string>): Promise<Record<string, string>>;
|
|
530
|
+
get_git_hashes(length: number): Promise<Record<string, string>>;
|
|
531
|
+
|
|
532
|
+
// database interface
|
|
533
|
+
db_sqlite(filename: string, options: number|object): db_sqlite;
|
|
534
|
+
db_mysql(options: ConnectionOptions, pool: boolean): Promise<MySQLDatabaseInterface>;
|
|
535
|
+
db_cast_set<T extends string>(set: string | null): Set<T>;
|
|
536
|
+
db_serialize_set<T extends string>(set: Set<T> | null): string;
|
|
537
|
+
|
|
538
|
+
// db_sqlite
|
|
539
|
+
update_schema(db_dir: string, schema_table?: string): Promise<void>
|
|
540
|
+
insert(sql: string, ...values: any): number;
|
|
541
|
+
insert_object(table: string, obj: Record<string, any>): number;
|
|
542
|
+
execute(sql: string, ...values: any): number;
|
|
543
|
+
get_all<T>(sql: string, ...values: any): T[];
|
|
544
|
+
get_single<T>(sql: string, ...values: any): T | null;
|
|
545
|
+
get_column<T>(sql: string, column: string, ...values: any): T[];
|
|
546
|
+
get_paged<T>(sql: string, values?: any[], page_size?: number): AsyncGenerator<T[]>;
|
|
547
|
+
count(sql: string, ...values: any): number;
|
|
548
|
+
count_table(table_name: string): number;
|
|
549
|
+
exists(sql: string, ...values: any): boolean;
|
|
550
|
+
transaction(scope: (transaction: SQLiteDatabaseInterface) => void | Promise<void>): boolean;
|
|
551
|
+
|
|
552
|
+
// db_mysql
|
|
553
|
+
update_schema(db_dir: string, schema_table?: string): Promise<void>
|
|
554
|
+
insert(sql: string, ...values: any): Promise<number>;
|
|
555
|
+
insert_object(table: string, obj: Record<string, any>): Promise<number>;
|
|
556
|
+
execute(sql: string, ...values: any): Promise<number>;
|
|
557
|
+
get_all<T>(sql: string, ...values: any): Promise<T[]>;
|
|
558
|
+
get_single<T>(sql: string, ...values: any): Promise<T | null>;
|
|
559
|
+
get_column<T>(sql: string, column: string, ...values: any): Promise<T[]>;
|
|
560
|
+
call<T>(func_name: string, ...args: any): Promise<T[]>;
|
|
561
|
+
get_paged<T>(sql: string, values?: any[], page_size?: number): AsyncGenerator<T[]>;
|
|
562
|
+
count(sql: string, ...values: any): Promise<number>;
|
|
563
|
+
count_table(table_name: string): Promise<number>;
|
|
564
|
+
exists(sql: string, ...values: any): Promise<boolean>;
|
|
565
|
+
transaction(scope: (transaction: MySQLDatabaseInterface) => void | Promise<void>): Promise<boolean>;
|
|
566
|
+
|
|
567
|
+
// database schema
|
|
568
|
+
db_update_schema_sqlite(db: Database, schema_dir: string, schema_table?: string): Promise<void>;
|
|
569
|
+
db_update_schema_mysql(db: Connection, schema_dir: string, schema_table?: string): Promise<void>;
|
|
570
|
+
|
|
571
|
+
// caching
|
|
572
|
+
cache_http(options?: CacheOptions);
|
|
573
|
+
cache.file(file_path: string): RequestHandler;
|
|
574
|
+
cache.request(req: Request, cache_key: string, content_generator: () => string | Promise<string>): Promise<Response>;
|
|
575
|
+
|
|
576
|
+
// utilities
|
|
577
|
+
filesize(bytes: number): string;
|
|
578
|
+
|
|
579
|
+
// constants
|
|
580
|
+
HTTP_STATUS_TEXT: Record<number, string>;
|
|
581
|
+
HTTP_STATUS_CODE: { OK_200: 200, NotFound_404: 404, ... };
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
<a id="api-logging"></a>
|
|
585
|
+
## API > Logging
|
|
586
|
+
|
|
587
|
+
### 🔧 `log(message: string)`
|
|
588
|
+
Print a message to the console using the default logger. Wrapping text segments in curly braces will highlight those segments with colour.
|
|
589
|
+
|
|
590
|
+
```ts
|
|
591
|
+
log('Hello, {world}!');
|
|
592
|
+
// > [info] Hello, world!
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
### 🔧 `log_error(message: string)`
|
|
596
|
+
Print an error message to the console. Wrapping text segments in curly braces will highlight those segments. This works the same as `log()` except it's red, so you know it's bad.
|
|
597
|
+
|
|
598
|
+
```ts
|
|
599
|
+
log_error('Something went {really} wrong');
|
|
600
|
+
// > [error] Something went really wrong
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
### 🔧 `log_create_logger(prefix: string, color: ColorInput)`
|
|
604
|
+
Create a `log()` function with a custom prefix and highlight colour.
|
|
605
|
+
|
|
606
|
+
```ts
|
|
607
|
+
const db_log = log_create_logger('db', 'pink');
|
|
608
|
+
db_log('Creating table {users}...');
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
> [!INFO]
|
|
612
|
+
> For information about `ColorInput`, see the [Bun Color API](https://bun.sh/docs/api/color).
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
### 🔧 `log_list(input: any[], delimiter = ', ')`
|
|
616
|
+
Utility function that joins an array of items together with each element wrapped in highlighting syntax for logging.
|
|
617
|
+
|
|
618
|
+
```ts
|
|
619
|
+
const fruit = ['apple', 'orange', 'peach'];
|
|
620
|
+
log(`Fruit must be one of ${fruit.map(e => `{${e}}`).join(', ')}`);
|
|
621
|
+
log(`Fruit must be one of ${log_list(fruit)}`);
|
|
622
|
+
```
|
|
469
623
|
|
|
470
|
-
<a id="api-
|
|
471
|
-
|
|
624
|
+
<a id="api-http"></a>
|
|
625
|
+
## API > HTTP
|
|
472
626
|
|
|
627
|
+
### `http_serve(port: number, hostname?: string): Server`
|
|
473
628
|
Bootstrap a server on the specified port (and optional hostname).
|
|
474
629
|
|
|
475
630
|
```ts
|
|
476
631
|
import { serve } from 'spooder';
|
|
477
632
|
|
|
478
|
-
const server =
|
|
479
|
-
const server =
|
|
633
|
+
const server = http_serve(8080); // port only
|
|
634
|
+
const server = http_serve(3000, '0.0.0.0'); // optional hostname
|
|
480
635
|
```
|
|
481
636
|
|
|
482
637
|
By default, the server responds with:
|
|
@@ -489,10 +644,28 @@ Content-Type: text/plain;charset=utf-8
|
|
|
489
644
|
Not Found
|
|
490
645
|
```
|
|
491
646
|
|
|
492
|
-
|
|
493
|
-
|
|
647
|
+
### 🔧 `server.stop(immediate: boolean)`
|
|
648
|
+
|
|
649
|
+
Stop the server process immediately, terminating all in-flight requests.
|
|
650
|
+
|
|
651
|
+
```ts
|
|
652
|
+
server.stop(true);
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
Stop the server process gracefully, waiting for all in-flight requests to complete.
|
|
656
|
+
|
|
657
|
+
```ts
|
|
658
|
+
server.stop(false);
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
`server.stop()` returns a promise, which if awaited, resolves when all pending connections have been completed.
|
|
662
|
+
```ts
|
|
663
|
+
await server.stop(false);
|
|
664
|
+
// do something now all connections are done
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
### Routing
|
|
494
668
|
|
|
495
|
-
<a id="api-routing-server-route"></a>
|
|
496
669
|
### 🔧 `server.route(path: string, handler: RequestHandler)`
|
|
497
670
|
|
|
498
671
|
Register a handler for a specific path.
|
|
@@ -503,7 +676,6 @@ server.route('/test/route', (req, url) => {
|
|
|
503
676
|
});
|
|
504
677
|
```
|
|
505
678
|
|
|
506
|
-
<a id="api-routing-server-unrouote"></a>
|
|
507
679
|
### 🔧 `server.unroute(path: string)`
|
|
508
680
|
|
|
509
681
|
Unregister a specific route.
|
|
@@ -513,10 +685,37 @@ server.route('/test/route', () => {});
|
|
|
513
685
|
server.unroute('/test/route');
|
|
514
686
|
```
|
|
515
687
|
|
|
516
|
-
|
|
688
|
+
### 🔧 `server.json(path: string, handler: JSONRequestHandler, method?: HTTP_METHODS)`
|
|
689
|
+
|
|
690
|
+
Register a JSON endpoint with automatic content validation. This method automatically validates that the request has the correct `Content-Type: application/json` header and that the request body contains a valid JSON object.
|
|
691
|
+
|
|
692
|
+
```ts
|
|
693
|
+
server.json('/api/users', (req, url, json) => {
|
|
694
|
+
// json is automatically parsed and validated as a plain object
|
|
695
|
+
const name = json.name;
|
|
696
|
+
const email = json.email;
|
|
697
|
+
|
|
698
|
+
// Process the JSON data
|
|
699
|
+
return { success: true, id: 123 };
|
|
700
|
+
});
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
By default, JSON routes are registered as `POST` endpoints, but this can be customized:
|
|
704
|
+
|
|
705
|
+
```ts
|
|
706
|
+
server.json('/api/data', (req, url, json) => {
|
|
707
|
+
return { received: json };
|
|
708
|
+
}, 'PUT');
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
The handler will automatically return `400 Bad Request` if:
|
|
712
|
+
- The `Content-Type` header is not `application/json`
|
|
713
|
+
- The request body is not valid JSON
|
|
714
|
+
- The JSON is not a plain object (e.g., it's an array, null, or primitive value)
|
|
715
|
+
|
|
517
716
|
### HTTP Methods
|
|
518
717
|
|
|
519
|
-
By default, `spooder` will register routes defined with `server.route()` and `server.dir()` as `GET` routes. Requests to these routes with other methods will return `405 Method Not Allowed`.
|
|
718
|
+
By default, `spooder` will register routes defined with `server.route()` and `server.dir()` as `GET` routes, while `server.json()` routes default to `POST`. Requests to these routes with other methods will return `405 Method Not Allowed`.
|
|
520
719
|
|
|
521
720
|
> [!NOTE]
|
|
522
721
|
> spooder does not automatically handle HEAD requests natively.
|
|
@@ -539,32 +738,38 @@ server.route('/test/route', (req, url) => {
|
|
|
539
738
|
> [!NOTE]
|
|
540
739
|
> Routes defined with .sse() or .webhook() are always registered as 'GET' and 'POST' respectively and cannot be configured.
|
|
541
740
|
|
|
542
|
-
<a id="api-routing-redirection-routes"></a>
|
|
543
741
|
### Redirection Routes
|
|
544
742
|
|
|
545
743
|
`spooder` does not provide a built-in redirection handler since it's trivial to implement one using [`Response.redirect`](https://developer.mozilla.org/en-US/docs/Web/API/Response/redirect_static), part of the standard Web API.
|
|
546
744
|
|
|
547
745
|
```ts
|
|
548
|
-
server.route('/redirect', () => Response.redirect('/redirected',
|
|
746
|
+
server.route('/redirect', () => Response.redirect('/redirected', HTTP_STATUS_CODE.MovedPermanently_301));
|
|
549
747
|
```
|
|
550
748
|
|
|
551
|
-
<a id="api-routing-status-code-text"></a>
|
|
552
749
|
### Status Code Text
|
|
553
750
|
|
|
554
|
-
`spooder` exposes `
|
|
751
|
+
`spooder` exposes `HTTP_STATUS_TEXT` to conveniently access status code text, and `HTTP_STATUS_CODE` for named status code constants.
|
|
555
752
|
|
|
556
753
|
```ts
|
|
557
|
-
import { HTTP_STATUS_CODE } from 'spooder';
|
|
754
|
+
import { HTTP_STATUS_TEXT, HTTP_STATUS_CODE } from 'spooder';
|
|
558
755
|
|
|
559
756
|
server.default((req, status_code) => {
|
|
560
757
|
// status_code: 404
|
|
561
758
|
// Body: Not Found
|
|
562
|
-
return new Response(
|
|
759
|
+
return new Response(HTTP_STATUS_TEXT[status_code], { status: status_code });
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
// Using named constants for better readability
|
|
763
|
+
server.route('/api/users', (req, url) => {
|
|
764
|
+
if (!isValidUser(req))
|
|
765
|
+
return HTTP_STATUS_CODE.Unauthorized_401;
|
|
766
|
+
|
|
767
|
+
// Process user request
|
|
768
|
+
return HTTP_STATUS_CODE.OK_200;
|
|
563
769
|
});
|
|
564
770
|
```
|
|
565
771
|
|
|
566
|
-
|
|
567
|
-
## API > Routing > RequestHandler
|
|
772
|
+
### RequestHandler
|
|
568
773
|
|
|
569
774
|
`RequestHandler` is a function that accepts a [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) object and a [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL) object and returns a `HandlerReturnType`.
|
|
570
775
|
|
|
@@ -587,8 +792,7 @@ server.default((req, status_code) => {
|
|
|
587
792
|
> [!NOTE]
|
|
588
793
|
> Returning `Bun.file()` directly is the most efficient way to serve static files as it uses system calls to stream the file directly to the client without loading into user-space.
|
|
589
794
|
|
|
590
|
-
|
|
591
|
-
## API > Routing > Query Parameters
|
|
795
|
+
### Query Parameters
|
|
592
796
|
|
|
593
797
|
Query parameters can be accessed from the `searchParams` property on the `URL` object.
|
|
594
798
|
|
|
@@ -618,8 +822,7 @@ server.route('/test/:param', (req, url) => {
|
|
|
618
822
|
});
|
|
619
823
|
```
|
|
620
824
|
|
|
621
|
-
|
|
622
|
-
## API > Routing > Wildcards
|
|
825
|
+
### Wildcards
|
|
623
826
|
|
|
624
827
|
Wildcards can be used to match any path that starts with a given path.
|
|
625
828
|
|
|
@@ -628,7 +831,7 @@ Wildcards can be used to match any path that starts with a given path.
|
|
|
628
831
|
|
|
629
832
|
```ts
|
|
630
833
|
server.route('/test/*', (req, url) => {
|
|
631
|
-
return new Response('Hello, world!', { status:
|
|
834
|
+
return new Response('Hello, world!', { status: HTTP_STATUS_CODE.OK_200 });
|
|
632
835
|
});
|
|
633
836
|
```
|
|
634
837
|
|
|
@@ -636,25 +839,22 @@ server.route('/test/*', (req, url) => {
|
|
|
636
839
|
> Routes are [FIFO](https://en.wikipedia.org/wiki/FIFO_(computing_and_electronics)) and wildcards are greedy. Wildcards should be registered last to ensure they do not consume more specific routes.
|
|
637
840
|
|
|
638
841
|
```ts
|
|
639
|
-
server.route('/*', () =>
|
|
640
|
-
server.route('/test', () =>
|
|
842
|
+
server.route('/*', () => HTTP_STATUS_CODE.MovedPermanently_301);
|
|
843
|
+
server.route('/test', () => HTTP_STATUS_CODE.OK_200);
|
|
641
844
|
|
|
642
845
|
// Accessing /test returns 301 here, because /* matches /test first.
|
|
643
846
|
```
|
|
644
847
|
|
|
645
|
-
|
|
646
|
-
## API > Routing > Fallback Handlers
|
|
848
|
+
### Fallback Handlers
|
|
647
849
|
|
|
648
|
-
<a id="api-routing-server-handle"></a>
|
|
649
850
|
### 🔧 `server.handle(status_code: number, handler: RequestHandler)`
|
|
650
851
|
Register a custom handler for a specific status code.
|
|
651
852
|
```ts
|
|
652
|
-
server.handle(
|
|
653
|
-
return new Response('Custom Internal Server Error Message', { status:
|
|
853
|
+
server.handle(HTTP_STATUS_CODE.InternalServerError_500, (req) => {
|
|
854
|
+
return new Response('Custom Internal Server Error Message', { status: HTTP_STATUS_CODE.InternalServerError_500 });
|
|
654
855
|
});
|
|
655
856
|
```
|
|
656
857
|
|
|
657
|
-
<a id="api-routing-server-default"></a>
|
|
658
858
|
### 🔧 `server.default(handler: DefaultHandler)`
|
|
659
859
|
Register a handler for all unhandled response codes.
|
|
660
860
|
> [!NOTE]
|
|
@@ -665,7 +865,6 @@ server.default((req, status_code) => {
|
|
|
665
865
|
});
|
|
666
866
|
```
|
|
667
867
|
|
|
668
|
-
<a id="api-routing-server-error"></a>
|
|
669
868
|
### 🔧 `server.error(handler: ErrorHandler)`
|
|
670
869
|
Register a handler for uncaught errors.
|
|
671
870
|
|
|
@@ -673,7 +872,7 @@ Register a handler for uncaught errors.
|
|
|
673
872
|
> Unlike other handlers, this should only return `Response` or `Promise<Response>`.
|
|
674
873
|
```ts
|
|
675
874
|
server.error((err, req, url) => {
|
|
676
|
-
return new Response('Custom Internal Server Error Message', { status:
|
|
875
|
+
return new Response('Custom Internal Server Error Message', { status: HTTP_STATUS_CODE.InternalServerError_500 });
|
|
677
876
|
});
|
|
678
877
|
```
|
|
679
878
|
|
|
@@ -686,14 +885,12 @@ server.error((err, req, url) => {
|
|
|
686
885
|
caution({ err, url });
|
|
687
886
|
|
|
688
887
|
// Return a response to the client.
|
|
689
|
-
return new Response('Custom Internal Server Error Message', { status:
|
|
888
|
+
return new Response('Custom Internal Server Error Message', { status: HTTP_STATUS_CODE.InternalServerError_500 });
|
|
690
889
|
});
|
|
691
890
|
```
|
|
692
891
|
|
|
693
|
-
|
|
694
|
-
## API > Routing > Slow Requests
|
|
892
|
+
### Slow Requests
|
|
695
893
|
|
|
696
|
-
<a id="api-routing-server-on-slow-request"></a>
|
|
697
894
|
### 🔧 `server.on_slow_request(callback: SlowRequestCallback, threshold: number)`
|
|
698
895
|
|
|
699
896
|
`server.on_slow_request` can be used to register a callback for requests that take an undesirable amount of time to process.
|
|
@@ -714,7 +911,6 @@ server.on_slow_request(async (req, time, url) => {
|
|
|
714
911
|
> [!NOTE]
|
|
715
912
|
> The callback is not awaited internally, so you can use `async/await` freely without blocking the server/request.
|
|
716
913
|
|
|
717
|
-
<a id="api-routing-server-allow-slow-request"></a>
|
|
718
914
|
### 🔧 `server.allow_slow_request(req: Request)`
|
|
719
915
|
|
|
720
916
|
In some scenarios, mitigation throttling or heavy workloads may cause slow requests intentionally. To prevent these triggering a caution, requests can be marked as slow.
|
|
@@ -735,106 +931,77 @@ server.route('/test', async (req) => {
|
|
|
735
931
|
> [!NOTE]
|
|
736
932
|
> This will have no effect if a handler hasn't been registered with `on_slow_request`.
|
|
737
933
|
|
|
738
|
-
<a id="api-
|
|
739
|
-
## API >
|
|
934
|
+
<a id="api-http-directory"></a>
|
|
935
|
+
## API > HTTP > Directory Serving
|
|
740
936
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
In the scenario that you're expecting an endpoint to receive JSON data, you might set up a handler like this:
|
|
937
|
+
### 🔧 `server.dir(path: string, dir: string, options?: DirOptions | DirHandler)`
|
|
938
|
+
Serve files from a directory.
|
|
745
939
|
|
|
746
940
|
```ts
|
|
747
|
-
server.
|
|
748
|
-
const json = await req.json();
|
|
749
|
-
// do something with json.
|
|
750
|
-
return 200;
|
|
751
|
-
})
|
|
941
|
+
server.dir('/content', './public/content');
|
|
752
942
|
```
|
|
753
943
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
What should instead happen is something like this:
|
|
944
|
+
> [!IMPORTANT]
|
|
945
|
+
> `server.dir` registers a wildcard route. Routes are [FIFO](https://en.wikipedia.org/wiki/FIFO_(computing_and_electronics)) and wildcards are greedy. Directories should be registered last to ensure they do not consume more specific routes.
|
|
757
946
|
|
|
758
947
|
```ts
|
|
759
|
-
server.
|
|
760
|
-
|
|
761
|
-
if (req.headers.get('Content-Type') !== 'application/json')
|
|
762
|
-
return 400;
|
|
763
|
-
|
|
764
|
-
try {
|
|
765
|
-
const json = await req.json();
|
|
766
|
-
if (json === null || typeof json !== 'object' || Array.isArray(json))
|
|
767
|
-
return 400;
|
|
948
|
+
server.dir('/', '/files');
|
|
949
|
+
server.route('/test', () => 200);
|
|
768
950
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
} catch (err) {
|
|
772
|
-
return 400;
|
|
773
|
-
}
|
|
774
|
-
})
|
|
951
|
+
// Route / is equal to /* with server.dir()
|
|
952
|
+
// Accessing /test returns 404 here because /files/test does not exist.
|
|
775
953
|
```
|
|
776
954
|
|
|
777
|
-
|
|
955
|
+
#### Directory Options
|
|
956
|
+
|
|
957
|
+
You can configure directory behavior using the `DirOptions` interface:
|
|
778
958
|
|
|
779
959
|
```ts
|
|
780
|
-
|
|
781
|
-
//
|
|
782
|
-
|
|
783
|
-
|
|
960
|
+
interface DirOptions {
|
|
961
|
+
ignore_hidden?: boolean; // default: true
|
|
962
|
+
index_directories?: boolean; // default: false
|
|
963
|
+
support_ranges?: boolean; // default: true
|
|
964
|
+
}
|
|
784
965
|
```
|
|
785
966
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
967
|
+
**Options-based configuration:**
|
|
968
|
+
```ts
|
|
969
|
+
// Enable directory browsing with HTML listings
|
|
970
|
+
server.dir('/files', './public', { index_directories: true });
|
|
790
971
|
|
|
791
|
-
|
|
792
|
-
|
|
972
|
+
// Serve hidden files and disable range requests
|
|
973
|
+
server.dir('/files', './public', {
|
|
974
|
+
ignore_hidden: false,
|
|
975
|
+
support_ranges: false
|
|
976
|
+
});
|
|
793
977
|
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
978
|
+
// Full configuration
|
|
979
|
+
server.dir('/files', './public', {
|
|
980
|
+
ignore_hidden: true,
|
|
981
|
+
index_directories: true,
|
|
982
|
+
support_ranges: true
|
|
983
|
+
});
|
|
799
984
|
```
|
|
800
985
|
|
|
801
|
-
|
|
802
|
-
> `server.dir` registers a wildcard route. Routes are [FIFO](https://en.wikipedia.org/wiki/FIFO_(computing_and_electronics)) and wildcards are greedy. Directories should be registered last to ensure they do not consume more specific routes.
|
|
803
|
-
|
|
804
|
-
```ts
|
|
805
|
-
server.dir('/', '/files');
|
|
806
|
-
server.route('/test', () => 200);
|
|
986
|
+
When `index_directories` is enabled, accessing a directory will return a styled HTML page listing the directory contents with file and folder icons.
|
|
807
987
|
|
|
808
|
-
|
|
809
|
-
// Accessing /test returns 404 here because /files/test does not exist.
|
|
810
|
-
```
|
|
988
|
+
#### Custom Directory Handlers
|
|
811
989
|
|
|
812
|
-
|
|
990
|
+
For complete control, provide a custom handler function:
|
|
813
991
|
|
|
814
992
|
```ts
|
|
815
|
-
|
|
993
|
+
server.dir('/static', '/static', (file_path, file, stat, request, url) => {
|
|
816
994
|
// ignore hidden files by default, return 404 to prevent file sniffing
|
|
817
995
|
if (path.basename(file_path).startsWith('.'))
|
|
818
|
-
return
|
|
996
|
+
return HTTP_STATUS_CODE.NotFound_404;
|
|
819
997
|
|
|
820
998
|
if (stat.isDirectory())
|
|
821
|
-
return
|
|
999
|
+
return HTTP_STATUS_CODE.Unauthorized_401;
|
|
822
1000
|
|
|
823
|
-
return
|
|
824
|
-
}
|
|
1001
|
+
return http_apply_range(file, request);
|
|
1002
|
+
});
|
|
825
1003
|
```
|
|
826
1004
|
|
|
827
|
-
> [!NOTE]
|
|
828
|
-
> Uncaught `ENOENT` errors thrown from the directory handler will return a `404` response, other errors will return a `500` response.
|
|
829
|
-
|
|
830
|
-
> [!NOTE]
|
|
831
|
-
> The call to `apply_range` in the default directory handler will automatically slice the file based on the [`Range`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range) header. This function is also exposed as part of the `spooder` API for use in your own handlers.
|
|
832
|
-
|
|
833
|
-
Provide your own directory handler for fine-grained control.
|
|
834
|
-
|
|
835
|
-
> [!IMPORTANT]
|
|
836
|
-
> Providing your own handler will override the default handler defined above. Be sure to implement the same logic if you want to retain the default behavior.
|
|
837
|
-
|
|
838
1005
|
| Parameter | Type | Reference |
|
|
839
1006
|
| --- | --- | --- |
|
|
840
1007
|
| `file_path` | `string` | The path to the file on disk. |
|
|
@@ -843,30 +1010,48 @@ Provide your own directory handler for fine-grained control.
|
|
|
843
1010
|
| `request` | `Request` | https://developer.mozilla.org/en-US/docs/Web/API/Request |
|
|
844
1011
|
| `url` | `URL` | https://developer.mozilla.org/en-US/docs/Web/API/URL |
|
|
845
1012
|
|
|
1013
|
+
Asynchronous directory handlers are supported and will be awaited.
|
|
1014
|
+
|
|
846
1015
|
```ts
|
|
847
|
-
server.dir('/static', '/static', (file_path, file
|
|
848
|
-
|
|
849
|
-
|
|
1016
|
+
server.dir('/static', '/static', async (file_path, file) => {
|
|
1017
|
+
let file_contents = await file.text();
|
|
1018
|
+
// do something with file_contents
|
|
1019
|
+
return file_contents;
|
|
850
1020
|
});
|
|
851
1021
|
```
|
|
852
1022
|
|
|
853
1023
|
> [!NOTE]
|
|
854
1024
|
> The directory handler function is only called for files that exist on disk - including directories.
|
|
855
1025
|
|
|
856
|
-
|
|
1026
|
+
> [!NOTE]
|
|
1027
|
+
> Uncaught `ENOENT` errors thrown from the directory handler will return a `404` response, other errors will return a `500` response.
|
|
857
1028
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
1029
|
+
### 🔧 `http_apply_range(file: BunFile, request: Request): HandlerReturnType`
|
|
1030
|
+
|
|
1031
|
+
`http_apply_range` parses the `Range` header for a request and slices the file accordingly. This is used internally by `server.dir()` and exposed for convenience.
|
|
1032
|
+
|
|
1033
|
+
```ts
|
|
1034
|
+
server.route('/test', (req, url) => {
|
|
1035
|
+
const file = Bun.file('./test.txt');
|
|
1036
|
+
return http_apply_range(file, req);
|
|
863
1037
|
});
|
|
864
1038
|
```
|
|
865
1039
|
|
|
866
|
-
|
|
867
|
-
|
|
1040
|
+
```http
|
|
1041
|
+
GET /test HTTP/1.1
|
|
1042
|
+
Range: bytes=0-5
|
|
1043
|
+
|
|
1044
|
+
HTTP/1.1 206 Partial Content
|
|
1045
|
+
Content-Length: 6
|
|
1046
|
+
Content-Range: bytes 0-5/6
|
|
1047
|
+
Content-Type: text/plain;charset=utf-8
|
|
1048
|
+
|
|
1049
|
+
Hello,
|
|
1050
|
+
```
|
|
1051
|
+
|
|
1052
|
+
<a id="api-http-sse"></a>
|
|
1053
|
+
## API > HTTP > Server-Sent Events (SSE)
|
|
868
1054
|
|
|
869
|
-
<a id="api-routing-server-sse"></a>
|
|
870
1055
|
### 🔧 `server.sse(path: string, handler: ServerSentEventHandler)`
|
|
871
1056
|
|
|
872
1057
|
Setup a [server-sent event](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) stream.
|
|
@@ -904,35 +1089,54 @@ server.sse('/sse', (req, url, client) => {
|
|
|
904
1089
|
});
|
|
905
1090
|
```
|
|
906
1091
|
|
|
907
|
-
<a id="api-
|
|
908
|
-
## API >
|
|
1092
|
+
<a id="api-http-webhooks"></a>
|
|
1093
|
+
## API > HTTP > Webhooks
|
|
909
1094
|
|
|
910
|
-
|
|
911
|
-
### 🔧 `server.webhook(secret: string, path: string, handler: WebhookHandler)`
|
|
1095
|
+
### 🔧 `server.webhook(secret: string, path: string, handler: WebhookHandler, branches?: string | string[])`
|
|
912
1096
|
|
|
913
1097
|
Setup a webhook handler.
|
|
914
1098
|
|
|
915
1099
|
```ts
|
|
916
1100
|
server.webhook(process.env.WEBHOOK_SECRET, '/webhook', payload => {
|
|
917
1101
|
// React to the webhook.
|
|
918
|
-
return
|
|
1102
|
+
return HTTP_STATUS_CODE.OK_200;
|
|
919
1103
|
});
|
|
920
1104
|
```
|
|
921
1105
|
|
|
1106
|
+
#### Branch Filtering
|
|
1107
|
+
|
|
1108
|
+
You can optionally filter webhooks by branch name using the `branches` parameter:
|
|
1109
|
+
|
|
1110
|
+
```ts
|
|
1111
|
+
// Only trigger for main branch
|
|
1112
|
+
server.webhook(process.env.WEBHOOK_SECRET, '/webhook', payload => {
|
|
1113
|
+
// This will only fire for pushes to main branch
|
|
1114
|
+
return HTTP_STATUS_CODE.OK_200;
|
|
1115
|
+
}, 'main');
|
|
1116
|
+
|
|
1117
|
+
// Trigger for multiple branches
|
|
1118
|
+
server.webhook(process.env.WEBHOOK_SECRET, '/webhook', payload => {
|
|
1119
|
+
// This will fire for pushes to main or staging branches
|
|
1120
|
+
return HTTP_STATUS_CODE.OK_200;
|
|
1121
|
+
}, ['main', 'staging']);
|
|
1122
|
+
```
|
|
1123
|
+
|
|
1124
|
+
When branch filtering is enabled, the webhook handler will only be called for pushes to the specified branches. The branch name is extracted from the payload's `ref` field (e.g., `refs/heads/main` becomes `main`).
|
|
1125
|
+
|
|
922
1126
|
A webhook callback will only be called if the following critera is met by a request:
|
|
923
1127
|
- Request method is `POST` (returns `405` otherwise)
|
|
924
1128
|
- Header `X-Hub-Signature-256` is present (returns `400` otherwise)
|
|
925
1129
|
- Header `Content-Type` is `application/json` (returns `401` otherwise)
|
|
926
1130
|
- Request body is a valid JSON object (returns `500` otherwise)
|
|
927
1131
|
- HMAC signature of the request body matches the `X-Hub-Signature-256` header (returns `401` otherwise)
|
|
1132
|
+
- If branch filtering is enabled, the push must be to one of the specified branches (returns `200` but ignores otherwise)
|
|
928
1133
|
|
|
929
1134
|
> [!NOTE]
|
|
930
1135
|
> Constant-time comparison is used to prevent timing attacks when comparing the HMAC signature.
|
|
931
1136
|
|
|
932
|
-
<a id="api-
|
|
933
|
-
## API >
|
|
1137
|
+
<a id="api-http-websockets"></a>
|
|
1138
|
+
## API > HTTP > Websocket Server
|
|
934
1139
|
|
|
935
|
-
<a id="api-routing-server-websocket"></a>
|
|
936
1140
|
### 🔧 `server.websocket(path: string, handlers: WebSocketHandlers)`
|
|
937
1141
|
|
|
938
1142
|
Register a route which handles websocket connections.
|
|
@@ -985,34 +1189,161 @@ server.websocket('/path/to/websocket', {
|
|
|
985
1189
|
> [!IMPORTANT]
|
|
986
1190
|
> While it is possible to register multiple routes for websockets, the only handler which is unique per route is `accept()`. The last handlers provided to any route (with the exception of `accept()`) will apply to ALL websocket routes. This is a limitation in Bun.
|
|
987
1191
|
|
|
988
|
-
<a id="api-
|
|
989
|
-
## API >
|
|
1192
|
+
<a id="api-http-bootstrap"></a>
|
|
1193
|
+
## API > HTTP > Bootstrap
|
|
990
1194
|
|
|
991
|
-
|
|
992
|
-
### 🔧 `server.stop(immediate: boolean)`
|
|
1195
|
+
`spooder` provides a building-block style API with the intention of giving you the blocks to construct a server your way, rather than being shoe-horned into one over-engineered mega-solution which you don't need.
|
|
993
1196
|
|
|
994
|
-
|
|
1197
|
+
For simpler projects, the scaffolding can often look the same, potentially something similar to below.
|
|
995
1198
|
|
|
996
1199
|
```ts
|
|
997
|
-
|
|
998
|
-
|
|
1200
|
+
import { http_serve, cache_http, generate_hash_subs, parse_template, http_apply_range } from 'spooder';
|
|
1201
|
+
import path from 'node:path';
|
|
999
1202
|
|
|
1000
|
-
|
|
1203
|
+
const server = http_serve(80);
|
|
1204
|
+
const cache = cache_http({
|
|
1205
|
+
ttl: 5 * 60 * 60 * 1000, // 5 minutes
|
|
1206
|
+
max_size: 5 * 1024 * 1024, // 5 MB
|
|
1207
|
+
use_canary_reporting: true,
|
|
1208
|
+
use_etags: true
|
|
1209
|
+
});
|
|
1001
1210
|
|
|
1002
|
-
|
|
1003
|
-
|
|
1211
|
+
const base_file = await Bun.file('./html/base_template.html').text();
|
|
1212
|
+
const hash_table = await generate_hash_subs();
|
|
1213
|
+
|
|
1214
|
+
async function default_handler(status_code: number): Promise<Response> {
|
|
1215
|
+
const error_text = HTTP_STATUS_CODE[status_code] as string;
|
|
1216
|
+
const error_page = await Bun.file('./html/error.html').text();
|
|
1217
|
+
|
|
1218
|
+
const content = await parse_template(error_page, {
|
|
1219
|
+
title: error_text,
|
|
1220
|
+
error_code: status_code.toString(),
|
|
1221
|
+
error_text: error_text
|
|
1222
|
+
}, true);
|
|
1223
|
+
|
|
1224
|
+
return new Response(content, { status: status_code });
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
server.error((err: Error) => {
|
|
1228
|
+
caution(err?.message ?? err);
|
|
1229
|
+
return default_handler(HTTP_STATUS_CODE.InternalServerError_500);
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
server.default((req, status_code) => default_handler(status_code));
|
|
1233
|
+
|
|
1234
|
+
server.dir('/static', './static', async (file_path, file, stat, request) => {
|
|
1235
|
+
// ignore hidden files by default, return 404 to prevent file sniffing
|
|
1236
|
+
if (path.basename(file_path).startsWith('.'))
|
|
1237
|
+
return HTTP_STATUS_CODE.NotFound_404;
|
|
1238
|
+
|
|
1239
|
+
if (stat.isDirectory())
|
|
1240
|
+
return HTTP_STATUS_CODE.Unauthorized_401;
|
|
1241
|
+
|
|
1242
|
+
// cache busting
|
|
1243
|
+
const ext = path.extname(file_path);
|
|
1244
|
+
if (ext === '.css' || ext === '.js') {
|
|
1245
|
+
const content = await parse_template(await file.text(), hash_table, true);
|
|
1246
|
+
|
|
1247
|
+
return new Response(content, {
|
|
1248
|
+
headers: {
|
|
1249
|
+
'Content-Type': file.type
|
|
1250
|
+
}
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
return http_apply_range(file, request);
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
function add_route(route: string, file: string, title: string) {
|
|
1258
|
+
server.route(route, async (req) => {
|
|
1259
|
+
return cache.request(req, route, async () => {
|
|
1260
|
+
const file_content = await Bun.file(file).text();
|
|
1261
|
+
const template = await parse_template(base_file, {
|
|
1262
|
+
title: title,
|
|
1263
|
+
content: file_content,
|
|
1264
|
+
...hash_table
|
|
1265
|
+
}, false);
|
|
1266
|
+
|
|
1267
|
+
return template;
|
|
1268
|
+
});
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
add_route('/', './html/index.html', 'Homepage');
|
|
1273
|
+
add_route('/about', './html/about.html', 'About Us');
|
|
1274
|
+
add_route('/contact', './html/contact.html', 'Contact Us');
|
|
1275
|
+
add_route('/privacy', './html/privacy.html', 'Privacy Policy');
|
|
1276
|
+
add_route('/terms', './html/terms.html', 'Terms of Service');
|
|
1004
1277
|
```
|
|
1005
1278
|
|
|
1006
|
-
|
|
1279
|
+
For a project where you are looking for fine control, this may be acceptable, but for bootstrapping simple servers this can be a lot of boilerplate. This is where `server.bootstrap` comes in.
|
|
1280
|
+
|
|
1281
|
+
### 🔧 `server.bootstrap(options: BootstrapOptions): Promise<void>`
|
|
1282
|
+
|
|
1283
|
+
Bootstrap a server using `spooder` utilities with a straight-forward options API, cutting out the boilerplate.
|
|
1284
|
+
|
|
1007
1285
|
```ts
|
|
1008
|
-
|
|
1009
|
-
|
|
1286
|
+
const server = http_serve(80);
|
|
1287
|
+
|
|
1288
|
+
server.bootstrap({
|
|
1289
|
+
base: Bun.file('./html/base_template.html'),
|
|
1290
|
+
|
|
1291
|
+
cache: {
|
|
1292
|
+
ttl: 5 * 60 * 60 * 1000, // 5 minutes
|
|
1293
|
+
max_size: 5 * 1024 * 1024, // 5 MB
|
|
1294
|
+
use_canary_reporting: true,
|
|
1295
|
+
use_etags: true
|
|
1296
|
+
},
|
|
1297
|
+
|
|
1298
|
+
error: {
|
|
1299
|
+
use_canary_reporting: true,
|
|
1300
|
+
error_page: Bun.file('./html/error.html')
|
|
1301
|
+
},
|
|
1302
|
+
|
|
1303
|
+
cache_bust: true,
|
|
1304
|
+
|
|
1305
|
+
static: {
|
|
1306
|
+
directory: './static',
|
|
1307
|
+
route: '/static',
|
|
1308
|
+
sub_ext: ['.css']
|
|
1309
|
+
},
|
|
1310
|
+
|
|
1311
|
+
global_subs: {
|
|
1312
|
+
'project_name': 'Some Project'
|
|
1313
|
+
},
|
|
1314
|
+
|
|
1315
|
+
routes: {
|
|
1316
|
+
'/': {
|
|
1317
|
+
content: Bun.file('./html/index.html'),
|
|
1318
|
+
subs: { 'title': 'Homepage' }
|
|
1319
|
+
},
|
|
1320
|
+
|
|
1321
|
+
'/about': {
|
|
1322
|
+
content: Bun.file('./html/about.html'),
|
|
1323
|
+
subs: { 'title': 'About Us' }
|
|
1324
|
+
},
|
|
1325
|
+
|
|
1326
|
+
'/contact': {
|
|
1327
|
+
content: Bun.file('./html/contact.html'),
|
|
1328
|
+
subs: { 'title': 'Contact Us' }
|
|
1329
|
+
},
|
|
1330
|
+
|
|
1331
|
+
'/privacy': {
|
|
1332
|
+
content: Bun.file('./html/privacy.html'),
|
|
1333
|
+
subs: { 'title': 'Privacy Policy' }
|
|
1334
|
+
},
|
|
1335
|
+
|
|
1336
|
+
'/terms': {
|
|
1337
|
+
content: Bun.file('./html/terms.html'),
|
|
1338
|
+
subs: { 'title': 'Terms of Service' }
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
});
|
|
1010
1342
|
```
|
|
1011
1343
|
|
|
1012
1344
|
<a id="api-error-handling"></a>
|
|
1013
1345
|
## API > Error Handling
|
|
1014
1346
|
|
|
1015
|
-
<a id="api-error-handling-error-with-metadata"></a>
|
|
1016
1347
|
### 🔧 `ErrorWithMetadata(message: string, metadata: object)`
|
|
1017
1348
|
|
|
1018
1349
|
The `ErrorWithMetadata` class allows you to attach metadata to errors, which can be used for debugging purposes when errors are dispatched to the canary.
|
|
@@ -1027,7 +1358,6 @@ Functions and promises contained in the metadata will be resolved and the return
|
|
|
1027
1358
|
throw new ErrorWithMetadata('Something went wrong', { foo: () => 'bar' });
|
|
1028
1359
|
```
|
|
1029
1360
|
|
|
1030
|
-
<a id="api-error-handling-caution"></a>
|
|
1031
1361
|
### 🔧 `caution(err_message_or_obj: string | object, ...err: object[]): Promise<void>`
|
|
1032
1362
|
|
|
1033
1363
|
Raise a warning issue on GitHub. This is useful for non-fatal issues which you want to be notified about.
|
|
@@ -1086,7 +1416,6 @@ await caution('Error with number ' + some_important_value);
|
|
|
1086
1416
|
await caution('Error with number', { some_important_value });
|
|
1087
1417
|
```
|
|
1088
1418
|
|
|
1089
|
-
<a id="api-error-handling-panic"></a>
|
|
1090
1419
|
### 🔧 `panic(err_message_or_obj: string | object, ...err: object[]): Promise<void>`
|
|
1091
1420
|
|
|
1092
1421
|
This behaves the same as `caution()` with the difference that once `panic()` has raised the issue, it will exit the process with a non-zero exit code.
|
|
@@ -1106,7 +1435,6 @@ try {
|
|
|
1106
1435
|
}
|
|
1107
1436
|
```
|
|
1108
1437
|
|
|
1109
|
-
<a id="api-error-handling-safe"></a>
|
|
1110
1438
|
### 🔧 `safe(fn: Callable): Promise<void>`
|
|
1111
1439
|
|
|
1112
1440
|
`safe()` is a utility function that wraps a "callable" and calls `caution()` if it throws an error.
|
|
@@ -1130,24 +1458,196 @@ await safe(() => {
|
|
|
1130
1458
|
});
|
|
1131
1459
|
```
|
|
1132
1460
|
|
|
1133
|
-
<a id="api-
|
|
1134
|
-
## API >
|
|
1461
|
+
<a id="api-workers"></a>
|
|
1462
|
+
## API > Workers
|
|
1135
1463
|
|
|
1136
|
-
|
|
1137
|
-
### 🔧 `parse_template(template: string, replacements: Replacements, drop_missing: boolean): Promise<string>`
|
|
1464
|
+
### 🔧 `worker_event_pipe(worker: Worker, options?: WorkerEventPipeOptions): WorkerEventPipe`
|
|
1138
1465
|
|
|
1139
|
-
|
|
1466
|
+
Create an event-based communication pipe between host and worker processes. This function works both inside and outside of workers and provides a simple event system on top of the native `postMessage` API.
|
|
1140
1467
|
|
|
1141
1468
|
```ts
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1469
|
+
// main thread
|
|
1470
|
+
const worker = new Worker('./some_file.ts');
|
|
1471
|
+
const pipe = worker_event_pipe(worker);
|
|
1472
|
+
|
|
1473
|
+
pipe.on('bar', data => console.log('Received from worker:', data));
|
|
1474
|
+
pipe.send('foo', { x: 42 });
|
|
1475
|
+
|
|
1476
|
+
// worker thread
|
|
1477
|
+
import { worker_event_pipe } from 'spooder';
|
|
1478
|
+
|
|
1479
|
+
const pipe = worker_event_pipe(globalThis as unknown as Worker);
|
|
1480
|
+
|
|
1481
|
+
pipe.on('foo', data => {
|
|
1482
|
+
console.log('Received from main:', data); // { x: 42 }
|
|
1483
|
+
pipe.send('bar', { response: 'success' });
|
|
1484
|
+
});
|
|
1485
|
+
```
|
|
1486
|
+
|
|
1487
|
+
### WorkerEventPipeOptions
|
|
1488
|
+
|
|
1489
|
+
The second parameter of `worker_event_pipe` accepts an object of options.
|
|
1490
|
+
|
|
1491
|
+
Currently the only available option is `use_canary_reporting`. If enabled, the event pipe will call `caution()` when it encounters errors such as malformed payloads.
|
|
1492
|
+
|
|
1493
|
+
### 🔧 `pipe.send(id: string, data?: object): void`
|
|
1494
|
+
|
|
1495
|
+
Send a message to the other side of the worker pipe with the specified event ID and optional data payload.
|
|
1496
|
+
|
|
1497
|
+
```ts
|
|
1498
|
+
pipe.send('user_update', { user_id: 123, name: 'John' });
|
|
1499
|
+
pipe.send('simple_event'); // data defaults to {}
|
|
1500
|
+
```
|
|
1501
|
+
|
|
1502
|
+
### 🔧 `pipe.on(event: string, callback: (data: object) => void | Promise<void>): void`
|
|
1503
|
+
|
|
1504
|
+
Register an event handler for messages with the specified event ID. The callback can be synchronous or asynchronous.
|
|
1505
|
+
|
|
1506
|
+
```ts
|
|
1507
|
+
pipe.on('process_data', async (data) => {
|
|
1508
|
+
const result = await processData(data);
|
|
1509
|
+
pipe.send('data_processed', { result });
|
|
1510
|
+
});
|
|
1511
|
+
|
|
1512
|
+
pipe.on('log_message', (data) => {
|
|
1513
|
+
console.log(data.message);
|
|
1514
|
+
});
|
|
1515
|
+
```
|
|
1516
|
+
|
|
1517
|
+
> [!NOTE]
|
|
1518
|
+
> There can only be one event handler for a specific event ID. Registering a new handler for an existing event ID will overwrite the previous handler.
|
|
1519
|
+
|
|
1520
|
+
### 🔧 `pipe.once(event: string, callback: (data: object) => void | Promise<void>): void`
|
|
1521
|
+
|
|
1522
|
+
Register an event handler for messages with the specified event ID. This is the same as `pipe.on`, except the handler is automatically removed once it is fired.
|
|
1523
|
+
|
|
1524
|
+
```ts
|
|
1525
|
+
pipe.once('one_time_event', async (data) => {
|
|
1526
|
+
// this will only fire once
|
|
1527
|
+
});
|
|
1528
|
+
```
|
|
1529
|
+
|
|
1530
|
+
### 🔧 `pipe.off(event: string): void`
|
|
1531
|
+
|
|
1532
|
+
Unregister an event handler for events with the specified event ID.
|
|
1533
|
+
|
|
1534
|
+
```ts
|
|
1535
|
+
pipe.off('event_name');
|
|
1536
|
+
```
|
|
1537
|
+
|
|
1538
|
+
> [!IMPORTANT]
|
|
1539
|
+
> Each worker pipe instance expects to be the sole handler for the worker's message events. Creating multiple pipes for the same worker may result in unexpected behavior.
|
|
1540
|
+
|
|
1541
|
+
<a id="api-caching"></a>
|
|
1542
|
+
## API > Caching
|
|
1543
|
+
|
|
1544
|
+
### 🔧 `cache_http(options?: CacheOptions)`
|
|
1545
|
+
|
|
1546
|
+
Initialize a file caching system that stores file contents in memory with configurable TTL, size limits, and ETag support for efficient HTTP caching.
|
|
1547
|
+
|
|
1548
|
+
```ts
|
|
1549
|
+
import { cache_http } from 'spooder';
|
|
1550
|
+
|
|
1551
|
+
const cache = cache_http({
|
|
1552
|
+
ttl: 5 * 60 * 1000 // 5 minutes
|
|
1553
|
+
});
|
|
1554
|
+
|
|
1555
|
+
// Use with server routes for static files
|
|
1556
|
+
server.route('/', cache.file('./index.html'));
|
|
1557
|
+
|
|
1558
|
+
// Use with server routes for dynamic content
|
|
1559
|
+
server.route('/dynamic', async (req) => cache.request(req, 'dynamic-page', () => 'Dynamic Content'));
|
|
1560
|
+
```
|
|
1561
|
+
|
|
1562
|
+
The `cache_http()` function returns an object with two methods:
|
|
1563
|
+
|
|
1564
|
+
### 🔧 `cache.file(file_path: string)`
|
|
1565
|
+
Caches static files from the filesystem. This method reads the file from disk and caches its contents with automatic content-type detection.
|
|
1566
|
+
|
|
1567
|
+
```ts
|
|
1568
|
+
// Cache a static HTML file
|
|
1569
|
+
server.route('/', cache.file('./public/index.html'));
|
|
1570
|
+
|
|
1571
|
+
// Cache CSS files
|
|
1572
|
+
server.route('/styles.css', cache.file('./public/styles.css'));
|
|
1573
|
+
```
|
|
1574
|
+
|
|
1575
|
+
### 🔧 `cache.request(req: Request, cache_key: string, content_generator: () => string | Promise<string>): Promise<Response>`
|
|
1576
|
+
Caches dynamic content using a cache key and content generator function. The generator function is called only when the cache is cold (empty or expired). This method directly processes requests and returns responses, making it compatible with any request handler.
|
|
1577
|
+
|
|
1578
|
+
```ts
|
|
1579
|
+
// Cache dynamic HTML content
|
|
1580
|
+
server.route('/user/:id', async (req) => {
|
|
1581
|
+
return cache.request(req, '/user', async () => {
|
|
1582
|
+
const userData = await fetchUserData();
|
|
1583
|
+
return generateUserHTML(userData);
|
|
1584
|
+
});
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
// Cache API responses
|
|
1588
|
+
server.route('/api/stats', async (req) => {
|
|
1589
|
+
return cache.request(req, 'stats', () => {
|
|
1590
|
+
return JSON.stringify({ users: getUserCount(), posts: getPostCount() });
|
|
1591
|
+
});
|
|
1592
|
+
});
|
|
1593
|
+
```
|
|
1594
|
+
|
|
1595
|
+
## Configuration Options
|
|
1596
|
+
|
|
1597
|
+
| Option | Type | Default | Description |
|
|
1598
|
+
| --- | --- | --- | --- |
|
|
1599
|
+
| `ttl` | `number` | `18000000` (5 hours) | Time in milliseconds before cached entries expire |
|
|
1600
|
+
| `max_size` | `number` | `5242880` (5 MB) | Maximum total size of all cached files in bytes |
|
|
1601
|
+
| `use_etags` | `boolean` | `true` | Generate and use ETag headers for cache validation |
|
|
1602
|
+
| `headers` | `Record<string, string>` | `{}` | Additional HTTP headers to include in responses |
|
|
1603
|
+
| `use_canary_reporting` | `boolean` | `false` | Reports faults to canary (see below).
|
|
1604
|
+
|
|
1605
|
+
#### Canary Reporting
|
|
1606
|
+
|
|
1607
|
+
If `use_canary_reporting` is enabled, `spooder` will call `caution()` in two scenarios:
|
|
1608
|
+
|
|
1609
|
+
1. The cache has exceeded it's maximum capacity and had to purge. If this happens frequently, it is an indication that the maximum capacity should be increased or the use of the cache should be evaluated.
|
|
1610
|
+
2. An item cannot enter the cache because it's size is larger than the total size of the cache. This is an indication that either something too large is being cached, or the maximum capacity is far too small.
|
|
1611
|
+
|
|
1612
|
+
#### Cache Behavior
|
|
1613
|
+
|
|
1614
|
+
- Files are cached for the specified TTL duration.
|
|
1615
|
+
- Individual files larger than `max_size` will not be cached
|
|
1616
|
+
- When total cache size exceeds `max_size`, expired entries are removed first
|
|
1617
|
+
- If still over limit, least recently used (LRU) entries are evicted
|
|
1618
|
+
|
|
1619
|
+
**ETag Support:**
|
|
1620
|
+
- When `use_etags` is enabled, SHA-256 hashes are generated for file contents
|
|
1621
|
+
- ETags enable HTTP 304 Not Modified responses for unchanged files
|
|
1622
|
+
- Clients can send `If-None-Match` headers for efficient cache validation
|
|
1623
|
+
|
|
1624
|
+
> [!IMPORTANT]
|
|
1625
|
+
> The cache uses memory storage and will be lost when the server restarts. It's designed for improving response times of frequently requested files rather than persistent storage.
|
|
1626
|
+
|
|
1627
|
+
> [!NOTE]
|
|
1628
|
+
> Files are only cached after the first request. The cache performs lazy loading and does not pre-populate files on initialization.
|
|
1629
|
+
|
|
1630
|
+
### Raw Cache Access
|
|
1631
|
+
|
|
1632
|
+
The internal cache map can be accessed via `cache.entries`. This is exposed primarily for debugging and diagnostics you may wish to implement. It is not recommended that you directly manage this.
|
|
1633
|
+
|
|
1634
|
+
<a id="api-templating"></a>
|
|
1635
|
+
## API > Templating
|
|
1636
|
+
|
|
1637
|
+
### 🔧 `parse_template(template: string, replacements: Replacements, drop_missing: boolean): Promise<string>`
|
|
1638
|
+
|
|
1639
|
+
Replace placeholders in a template string with values from a replacement object.
|
|
1640
|
+
|
|
1641
|
+
```ts
|
|
1642
|
+
const template = `
|
|
1643
|
+
<html>
|
|
1644
|
+
<head>
|
|
1645
|
+
<title>{{title}}</title>
|
|
1646
|
+
</head>
|
|
1147
1647
|
<body>
|
|
1148
|
-
<h1>{
|
|
1149
|
-
<p>{
|
|
1150
|
-
<p>{
|
|
1648
|
+
<h1>{{title}}</h1>
|
|
1649
|
+
<p>{{content}}</p>
|
|
1650
|
+
<p>{{ignored}}</p>
|
|
1151
1651
|
</body>
|
|
1152
1652
|
</html>
|
|
1153
1653
|
`;
|
|
@@ -1168,7 +1668,7 @@ const html = await parse_template(template, replacements);
|
|
|
1168
1668
|
<body>
|
|
1169
1669
|
<h1>Hello, world!</h1>
|
|
1170
1670
|
<p>This is a test.</p>
|
|
1171
|
-
<p>{
|
|
1671
|
+
<p>{{ignored}}</p>
|
|
1172
1672
|
</body>
|
|
1173
1673
|
</html>
|
|
1174
1674
|
```
|
|
@@ -1199,7 +1699,7 @@ const replacer = (placeholder: string) => {
|
|
|
1199
1699
|
return placeholder.toUpperCase();
|
|
1200
1700
|
};
|
|
1201
1701
|
|
|
1202
|
-
await parse_template('Hello {
|
|
1702
|
+
await parse_template('Hello {{world}}', replacer);
|
|
1203
1703
|
```
|
|
1204
1704
|
|
|
1205
1705
|
```html
|
|
@@ -1215,30 +1715,41 @@ await parse_template('Hello {$world}', replacer);
|
|
|
1215
1715
|
</html>
|
|
1216
1716
|
```
|
|
1217
1717
|
|
|
1218
|
-
`parse_template` supports
|
|
1718
|
+
`parse_template` supports conditional rendering with the following syntax.
|
|
1219
1719
|
|
|
1220
1720
|
```html
|
|
1221
|
-
|
|
1721
|
+
<t-if test="foo">I love {{foo}}</t-if>
|
|
1222
1722
|
```
|
|
1223
|
-
Contents contained inside
|
|
1723
|
+
Contents contained inside a `t-if` block will be rendered providing the given value, in this case `foo` is truthy in the substitution table.
|
|
1224
1724
|
|
|
1225
|
-
|
|
1725
|
+
A `t-if` block is only removed if `drop_missing` is `true`, allowing them to persist through multiple passes of a template.
|
|
1226
1726
|
|
|
1227
1727
|
|
|
1228
|
-
`parse_template` supports looping arrays
|
|
1728
|
+
`parse_template` supports looping arrays and objects using the `items` and `as` attributes.
|
|
1729
|
+
|
|
1730
|
+
#### Object/Array Looping with `items` and `as` Attributes
|
|
1229
1731
|
|
|
1230
1732
|
```html
|
|
1231
|
-
|
|
1733
|
+
<t-for items="items" as="item"><div>{{item.name}}: {{item.value}}</div></t-for>
|
|
1232
1734
|
```
|
|
1735
|
+
|
|
1233
1736
|
```ts
|
|
1234
1737
|
const template = `
|
|
1235
1738
|
<ul>
|
|
1236
|
-
|
|
1739
|
+
<t-for items="colors" as="color">
|
|
1740
|
+
<li class="{{color.type}}">
|
|
1741
|
+
{{color.name}}
|
|
1742
|
+
</li>
|
|
1743
|
+
</t-for>
|
|
1237
1744
|
</ul>
|
|
1238
1745
|
`;
|
|
1239
1746
|
|
|
1240
1747
|
const replacements = {
|
|
1241
|
-
|
|
1748
|
+
colors: [
|
|
1749
|
+
{ name: 'red', type: 'warm' },
|
|
1750
|
+
{ name: 'blue', type: 'cool' },
|
|
1751
|
+
{ name: 'green', type: 'neutral' }
|
|
1752
|
+
]
|
|
1242
1753
|
};
|
|
1243
1754
|
|
|
1244
1755
|
const html = await parse_template(template, replacements);
|
|
@@ -1246,19 +1757,37 @@ const html = await parse_template(template, replacements);
|
|
|
1246
1757
|
|
|
1247
1758
|
```html
|
|
1248
1759
|
<ul>
|
|
1249
|
-
<li>red</li>
|
|
1250
|
-
<li>
|
|
1251
|
-
<li>
|
|
1760
|
+
<li class="warm">red</li>
|
|
1761
|
+
<li class="cool">blue</li>
|
|
1762
|
+
<li class="neutral">green</li>
|
|
1252
1763
|
</ul>
|
|
1253
1764
|
```
|
|
1254
1765
|
|
|
1255
|
-
|
|
1766
|
+
#### Dot Notation Property Access
|
|
1767
|
+
|
|
1768
|
+
You can access nested object properties using dot notation:
|
|
1769
|
+
|
|
1770
|
+
```ts
|
|
1771
|
+
const data = {
|
|
1772
|
+
user: {
|
|
1773
|
+
profile: { name: 'John', age: 30 },
|
|
1774
|
+
settings: { theme: 'dark' }
|
|
1775
|
+
}
|
|
1776
|
+
};
|
|
1777
|
+
|
|
1778
|
+
await parse_template('Hello {{user.profile.name}}, you prefer {{user.settings.theme}} mode!', data);
|
|
1779
|
+
// Result: "Hello John, you prefer dark mode!"
|
|
1780
|
+
```
|
|
1781
|
+
|
|
1782
|
+
All placeholders inside a `<t-for>` loop are substituted, but only if the loop variable exists.
|
|
1256
1783
|
|
|
1257
1784
|
In the following example, `missing` does not exist, so `test` is not substituted inside the loop, but `test` is still substituted outside the loop.
|
|
1258
1785
|
|
|
1259
1786
|
```html
|
|
1260
|
-
<div>Hello {
|
|
1261
|
-
|
|
1787
|
+
<div>Hello {{test}}!</div>
|
|
1788
|
+
<t-for items="missing" as="item">
|
|
1789
|
+
<div>Loop {{test}}</div>
|
|
1790
|
+
</t-for>
|
|
1262
1791
|
```
|
|
1263
1792
|
|
|
1264
1793
|
```ts
|
|
@@ -1269,10 +1798,11 @@ await parse_template(..., {
|
|
|
1269
1798
|
|
|
1270
1799
|
```html
|
|
1271
1800
|
<div>Hello world!</div>
|
|
1272
|
-
|
|
1801
|
+
<t-for items="missing" as="item">
|
|
1802
|
+
<div>Loop {{test}}</div>
|
|
1803
|
+
</t-for>
|
|
1273
1804
|
```
|
|
1274
1805
|
|
|
1275
|
-
<a id="api-content-generate-hash-subs"></a>
|
|
1276
1806
|
### 🔧 `generate_hash_subs(length: number, prefix: string, hashes?: Record<string, string>): Promise<Record<string, string>>`
|
|
1277
1807
|
|
|
1278
1808
|
Generate a replacement table for mapping file paths to hashes in templates. This is useful for cache-busting static assets.
|
|
@@ -1286,7 +1816,7 @@ let hash_sub_table = {};
|
|
|
1286
1816
|
generate_hash_subs().then(subs => hash_sub_table = subs).catch(caution);
|
|
1287
1817
|
|
|
1288
1818
|
server.route('/test', (req, url) => {
|
|
1289
|
-
return parse_template('Hello world {
|
|
1819
|
+
return parse_template('Hello world {{hash=docs/project-logo.png}}', hash_sub_table);
|
|
1290
1820
|
});
|
|
1291
1821
|
```
|
|
1292
1822
|
|
|
@@ -1313,11 +1843,10 @@ Use a different prefix other than `hash=` by passing it as the first parameter.
|
|
|
1313
1843
|
generate_hash_subs(7, '$#').then(subs => hash_sub_table = subs).catch(caution);
|
|
1314
1844
|
|
|
1315
1845
|
server.route('/test', (req, url) => {
|
|
1316
|
-
return parse_template('Hello world {$#docs/project-logo.png}', hash_sub_table);
|
|
1846
|
+
return parse_template('Hello world {{$#docs/project-logo.png}}', hash_sub_table);
|
|
1317
1847
|
});
|
|
1318
1848
|
```
|
|
1319
1849
|
|
|
1320
|
-
<a id="api-content-get-git-hashes"></a>
|
|
1321
1850
|
### 🔧 ``get_git_hashes(length: number): Promise<Record<string, string>>``
|
|
1322
1851
|
|
|
1323
1852
|
Internally, `generate_hash_subs()` uses `get_git_hashes()` to retrieve the hash table from git. This function is exposed for convenience.
|
|
@@ -1340,113 +1869,367 @@ const subs = await generate_hash_subs(7, undefined, hashes);
|
|
|
1340
1869
|
// subs[0] -> { 'hash=docs/project-logo.png': '754d9ea' }
|
|
1341
1870
|
```
|
|
1342
1871
|
|
|
1343
|
-
<a id="api-
|
|
1344
|
-
|
|
1872
|
+
<a id="api-database"></a>
|
|
1873
|
+
<a id="api-database-interface"></a>
|
|
1874
|
+
## API > Database
|
|
1345
1875
|
|
|
1346
|
-
|
|
1876
|
+
### 🔧 ``db_cast_set<T extends string>(set: string | null): Set<T>``
|
|
1877
|
+
|
|
1878
|
+
Takes a database SET string and returns a `Set<T>` where `T` is a provided enum.
|
|
1347
1879
|
|
|
1348
1880
|
```ts
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1881
|
+
enum ExampleRow {
|
|
1882
|
+
OPT_A = 'OPT_A',
|
|
1883
|
+
OPT_B = 'OPT_B',
|
|
1884
|
+
OPT_C = 'OPT_C'
|
|
1885
|
+
};
|
|
1886
|
+
|
|
1887
|
+
const set = db_cast_set<ExampleRow>('OPT_A,OPT_B');
|
|
1888
|
+
if (set.has(ExampleRow.OPT_B)) {
|
|
1889
|
+
// ...
|
|
1890
|
+
}
|
|
1353
1891
|
```
|
|
1354
1892
|
|
|
1355
|
-
|
|
1356
|
-
GET /test HTTP/1.1
|
|
1357
|
-
Range: bytes=0-5
|
|
1893
|
+
### 🔧 ``db_serialize_set<T extends string>(set: Set<T> | null): string``
|
|
1358
1894
|
|
|
1359
|
-
|
|
1360
|
-
Content-Length: 6
|
|
1361
|
-
Content-Range: bytes 0-5/6
|
|
1362
|
-
Content-Type: text/plain;charset=utf-8
|
|
1895
|
+
Takes a `Set<T>` and returns a database SET string. If the set is empty or `null`, it returns an empty string.
|
|
1363
1896
|
|
|
1364
|
-
|
|
1897
|
+
```ts
|
|
1898
|
+
enum ExampleRow {
|
|
1899
|
+
OPT_A = 'OPT_A',
|
|
1900
|
+
OPT_B = 'OPT_B',
|
|
1901
|
+
OPT_C = 'OPT_C'
|
|
1902
|
+
};
|
|
1903
|
+
|
|
1904
|
+
const set = new Set<ExampleRow>([ExampleRow.OPT_A, ExampleRow.OPT_B]);
|
|
1905
|
+
|
|
1906
|
+
const serialized = db_serialize_set(set);
|
|
1907
|
+
// > 'OPT_A,OPT_B'
|
|
1365
1908
|
```
|
|
1366
1909
|
|
|
1367
|
-
<a id="api-
|
|
1368
|
-
## API >
|
|
1910
|
+
<a id="api-database-interface-sqlite"></a>
|
|
1911
|
+
## API > Database > Interface > SQLite
|
|
1912
|
+
|
|
1913
|
+
`spooder` provides a simple **SQLite** interface that acts as a wrapper around the Bun SQLite API. The construction parameters match the underlying API.
|
|
1369
1914
|
|
|
1370
|
-
|
|
1371
|
-
|
|
1915
|
+
```ts
|
|
1916
|
+
// see: https://bun.sh/docs/api/sqlite
|
|
1917
|
+
const db = db_sqlite(':memory:', { create: true });
|
|
1918
|
+
db.instance; // raw access to underlying sqlite instance.
|
|
1919
|
+
```
|
|
1372
1920
|
|
|
1373
|
-
|
|
1921
|
+
### Error Reporting
|
|
1922
|
+
|
|
1923
|
+
In the event of an error from SQLite, an applicable value will be returned from interface functions, rather than the error being thrown.
|
|
1374
1924
|
|
|
1375
1925
|
```ts
|
|
1376
|
-
const
|
|
1377
|
-
|
|
1926
|
+
const result = await db.get_single('BROKEN QUERY');
|
|
1927
|
+
if (result !== null) {
|
|
1928
|
+
// do more stuff with result
|
|
1929
|
+
}
|
|
1378
1930
|
```
|
|
1379
1931
|
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1932
|
+
If you have configured the canary reporting feature in spooder, you can instruct the database interface to report errors using this feature with the `use_canary_reporting` parameter.
|
|
1933
|
+
|
|
1934
|
+
```ts
|
|
1935
|
+
const db = db_sqlite(':memory', { ... }, true);
|
|
1936
|
+
```
|
|
1937
|
+
|
|
1938
|
+
### 🔧 ``db_sqlite.update_schema(schema_dir: string, schema_table: string): Promise<void>``
|
|
1939
|
+
|
|
1940
|
+
`spooder` offers a database schema management system. The `update_schema()` function is a shortcut to call this on the underlying database.
|
|
1384
1941
|
|
|
1385
|
-
|
|
1942
|
+
See [API > Database > Schema](#api-database-schema) for information on how schema updating works.
|
|
1943
|
+
|
|
1944
|
+
```ts
|
|
1945
|
+
// without interface
|
|
1946
|
+
import { db_sqlite, db_update_schema_sqlite } from 'spooder';
|
|
1947
|
+
const db = db_sqlite('./my_database.sqlite');
|
|
1948
|
+
await db_update_schema_sqlite(db.instance, './schema');
|
|
1949
|
+
|
|
1950
|
+
// with interface
|
|
1951
|
+
import { db_sqlite } from 'spooder';
|
|
1952
|
+
const db = db_sqlite('./my_database.sqlite');
|
|
1953
|
+
await db.update_schema('./schema');
|
|
1954
|
+
```
|
|
1955
|
+
|
|
1956
|
+
### 🔧 ``db_sqlite.insert(sql: string, ...values: any): number``
|
|
1957
|
+
|
|
1958
|
+
Executes a query and returns the `lastInsertRowid`. Returns `-1` in the event of an error or if `lastInsertRowid` is not provided.
|
|
1959
|
+
|
|
1960
|
+
```ts
|
|
1961
|
+
const id = db.insert('INSERT INTO users (name) VALUES(?)', 'test');
|
|
1962
|
+
```
|
|
1963
|
+
|
|
1964
|
+
### 🔧 ``db_sqlite.insert_object(table: string, obj: Record<string, any>): number``
|
|
1965
|
+
|
|
1966
|
+
Executes an insert query using object key/value mapping and returns the `lastInsertRowid`. Returns `-1` in the event of an error.
|
|
1967
|
+
|
|
1968
|
+
```ts
|
|
1969
|
+
const id = db.insert_object('users', { name: 'John', email: 'john@example.com' });
|
|
1970
|
+
```
|
|
1971
|
+
|
|
1972
|
+
### 🔧 ``db_sqlite.execute(sql: string, ...values: any): number``
|
|
1973
|
+
|
|
1974
|
+
Executes a query and returns the number of affected rows. Returns `-1` in the event of an error.
|
|
1975
|
+
|
|
1976
|
+
```ts
|
|
1977
|
+
const affected = db.execute('UPDATE users SET name = ? WHERE id = ?', 'Jane', 1);
|
|
1386
1978
|
```
|
|
1387
1979
|
|
|
1980
|
+
### 🔧 ``db_sqlite.get_all<T>(sql: string, ...values: any): T[]``
|
|
1981
|
+
|
|
1982
|
+
Returns the complete query result set as an array. Returns empty array if no rows found or if query fails.
|
|
1983
|
+
|
|
1984
|
+
```ts
|
|
1985
|
+
const users = db.get_all<User>('SELECT * FROM users WHERE active = ?', true);
|
|
1986
|
+
```
|
|
1987
|
+
|
|
1988
|
+
### 🔧 ``db_sqlite.get_single<T>(sql: string, ...values: any): T | null``
|
|
1989
|
+
|
|
1990
|
+
Returns the first row from a query result set. Returns `null` if no rows found or if query fails.
|
|
1991
|
+
|
|
1992
|
+
```ts
|
|
1993
|
+
const user = db.get_single<User>('SELECT * FROM users WHERE id = ?', 1);
|
|
1994
|
+
```
|
|
1995
|
+
|
|
1996
|
+
### 🔧 ``db_sqlite.get_column<T>(sql: string, column: string, ...values: any): T[]``
|
|
1997
|
+
|
|
1998
|
+
Returns the query result as a single column array. Returns empty array if no rows found or if query fails.
|
|
1999
|
+
|
|
2000
|
+
```ts
|
|
2001
|
+
const names = db.get_column<string>('SELECT name FROM users', 'name');
|
|
2002
|
+
```
|
|
2003
|
+
|
|
2004
|
+
### 🔧 ``db_sqlite.get_paged<T>(sql: string, values?: any[], page_size?: number): AsyncGenerator<T[]>``
|
|
2005
|
+
|
|
2006
|
+
Returns an async iterator that yields pages of database rows. Each page contains at most `page_size` rows (default 1000).
|
|
2007
|
+
|
|
2008
|
+
```ts
|
|
2009
|
+
for await (const page of db.get_paged<User>('SELECT * FROM users', [], 100)) {
|
|
2010
|
+
console.log(`Processing ${page.length} users`);
|
|
2011
|
+
}
|
|
2012
|
+
```
|
|
2013
|
+
|
|
2014
|
+
### 🔧 ``db_sqlite.count(sql: string, ...values: any): number``
|
|
2015
|
+
|
|
2016
|
+
Returns the value of `count` from a query. Returns `0` if query fails.
|
|
2017
|
+
|
|
2018
|
+
```ts
|
|
2019
|
+
const user_count = db.count('SELECT COUNT(*) AS count FROM users WHERE active = ?', true);
|
|
2020
|
+
```
|
|
2021
|
+
|
|
2022
|
+
### 🔧 ``db_sqlite.count_table(table_name: string): number``
|
|
2023
|
+
|
|
2024
|
+
Returns the total count of rows from a table. Returns `0` if query fails.
|
|
2025
|
+
|
|
2026
|
+
```ts
|
|
2027
|
+
const total_users = db.count_table('users');
|
|
2028
|
+
```
|
|
2029
|
+
|
|
2030
|
+
### 🔧 ``db_sqlite.exists(sql: string, ...values: any): boolean``
|
|
2031
|
+
|
|
2032
|
+
Returns `true` if the query returns any results. Returns `false` if no results found or if query fails.
|
|
2033
|
+
|
|
2034
|
+
```ts
|
|
2035
|
+
const has_active_users = db.exists('SELECT 1 FROM users WHERE active = ? LIMIT 1', true);
|
|
2036
|
+
```
|
|
2037
|
+
|
|
2038
|
+
### 🔧 ``db_sqlite.transaction(scope: (transaction: SQLiteDatabaseInterface) => void | Promise<void>): boolean``
|
|
2039
|
+
|
|
2040
|
+
Executes a callback function within a database transaction. The callback receives a transaction object with all the same database methods available. Returns `true` if the transaction was committed successfully, `false` if it was rolled back due to an error.
|
|
2041
|
+
|
|
2042
|
+
```ts
|
|
2043
|
+
const success = db.transaction(async (tx) => {
|
|
2044
|
+
const user_id = tx.insert('INSERT INTO users (name) VALUES (?)', 'John');
|
|
2045
|
+
tx.insert('INSERT INTO user_profiles (user_id, bio) VALUES (?, ?)', user_id, 'Hello world');
|
|
2046
|
+
});
|
|
2047
|
+
|
|
2048
|
+
if (success) {
|
|
2049
|
+
console.log('Transaction completed successfully');
|
|
2050
|
+
} else {
|
|
2051
|
+
console.log('Transaction was rolled back');
|
|
2052
|
+
}
|
|
2053
|
+
```
|
|
2054
|
+
|
|
2055
|
+
<a id="api-database-interface-mysql"></a>
|
|
2056
|
+
## API > Database > Interface > MySQL
|
|
2057
|
+
|
|
2058
|
+
`spooder` provides a simple **MySQL** interface that acts as a wrapper around the `mysql2` API. The connection options match the underlying API.
|
|
2059
|
+
|
|
1388
2060
|
> [!IMPORTANT]
|
|
1389
|
-
>
|
|
2061
|
+
> MySQL requires the optional dependency `mysql2` to be installed - this is not automatically installed with spooder. This will be replaced when bun:sql supports MySQL natively.
|
|
1390
2062
|
|
|
1391
2063
|
```ts
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
expires?: number,
|
|
1398
|
-
encode?: boolean,
|
|
1399
|
-
max_age?: number
|
|
1400
|
-
};
|
|
2064
|
+
// see: https://github.com/mysqljs/mysql#connection-options
|
|
2065
|
+
const db = await db_mysql({
|
|
2066
|
+
// ...
|
|
2067
|
+
});
|
|
2068
|
+
db.instance; // raw access to underlying mysql2 instance.
|
|
1401
2069
|
```
|
|
1402
2070
|
|
|
1403
|
-
|
|
2071
|
+
### Error Reporting
|
|
1404
2072
|
|
|
1405
|
-
|
|
2073
|
+
In the event of an error from MySQL, an applicable value will be returned from interface functions, rather than the error being thrown.
|
|
1406
2074
|
|
|
1407
2075
|
```ts
|
|
1408
|
-
|
|
2076
|
+
const result = await db.get_single('BROKEN QUERY');
|
|
2077
|
+
if (result !== null) {
|
|
2078
|
+
// do more stuff with result
|
|
2079
|
+
}
|
|
1409
2080
|
```
|
|
1410
2081
|
|
|
1411
|
-
|
|
1412
|
-
|
|
2082
|
+
If you have configured the canary reporting feature in spooder, you can instruct the database interface to report errors using this feature with the `use_canary_reporting` parameter.
|
|
2083
|
+
|
|
2084
|
+
```ts
|
|
2085
|
+
const db = await db_mysql({ ... }, false, true);
|
|
1413
2086
|
```
|
|
1414
2087
|
|
|
1415
|
-
|
|
1416
|
-
### 🔧 `get_cookies(source: Request | Response, decode: boolean = false): Record<string, string>`
|
|
2088
|
+
### Pooling
|
|
1417
2089
|
|
|
1418
|
-
|
|
2090
|
+
MySQL supports connection pooling. This can be configured by providing `true` to the `pool` parameter.
|
|
1419
2091
|
|
|
1420
|
-
```
|
|
1421
|
-
|
|
1422
|
-
Cookie: my_test_cookie=my_cookie_value
|
|
2092
|
+
```ts
|
|
2093
|
+
const pool = await db_mysql({ ... }, true);
|
|
1423
2094
|
```
|
|
1424
2095
|
|
|
2096
|
+
### 🔧 ``db_mysql.update_schema(schema_dir: string, schema_table: string): Promise<void>``
|
|
2097
|
+
|
|
2098
|
+
`spooder` offers a database schema management system. The `update_schema()` function is a shortcut to call this on the underlying database.
|
|
2099
|
+
|
|
2100
|
+
See [API > Database > Schema](#api-database-schema) for information on how schema updating works.
|
|
2101
|
+
|
|
1425
2102
|
```ts
|
|
1426
|
-
|
|
1427
|
-
{
|
|
2103
|
+
// without interface
|
|
2104
|
+
import { db_mysql, db_update_schema_mysql } from 'spooder';
|
|
2105
|
+
const db = await db_mysql({ ... });
|
|
2106
|
+
await db_update_schema_mysql(db.instance, './schema');
|
|
2107
|
+
|
|
2108
|
+
// with interface
|
|
2109
|
+
import { db_mysql } from 'spooder';
|
|
2110
|
+
const db = await db_mysql({ ... });
|
|
2111
|
+
await db.update_schema('./schema');
|
|
1428
2112
|
```
|
|
1429
2113
|
|
|
1430
|
-
|
|
2114
|
+
### 🔧 ``db_mysql.insert(sql: string, ...values: any): Promise<number>``
|
|
1431
2115
|
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
2116
|
+
Executes a query and returns the `LAST_INSERT_ID`. Returns `-1` in the event of an error or if `LAST_INSERT_ID` is not provided.
|
|
2117
|
+
|
|
2118
|
+
```ts
|
|
2119
|
+
const id = await db.insert('INSERT INTO tbl (name) VALUES(?)', 'test');
|
|
1435
2120
|
```
|
|
2121
|
+
|
|
2122
|
+
### 🔧 ``db_mysql.insert_object(table: string, obj: Record<string, any>): Promise<number>``
|
|
2123
|
+
|
|
2124
|
+
Executes an insert query using object key/value mapping and returns the `LAST_INSERT_ID`. Returns `-1` in the event of an error.
|
|
2125
|
+
|
|
2126
|
+
```ts
|
|
2127
|
+
const id = await db.insert_object('users', { name: 'John', email: 'john@example.com' });
|
|
2128
|
+
```
|
|
2129
|
+
|
|
2130
|
+
### 🔧 ``db_mysql.execute(sql: string, ...values: any): Promise<number>``
|
|
2131
|
+
|
|
2132
|
+
Executes a query and returns the number of affected rows. Returns `-1` in the event of an error.
|
|
2133
|
+
|
|
1436
2134
|
```ts
|
|
1437
|
-
const
|
|
1438
|
-
|
|
2135
|
+
const affected = await db.execute('UPDATE users SET name = ? WHERE id = ?', 'Jane', 1);
|
|
2136
|
+
```
|
|
2137
|
+
|
|
2138
|
+
### 🔧 ``db_mysql.get_all<T>(sql: string, ...values: any): Promise<T[]>``
|
|
2139
|
+
|
|
2140
|
+
Returns the complete query result set as an array. Returns empty array if no rows found or if query fails.
|
|
2141
|
+
|
|
2142
|
+
```ts
|
|
2143
|
+
const users = await db.get_all<User>('SELECT * FROM users WHERE active = ?', true);
|
|
2144
|
+
```
|
|
2145
|
+
|
|
2146
|
+
### 🔧 ``db_mysql.get_single<T>(sql: string, ...values: any): Promise<T | null>``
|
|
2147
|
+
|
|
2148
|
+
Returns the first row from a query result set. Returns `null` if no rows found or if query fails.
|
|
2149
|
+
|
|
2150
|
+
```ts
|
|
2151
|
+
const user = await db.get_single<User>('SELECT * FROM users WHERE id = ?', 1);
|
|
2152
|
+
```
|
|
2153
|
+
|
|
2154
|
+
### 🔧 ``db_mysql.get_column<T>(sql: string, column: string, ...values: any): Promise<T[]>``
|
|
2155
|
+
|
|
2156
|
+
Returns the query result as a single column array. Returns empty array if no rows found or if query fails.
|
|
2157
|
+
|
|
2158
|
+
```ts
|
|
2159
|
+
const names = await db.get_column<string>('SELECT name FROM users', 'name');
|
|
2160
|
+
```
|
|
2161
|
+
|
|
2162
|
+
### 🔧 ``db_mysql.call<T>(func_name: string, ...args: any): Promise<T[]>``
|
|
2163
|
+
|
|
2164
|
+
Calls a stored procedure and returns the result set as an array. Returns empty array if no rows found or if query fails.
|
|
2165
|
+
|
|
2166
|
+
```ts
|
|
2167
|
+
const results = await db.call<User>('get_active_users', true, 10);
|
|
2168
|
+
```
|
|
2169
|
+
|
|
2170
|
+
### 🔧 ``db_mysql.get_paged<T>(sql: string, values?: any[], page_size?: number): AsyncGenerator<T[]>``
|
|
2171
|
+
|
|
2172
|
+
Returns an async iterator that yields pages of database rows. Each page contains at most `page_size` rows (default 1000).
|
|
2173
|
+
|
|
2174
|
+
```ts
|
|
2175
|
+
for await (const page of db.get_paged<User>('SELECT * FROM users', [], 100)) {
|
|
2176
|
+
console.log(`Processing ${page.length} users`);
|
|
2177
|
+
}
|
|
2178
|
+
```
|
|
2179
|
+
|
|
2180
|
+
### 🔧 ``db_mysql.count(sql: string, ...values: any): Promise<number>``
|
|
2181
|
+
|
|
2182
|
+
Returns the value of `count` from a query. Returns `0` if query fails.
|
|
2183
|
+
|
|
2184
|
+
```ts
|
|
2185
|
+
const user_count = await db.count('SELECT COUNT(*) AS count FROM users WHERE active = ?', true);
|
|
2186
|
+
```
|
|
2187
|
+
|
|
2188
|
+
### 🔧 ``db_mysql.count_table(table_name: string): Promise<number>``
|
|
2189
|
+
|
|
2190
|
+
Returns the total count of rows from a table. Returns `0` if query fails.
|
|
2191
|
+
|
|
2192
|
+
```ts
|
|
2193
|
+
const total_users = await db.count_table('users');
|
|
2194
|
+
```
|
|
2195
|
+
|
|
2196
|
+
### 🔧 ``db_mysql.exists(sql: string, ...values: any): Promise<boolean>``
|
|
2197
|
+
|
|
2198
|
+
Returns `true` if the query returns any results. Returns `false` if no results found or if query fails.
|
|
2199
|
+
|
|
2200
|
+
```ts
|
|
2201
|
+
const has_active_users = await db.exists('SELECT 1 FROM users WHERE active = ? LIMIT 1', true);
|
|
2202
|
+
```
|
|
2203
|
+
|
|
2204
|
+
### 🔧 ``db_mysql.transaction(scope: (transaction: MySQLDatabaseInterface) => void | Promise<void>): Promise<boolean>``
|
|
2205
|
+
|
|
2206
|
+
Executes a callback function within a database transaction. The callback receives a transaction object with all the same database methods available. Returns `true` if the transaction was committed successfully, `false` if it was rolled back due to an error.
|
|
2207
|
+
|
|
2208
|
+
```ts
|
|
2209
|
+
const success = await db.transaction(async (tx) => {
|
|
2210
|
+
const user_id = await tx.insert('INSERT INTO users (name) VALUES (?)', 'John');
|
|
2211
|
+
await tx.insert('INSERT INTO user_profiles (user_id, bio) VALUES (?, ?)', user_id, 'Hello world');
|
|
2212
|
+
});
|
|
2213
|
+
|
|
2214
|
+
if (success) {
|
|
2215
|
+
console.log('Transaction completed successfully');
|
|
2216
|
+
} else {
|
|
2217
|
+
console.log('Transaction was rolled back');
|
|
2218
|
+
}
|
|
1439
2219
|
```
|
|
1440
2220
|
|
|
1441
2221
|
<a id="api-database-schema"></a>
|
|
1442
|
-
## API > Database Schema
|
|
2222
|
+
## API > Database > Schema
|
|
1443
2223
|
|
|
1444
2224
|
`spooder` provides a straightforward API to manage database schema in revisions through source control.
|
|
1445
2225
|
|
|
1446
|
-
|
|
2226
|
+
```ts
|
|
2227
|
+
// sqlite
|
|
2228
|
+
db_update_schema_sqlite(db: Database, schema_dir: string, schema_table?: string): Promise<void>;
|
|
1447
2229
|
|
|
1448
|
-
|
|
1449
|
-
|
|
2230
|
+
// mysql
|
|
2231
|
+
db_update_schema_mysql(db: Connection, schema_dir: string, schema_table?: string): Promise<void>;
|
|
2232
|
+
```
|
|
1450
2233
|
|
|
1451
2234
|
```ts
|
|
1452
2235
|
// sqlite example
|
|
@@ -1465,41 +2248,20 @@ import mysql from 'mysql2';
|
|
|
1465
2248
|
const db = await mysql.createConnection({
|
|
1466
2249
|
// connection options
|
|
1467
2250
|
// see https://github.com/mysqljs/mysql#connection-options
|
|
1468
|
-
})
|
|
2251
|
+
});
|
|
2252
|
+
await db_update_schema_mysql(db, './schema');
|
|
1469
2253
|
```
|
|
1470
2254
|
|
|
1471
2255
|
> [!IMPORTANT]
|
|
1472
2256
|
> MySQL requires the optional dependency `mysql2` to be installed - this is not automatically installed with spooder. This will be replaced when bun:sql supports MySQL natively.
|
|
1473
2257
|
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
```ts
|
|
1477
|
-
// sqlite example
|
|
1478
|
-
import { db_init_sqlite } from 'spooder';
|
|
1479
|
-
const db = await db_init_sqlite('./database.sqlite', './schema');
|
|
1480
|
-
```
|
|
1481
|
-
|
|
1482
|
-
```ts
|
|
1483
|
-
// mysql example
|
|
1484
|
-
import { db_init_mysql } from 'spooder';
|
|
1485
|
-
const db = await db_init_mysql({
|
|
1486
|
-
// connection options
|
|
1487
|
-
// see https://github.com/mysqljs/mysql#connection-options
|
|
1488
|
-
}, './schema');
|
|
1489
|
-
```
|
|
1490
|
-
|
|
1491
|
-
### Pooling
|
|
2258
|
+
### Interface API
|
|
1492
2259
|
|
|
1493
|
-
|
|
2260
|
+
If you are already using the [database interface API](#api-database-interface) provided by `spooder`, you can call `update_schema()` directly on the interface.
|
|
1494
2261
|
|
|
1495
2262
|
```ts
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
// connection options
|
|
1499
|
-
connectionLimit: 10
|
|
1500
|
-
}, './schema', true);
|
|
1501
|
-
|
|
1502
|
-
const connection = await pool.getConnection();
|
|
2263
|
+
const db = await db_mysql({ ... });
|
|
2264
|
+
await db.update_schema('./schema');
|
|
1503
2265
|
```
|
|
1504
2266
|
|
|
1505
2267
|
### Schema Files
|
|
@@ -1550,7 +2312,7 @@ Each revision should be clearly marked with a comment containing the revision nu
|
|
|
1550
2312
|
|
|
1551
2313
|
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.
|
|
1552
2314
|
|
|
1553
|
-
When calling `
|
|
2315
|
+
When calling `db_update_schema_*`, unapplied revisions will be applied in ascending order (regardless of order within the file) until the schema is up-to-date.
|
|
1554
2316
|
|
|
1555
2317
|
It is acceptable to omit keys. This can be useful to prevent repitition when managing stored procedures, views or functions.
|
|
1556
2318
|
|
|
@@ -1590,7 +2352,7 @@ await db_update_schema_sqlite(db, './schema', 'my_schema_table');
|
|
|
1590
2352
|
> 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.
|
|
1591
2353
|
|
|
1592
2354
|
>[!IMPORTANT]
|
|
1593
|
-
> `
|
|
2355
|
+
> `db_update_schema_*` will throw an error if the revisions cannot be parsed or applied for any reason. It is important you catch and handle appropriately.
|
|
1594
2356
|
|
|
1595
2357
|
```ts
|
|
1596
2358
|
try {
|
|
@@ -1616,6 +2378,21 @@ CREATE ...
|
|
|
1616
2378
|
>[!IMPORTANT]
|
|
1617
2379
|
> Cyclic or missing dependencies will throw an error.
|
|
1618
2380
|
|
|
2381
|
+
<a id="api-utilities"></a>
|
|
2382
|
+
## API > Utilities
|
|
2383
|
+
|
|
2384
|
+
### 🔧 ``filesize(bytes: number): string``
|
|
2385
|
+
|
|
2386
|
+
Returns a human-readable string representation of a file size in bytes.
|
|
2387
|
+
|
|
2388
|
+
```ts
|
|
2389
|
+
filesize(512); // > "512 bytes"
|
|
2390
|
+
filesize(1024); // > "1 kb"
|
|
2391
|
+
filesize(1048576); // > "1 mb"
|
|
2392
|
+
filesize(1073741824); // > "1 gb"
|
|
2393
|
+
filesize(1099511627776); // > "1 tb"
|
|
2394
|
+
```
|
|
2395
|
+
|
|
1619
2396
|
## Legal
|
|
1620
2397
|
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.
|
|
1621
2398
|
|