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 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
- ```json
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
- "auto_restart": 5000,
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, regardless of exit code, `spooder` can automatically restart it after a short delay. To enable this feature specify the restart delay in milliseconds as `auto_restart` in the configuration.
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": 5000
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
- If set to `0`, the server will be restarted immediately without delay. If set to `-1`, the server will not be restarted at all.
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 200;
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 create a GitHub App and configure it:
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
- ### 1. Create a GitHub App
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
- ### 2. Add package.json configuration
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
- ### 3. Setup environment variables
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
- ### 4. Use canary
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
- <a id="api-serving"></a>
468
- ## API > Serving
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-serving-serve"></a>
471
- ### `serve(port: number, hostname?: string): Server`
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 = serve(8080); // port only
479
- const server = serve(3000, '0.0.0.0'); // optional hostname
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
- <a id="api-routing"></a>
493
- ## API > Routing
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
- <a id="api-routing-methods"></a>
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', 301));
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 `HTTP_STATUS_CODE` to convieniently access status code text.
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(HTTP_STATUS_CODE[status_code], { status: status_code });
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
- <a id="api-routing-request-handler"></a>
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
- <a id="api-routing-query-parameters"></a>
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
- <a id="api-routing-wildcards"></a>
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: 200 });
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('/*', () => 301);
640
- server.route('/test', () => 200);
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
- <a id="api-routing-fallback-handlers"></a>
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(500, (req) => {
653
- return new Response('Custom Internal Server Error Message', { status: 500 });
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: 500 });
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: 500 });
888
+ return new Response('Custom Internal Server Error Message', { status: HTTP_STATUS_CODE.InternalServerError_500 });
690
889
  });
691
890
  ```
692
891
 
693
- <a id="api-routing-slow-requests"></a>
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-routing-validation"></a>
739
- ## API > Routing > Validation
934
+ <a id="api-http-directory"></a>
935
+ ## API > HTTP > Directory Serving
740
936
 
741
- <a id="api-routing-validate-req-json"></a>
742
- ### 🔧 `validate_req_json(handler: JSONRequestHandler)`
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.route('/api/endpoint', async (req, url) => {
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
- The problem with this is that if the request body is not valid JSON, the server will throw an error (potentially triggering canary reports) and return a `500` response.
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.route('/api/endpoint', async (req, url) => {
760
- // check content-type header
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
- // do something with json.
770
- return 200;
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
- As you can see this is quite verbose and adds a lot of boilerplate to your handlers. `validate_req_json` can be used to simplify this.
955
+ #### Directory Options
956
+
957
+ You can configure directory behavior using the `DirOptions` interface:
778
958
 
779
959
  ```ts
780
- server.route('/api/endpoint', validate_req_json(async (req, url, json) => {
781
- // do something with json.
782
- return 200;
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
- This behaves the same as the code above, where a `400` status code is returned if the `Content-Type` header is not `application/json` or if the request body is not valid JSON, and no error is thrown.
787
-
788
- > [!NOTE]
789
- > While arrays and other primitives are valid JSON, `validate_req_json` will only pass objects to the handler, since they are the most common use case for JSON request bodies and it removes the need to validate that in the handler. If you need to use arrays or other primitives, either box them in an object or provide your own validation.
967
+ **Options-based configuration:**
968
+ ```ts
969
+ // Enable directory browsing with HTML listings
970
+ server.dir('/files', './public', { index_directories: true });
790
971
 
791
- <a id="api-routing-directory-serving"></a>
792
- ## API > Routing > Directory Serving
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
- <a id="api-routing-server-dir"></a>
795
- ### 🔧 `server.dir(path: string, dir: string, handler?: DirHandler)`
796
- Serve files from a directory.
797
- ```ts
798
- server.dir('/content', './public/content');
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
- > [!IMPORTANT]
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
- // Route / is equal to /* with server.dir()
809
- // Accessing /test returns 404 here because /files/test does not exist.
810
- ```
988
+ #### Custom Directory Handlers
811
989
 
812
- By default, spooder will use the following default handler for serving directories.
990
+ For complete control, provide a custom handler function:
813
991
 
814
992
  ```ts
815
- function default_directory_handler(file_path: string, file: BunFile, stat: DirStat, request: Request): HandlerReturnType {
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 404; // Not Found
996
+ return HTTP_STATUS_CODE.NotFound_404;
819
997
 
820
998
  if (stat.isDirectory())
821
- return 401; // Unauthorized
999
+ return HTTP_STATUS_CODE.Unauthorized_401;
822
1000
 
823
- return apply_range(file, request);
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, stat, request, url) => {
848
- // Implement custom logic.
849
- return file; // HandlerReturnType
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
- Asynchronous directory handlers are supported and will be awaited.
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
- ```js
859
- server.dir('/static', '/static', async (file_path, file) => {
860
- let file_contents = await file.text();
861
- // do something with file_contents
862
- return file_contents;
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
- <a id="api-routing-server-sse"></a>
867
- ## API > Routing > Server-Sent Events
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-routing-webhooks"></a>
908
- ## API > Routing > Webhooks
1092
+ <a id="api-http-webhooks"></a>
1093
+ ## API > HTTP > Webhooks
909
1094
 
910
- <a id="api-routing-server-webhook"></a>
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 200;
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-routing-websockets"></a>
933
- ## API > Routing > WebSockets
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-server-control"></a>
989
- ## API > Server Control
1192
+ <a id="api-http-bootstrap"></a>
1193
+ ## API > HTTP > Bootstrap
990
1194
 
991
- <a id="api-server-control-stop"></a>
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
- Stop the server process immediately, terminating all in-flight requests.
1197
+ For simpler projects, the scaffolding can often look the same, potentially something similar to below.
995
1198
 
996
1199
  ```ts
997
- server.stop(true);
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
- Stop the server process gracefully, waiting for all in-flight requests to complete.
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
- ```ts
1003
- server.stop(false);
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
- `server.stop()` returns a promise, which if awaited, resolves when all pending connections have been completed.
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
- await server.stop(false);
1009
- // do something now all connections are done
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-content"></a>
1134
- ## API > Content
1461
+ <a id="api-workers"></a>
1462
+ ## API > Workers
1135
1463
 
1136
- <a id="api-content-parse-template"></a>
1137
- ### 🔧 `parse_template(template: string, replacements: Replacements, drop_missing: boolean): Promise<string>`
1464
+ ### 🔧 `worker_event_pipe(worker: Worker, options?: WorkerEventPipeOptions): WorkerEventPipe`
1138
1465
 
1139
- Replace placeholders in a template string with values from a replacement object.
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
- const template = `
1143
- <html>
1144
- <head>
1145
- <title>{$title}</title>
1146
- </head>
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>{$title}</h1>
1149
- <p>{$content}</p>
1150
- <p>{$ignored}</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>{$ignored}</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 {$world}', replacer);
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 optional scopes with the following syntax.
1718
+ `parse_template` supports conditional rendering with the following syntax.
1219
1719
 
1220
1720
  ```html
1221
- {$if:foo}I love {$foo}{/if}
1721
+ <t-if test="foo">I love {{foo}}</t-if>
1222
1722
  ```
1223
- Contents contained inside an `if` block will be rendered providing the given value, in this case `foo` is truthy in the substitution table.
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
- An `if` block is only removed if `drop_missing` is `true`, allowing them to persist through multiple passes of a template.
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 with the following syntax.
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
- {$for:foo}My colour is %s{/for}
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
- {$for:foo}<li>%s</li>{/for}
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
- foo: ['red', 'green', 'blue']
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>green</li>
1251
- <li>blue</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
- All placeholders inside a `{$for:}` loop are substituted, but only if the loop variable exists.
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 {$test}!</div>
1261
- {$for:missing}<div>Loop {$test}</div>{/for}
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
- {$for}Loop <div>{$test}</div>{/for}
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 {$hash=docs/project-logo.png}', hash_sub_table);
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-apply-range"></a>
1344
- ### 🔧 `apply_range(file: BunFile, request: Request): HandlerReturnType`
1872
+ <a id="api-database"></a>
1873
+ <a id="api-database-interface"></a>
1874
+ ## API > Database
1345
1875
 
1346
- `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.
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
- server.route('/test', (req, url) => {
1350
- const file = Bun.file('./test.txt');
1351
- return apply_range(file, req);
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
- ```http
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
- HTTP/1.1 206 Partial Content
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
- Hello,
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-state-management"></a>
1368
- ## API > State Management
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
- <a id="api-state-management-set-cookie"></a>
1371
- ### 🔧 `set_cookie(res: Response, name: string, value: string, options?: CookieOptions)`
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
- Set a cookie onto a `Response` object.
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 res = new Response('Cookies!', { status: 200 });
1377
- set_cookie(res, 'my_test_cookie', 'my_cookie_value');
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
- ```http
1381
- HTTP/1.1 200 OK
1382
- Set-Cookie: my_test_cookie=my_cookie_value
1383
- Content-Length: 8
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
- Cookies!
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
- > Spooder does not URL encode cookies by default. This can result in invalid cookies if they contain special characters. See `encode` option on `CookieOptions` below.
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
- type CookieOptions = {
1393
- same_site?: 'Strict' | 'Lax' | 'None',
1394
- secure?: boolean,
1395
- http_only?: boolean,
1396
- path?: string,
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
- Most of the options that can be provided as `CookieOptions` are part of the standard `Set-Cookie` header. See [HTTP Cookies - MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies).
2071
+ ### Error Reporting
1404
2072
 
1405
- Passing `encode` as `true` will URL encode the cookie value.
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
- set_cookie(res, 'my_test_cookie', 'my cookie value', { encode: true });
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
- ```http
1412
- Set-Cookie: my_test_cookie=my%20cookie%20value
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
- <a id="api-state-management-get-cookies"></a>
1416
- ### 🔧 `get_cookies(source: Request | Response, decode: boolean = false): Record<string, string>`
2088
+ ### Pooling
1417
2089
 
1418
- Get cookies from a `Request` or `Response` object.
2090
+ MySQL supports connection pooling. This can be configured by providing `true` to the `pool` parameter.
1419
2091
 
1420
- ```http
1421
- GET /test HTTP/1.1
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
- const cookies = get_cookies(req);
1427
- { my_test_cookie: 'my_cookie_value' }
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
- Cookies are not URL decoded by default. This can be enabled by passing `true` as the second parameter.
2114
+ ### 🔧 ``db_mysql.insert(sql: string, ...values: any): Promise<number>``
1431
2115
 
1432
- ```http
1433
- GET /test HTTP/1.1
1434
- Cookie: my_test_cookie=my%20cookie%20value
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 cookies = get_cookies(req, true);
1438
- { my_test_cookie: 'my cookie value' }
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
- Database schema is updated with `db_update_schema_DRIVER` where `DRIVER` corresponds to the database driver being used.
2226
+ ```ts
2227
+ // sqlite
2228
+ db_update_schema_sqlite(db: Database, schema_dir: string, schema_table?: string): Promise<void>;
1447
2229
 
1448
- > [!NOTE]
1449
- > Currently, only SQLite and MySQL are supported. This may be expanded once Bun supports more database drivers.
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
- Database initiation and schema updating can be streamlined with the `db_init_DRIVER` functions. The following examples are equivalent to the above ones.
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
- Providing `true` to the `pool` parameter of `db_init_mysql` will return a connection pool instead of a single connection.
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
- import { db_init_mysql } from 'spooder';
1497
- const pool = await db_init_mysql({
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 `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.
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
- > `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.
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