spooder 4.6.2 → 5.0.0

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/src/api.ts CHANGED
@@ -2,27 +2,304 @@ import { dispatch_report } from './dispatch';
2
2
  import http from 'node:http';
3
3
  import path from 'node:path';
4
4
  import fs from 'node:fs/promises';
5
- import { log } from './utils';
6
5
  import crypto from 'crypto';
7
6
  import { Blob } from 'node:buffer';
8
- import { Database } from 'bun:sqlite';
9
- import type * as mysql_types from 'mysql2/promise';
10
-
11
- let mysql: typeof mysql_types | undefined;
12
- try {
13
- mysql = await import('mysql2/promise') as typeof mysql_types;
14
- } catch (e) {
15
- // mysql2 optional dependency not installed.
16
- // this dependency will be replaced once bun:sql supports mysql.
17
- // db_update_schema_mysql and db_init_schema_mysql will throw.
7
+ import { ColorInput } from 'bun';
8
+ import packageJson from '../package.json' with { type: 'json' };
9
+
10
+ // region api forwarding
11
+ export * from './api_db';
12
+ // endregion
13
+
14
+ // region workers
15
+ type WorkerMessageData = Record<string, any>;
16
+ type WorkerEventPipeOptions = {
17
+ use_canary_reporting?: boolean;
18
+ };
19
+
20
+ export interface WorkerEventPipe {
21
+ send: (id: string, data?: object) => void;
22
+ on: (event: string, callback: (data: WorkerMessageData) => Promise<void> | void) => void;
23
+ once: (event: string, callback: (data: WorkerMessageData) => Promise<void> | void) => void;
24
+ off: (event: string) => void;
18
25
  }
19
26
 
20
- export const HTTP_STATUS_CODE = http.STATUS_CODES;
27
+ function worker_validate_message(message: any) {
28
+ if (typeof message !== 'object' || message === null)
29
+ throw new ErrorWithMetadata('invalid worker message type', { message });
30
+
31
+ if (typeof message.id !== 'string')
32
+ throw new Error('missing worker message .id');
33
+ }
21
34
 
22
- // Create enum containing HTTP methods
23
- type HTTP_METHOD = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'CONNECT' | 'TRACE';
24
- type HTTP_METHODS = HTTP_METHOD|HTTP_METHOD[];
35
+ const log_worker = log_create_logger('worker', 'spooder');
36
+ export function worker_event_pipe(worker: Worker, options?: WorkerEventPipeOptions): WorkerEventPipe {
37
+ const use_canary_reporting = options?.use_canary_reporting ?? false;
38
+ const callbacks = new Map<string, (data: Record<string, any>) => Promise<void> | void>();
39
+
40
+ function handle_message(event: MessageEvent) {
41
+ try {
42
+ const message = JSON.parse(event.data);
43
+ worker_validate_message(message);
44
+
45
+ const callback = callbacks.get(message.id);
46
+ if (callback !== undefined)
47
+ callback(message.data ?? {});
48
+ } catch (e) {
49
+ log_error(`exception in worker: ${(e as Error).message}`);
50
+
51
+ if (use_canary_reporting)
52
+ caution('worker: exception handling payload', { exception: e });
53
+ }
54
+ }
55
+
56
+ if (Bun.isMainThread) {
57
+ log_worker(`event pipe connected {main thread} ⇄ {worker}`);
58
+ worker.addEventListener('message', handle_message);
59
+ } else {
60
+ log_worker(`event pipe connected {worker} ⇄ {main thread}`);
61
+ worker.onmessage = handle_message;
62
+ }
63
+
64
+ return {
65
+ send: (id: string, data: object = {}) => {
66
+ worker.postMessage(JSON.stringify({ id, data }));
67
+ },
68
+
69
+ on: (event: string, callback: (data: WorkerMessageData) => Promise<void> | void) => {
70
+ callbacks.set(event, callback);
71
+ },
72
+
73
+ off: (event: string) => {
74
+ callbacks.delete(event);
75
+ },
76
+
77
+ once: (event: string, callback: (data: WorkerMessageData) => Promise<void> | void) => {
78
+ callbacks.set(event, async (data: WorkerMessageData) => {
79
+ await callback(data);
80
+ callbacks.delete(event);
81
+ });
82
+ }
83
+ };
84
+ }
85
+ // endregion
86
+
87
+ // region utility
88
+ const FILESIZE_UNITS = ['bytes', 'kb', 'mb', 'gb', 'tb'];
89
+
90
+ function filesize(bytes: number): string {
91
+ if (bytes === 0)
92
+ return '0 bytes';
93
+
94
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
95
+ const size = bytes / Math.pow(1024, i);
96
+
97
+ return `${size.toFixed(i === 0 ? 0 : 1)} ${FILESIZE_UNITS[i]}`;
98
+ }
99
+ // endregion
100
+
101
+ // region logging
102
+ export function log_create_logger(label: string, color: ColorInput = 'blue') {
103
+ if (color === 'spooder')
104
+ color = '#16b39e';
105
+
106
+ const ansi = Bun.color(color, 'ansi-256') ?? '\x1b[38;5;6m';
107
+ const prefix = `[${ansi}${label}\x1b[0m] `;
108
+
109
+ return (message: string) => {
110
+ process.stdout.write(prefix + message.replace(/\{([^}]+)\}/g, `${ansi}$1\x1b[0m`) + '\n');
111
+ };
112
+ }
113
+
114
+ export function log_list(input: any[], delimiter = ',') {
115
+ return input.map(e => `{${e}}`).join(delimiter);
116
+ }
25
117
 
118
+ const log_spooder = log_create_logger('spooder', 'spooder');
119
+ export const log = log_create_logger('info', 'blue');
120
+ export const log_error = log_create_logger('error', 'red');
121
+
122
+ // endregion
123
+
124
+ // region cache
125
+ type CacheOptions = {
126
+ ttl?: number;
127
+ max_size?: number;
128
+ use_etags?: boolean;
129
+ headers?: Record<string, string>,
130
+ use_canary_reporting?: boolean;
131
+ };
132
+
133
+ type CacheEntry = {
134
+ content: string;
135
+ last_access_ts: number;
136
+ etag?: string;
137
+ content_type: string;
138
+ size: number;
139
+ cached_ts: number;
140
+ };
141
+
142
+ const CACHE_DEFAULT_TTL = 5 * 60 * 60 * 1000; // 5 hours
143
+ const CACHE_DEFAULT_MAX_SIZE = 5 * 1024 * 1024; // 5 MB
144
+
145
+ const log_cache = log_create_logger('cache', 'spooder');
146
+
147
+ function is_cache_http(target: any): target is ReturnType<typeof cache_http> {
148
+ return target && typeof target === 'object' && 'entries' in target && typeof target.entries === 'object';
149
+ }
150
+
151
+ export function cache_http(options?: CacheOptions) {
152
+ const ttl = options?.ttl ?? CACHE_DEFAULT_TTL;
153
+ const max_cache_size = options?.max_size ?? CACHE_DEFAULT_MAX_SIZE;
154
+ const use_etags = options?.use_etags ?? true;
155
+ const cache_headers = options?.headers ?? {};
156
+ const canary_report = options?.use_canary_reporting ?? false;
157
+
158
+ const entries = new Map<string, CacheEntry>();
159
+ let total_cache_size = 0;
160
+
161
+ function get_and_validate_entry(cache_key: string, now_ts: number): CacheEntry | undefined {
162
+ let entry = entries.get(cache_key);
163
+
164
+ if (entry) {
165
+ entry.last_access_ts = now_ts;
166
+
167
+ if (now_ts - entry.cached_ts > ttl) {
168
+ log_cache(`access: invalidating expired cache entry {${cache_key}} (TTL expired)`);
169
+ entries.delete(cache_key);
170
+ total_cache_size -= entry.size ?? 0;
171
+ entry = undefined;
172
+ }
173
+ }
174
+
175
+ return entry;
176
+ }
177
+
178
+ function store_cache_entry(cache_key: string, entry: CacheEntry, now_ts: number): void {
179
+ const size = entry.size;
180
+
181
+ if (size < max_cache_size) {
182
+ if (use_etags)
183
+ entry.etag = crypto.createHash('sha256').update(entry.content).digest('hex');
184
+
185
+ entries.set(cache_key, entry);
186
+ total_cache_size += size;
187
+
188
+ log_cache(`caching {${cache_key}} (size: {${filesize(size)}}, etag: {${entry.etag ?? 'none'}})`);
189
+
190
+ if (total_cache_size > max_cache_size) {
191
+ log_cache(`exceeded maximum capacity {${filesize(total_cache_size)}} > {${filesize(max_cache_size)}}, freeing space...`);
192
+
193
+ if (canary_report) {
194
+ caution('cache exceeded maximum capacity', {
195
+ total_cache_size,
196
+ max_cache_size,
197
+ item_count: entries.size
198
+ });
199
+ }
200
+
201
+ log_cache(`free: force-invalidating expired entries`);
202
+ for (const [key, cache_entry] of entries.entries()) {
203
+ if (now_ts - cache_entry.last_access_ts > ttl) {
204
+ log_cache(`free: invalidating expired cache entry {${key}} (TTL expired)`);
205
+ entries.delete(key);
206
+ total_cache_size -= cache_entry.size;
207
+ }
208
+ }
209
+
210
+ if (total_cache_size > max_cache_size) {
211
+ log_cache(`free: cache still over-budget {${filesize(total_cache_size)}} > {${filesize(max_cache_size)}}, pruning by last access`);
212
+ const sorted_entries = Array.from(entries.entries()).sort((a, b) => a[1].last_access_ts - b[1].last_access_ts);
213
+ for (let i = 0; i < sorted_entries.length && total_cache_size > max_cache_size; i++) {
214
+ const [key, cache_entry] = sorted_entries[i];
215
+ log_cache(`free: removing entry {${key}} (size: {${filesize(cache_entry.size)}})`);
216
+ entries.delete(key);
217
+ total_cache_size -= cache_entry.size;
218
+ }
219
+ }
220
+ }
221
+ } else {
222
+ log_cache(`{${cache_key}} cannot enter cache, exceeds maximum size {${filesize(size)} > ${filesize(max_cache_size)}}`);
223
+
224
+ if (canary_report) {
225
+ caution('cache entry exceeds maximum size', {
226
+ file_path: cache_key,
227
+ size,
228
+ max_cache_size
229
+ });
230
+ }
231
+ }
232
+ }
233
+
234
+ function build_response(entry: CacheEntry, req: Request, status_code: number): Response {
235
+ const headers = Object.assign({
236
+ 'Content-Type': entry.content_type
237
+ }, cache_headers) as Record<string, string>;
238
+
239
+ if (use_etags && entry.etag) {
240
+ headers['ETag'] = entry.etag;
241
+
242
+ if (req.headers.get('If-None-Match') === entry.etag)
243
+ return new Response(null, { status: 304, headers });
244
+ }
245
+
246
+ return new Response(entry.content, { status: status_code, headers });
247
+ }
248
+
249
+ return {
250
+ entries,
251
+
252
+ file(file_path: string) {
253
+ return async (req: Request, url: URL) => {
254
+ const now_ts = Date.now();
255
+ let entry = get_and_validate_entry(file_path, now_ts);
256
+
257
+ if (entry === undefined) {
258
+ const file = Bun.file(file_path);
259
+ const content = await file.text();
260
+ const size = Buffer.byteLength(content);
261
+
262
+ entry = {
263
+ content,
264
+ size,
265
+ last_access_ts: now_ts,
266
+ content_type: file.type,
267
+ cached_ts: now_ts
268
+ };
269
+
270
+ store_cache_entry(file_path, entry, now_ts);
271
+ }
272
+
273
+ return build_response(entry, req, 200);
274
+ };
275
+ },
276
+
277
+ async request(req: Request, cache_key: string, content_generator: () => string | Promise<string>, status_code = 200): Promise<Response> {
278
+ const now_ts = Date.now();
279
+ let entry = get_and_validate_entry(cache_key, now_ts);
280
+
281
+ if (entry === undefined) {
282
+ const content = await content_generator();
283
+ const size = Buffer.byteLength(content);
284
+
285
+ entry = {
286
+ content,
287
+ size,
288
+ last_access_ts: now_ts,
289
+ content_type: 'text/html',
290
+ cached_ts: now_ts
291
+ };
292
+
293
+ store_cache_entry(cache_key, entry, now_ts);
294
+ }
295
+
296
+ return build_response(entry, req, status_code);
297
+ }
298
+ };
299
+ }
300
+ // endregion
301
+
302
+ // region error handling
26
303
  export class ErrorWithMetadata extends Error {
27
304
  constructor(message: string, public metadata: Record<string, unknown>) {
28
305
  super(message);
@@ -30,66 +307,66 @@ export class ErrorWithMetadata extends Error {
30
307
  if (this.stack)
31
308
  this.stack = this.stack.split('\n').slice(1).join('\n');
32
309
  }
33
-
310
+
34
311
  async resolve_metadata(): Promise<object> {
35
312
  const metadata = Object.assign({}, this.metadata);
36
313
  for (const [key, value] of Object.entries(metadata)) {
37
314
  let resolved_value = value;
38
-
315
+
39
316
  if (value instanceof Promise)
40
317
  resolved_value = await value;
41
318
  else if (typeof value === 'function')
42
319
  resolved_value = await value();
43
320
  else if (value instanceof ReadableStream)
44
321
  resolved_value = await Bun.readableStreamToText(value);
45
-
322
+
46
323
  if (typeof resolved_value === 'string' && resolved_value.includes('\n'))
47
324
  resolved_value = resolved_value.split(/\r?\n/);
48
-
325
+
49
326
  metadata[key] = resolved_value;
50
327
  }
51
-
328
+
52
329
  return metadata;
53
330
  }
54
331
  }
55
332
 
56
333
  async function handle_error(prefix: string, err_message_or_obj: string | object, ...err: unknown[]): Promise<void> {
57
334
  let error_message = 'unknown error';
58
-
335
+
59
336
  if (typeof err_message_or_obj === 'string') {
60
337
  error_message = err_message_or_obj;
61
338
  err.unshift(error_message);
62
339
  } else {
63
340
  if (err_message_or_obj instanceof Error)
64
341
  error_message = err_message_or_obj.message;
65
-
342
+
66
343
  err.push(err_message_or_obj);
67
344
  }
68
-
345
+
69
346
  const final_err = Array(err.length);
70
347
  for (let i = 0; i < err.length; i++) {
71
348
  const e = err[i];
72
-
349
+
73
350
  if (e instanceof Error) {
74
351
  const report = {
75
352
  name: e.name,
76
353
  message: e.message,
77
354
  stack: e.stack?.split('\n') ?? []
78
355
  } as Record<string, unknown>;
79
-
356
+
80
357
  if (e instanceof ErrorWithMetadata)
81
358
  report.metadata = await e.resolve_metadata();
82
-
359
+
83
360
  final_err[i] = report;
84
361
  } else {
85
362
  final_err[i] = e;
86
363
  }
87
364
  }
88
-
365
+
89
366
  if (process.env.SPOODER_ENV === 'dev') {
90
- log('[{dev}] dispatch_report %s', prefix + error_message);
91
- log('[{dev}] without {--dev}, this would raise a canary report');
92
- log('[{dev}] %o', final_err);
367
+ log_spooder(`[{dev}] dispatch_report ${prefix + error_message}`);
368
+ log_spooder('[{dev}] without {--dev}, this would raise a canary report');
369
+ log_spooder(`[{dev}] ${final_err}`);
93
370
  } else {
94
371
  await dispatch_report(prefix + error_message, final_err);
95
372
  }
@@ -104,16 +381,6 @@ export async function caution(err_message_or_obj: string | object, ...err: objec
104
381
  await handle_error('caution: ', err_message_or_obj, ...err);
105
382
  }
106
383
 
107
- type WebsocketAcceptReturn = object | boolean;
108
- type WebsocketHandlers = {
109
- accept?: (req: Request) => WebsocketAcceptReturn | Promise<WebsocketAcceptReturn>,
110
- message?: (ws: WebSocket, message: string) => void,
111
- message_json?: (ws: WebSocket, message: JsonSerializable) => void,
112
- open?: (ws: WebSocket) => void,
113
- close?: (ws: WebSocket, code: number, reason: string) => void,
114
- drain?: (ws: WebSocket) => void
115
- };
116
-
117
384
  type CallableFunction = (...args: any[]) => any;
118
385
  type Callable = Promise<any> | CallableFunction;
119
386
 
@@ -122,91 +389,139 @@ export async function safe(target_fn: Callable) {
122
389
  if (target_fn instanceof Promise)
123
390
  await target_fn;
124
391
  else
125
- await target_fn();
392
+ await target_fn();
126
393
  } catch (e) {
127
394
  caution(e as Error);
128
395
  }
129
396
  }
397
+ // endregion
130
398
 
399
+ // region templates
131
400
  type ReplacerFn = (key: string) => string | Array<string> | undefined;
132
401
  type AsyncReplaceFn = (key: string) => Promise<string | Array<string> | undefined>;
133
- type Replacements = Record<string, string | Array<string>> | ReplacerFn | AsyncReplaceFn;
402
+ type Replacements = Record<string, string | Array<string> | object | object[]> | ReplacerFn | AsyncReplaceFn;
134
403
 
135
- export async function parse_template(template: string, replacements: Replacements, drop_missing = false): Promise<string> {
136
- let result = '';
137
- let buffer = '';
138
- let buffer_active = false;
404
+ function get_nested_property(obj: any, path: string): any {
405
+ const keys = path.split('.');
406
+ let current = obj;
407
+
408
+ for (const key of keys) {
409
+ if (current === null || current === undefined || typeof current !== 'object')
410
+ return undefined;
411
+ current = current[key];
412
+ }
413
+
414
+ return current;
415
+ }
139
416
 
417
+ export async function parse_template(template: string, replacements: Replacements, drop_missing = false): Promise<string> {
140
418
  const is_replacer_fn = typeof replacements === 'function';
141
-
142
- const template_length = template.length;
143
- for (let i = 0; i < template_length; i++) {
144
- const char = template[i];
145
-
146
- if (char === '{' && template[i + 1] === '$') {
147
- i++;
148
- buffer_active = true;
149
- buffer = '';
150
- } else if (char === '}' && buffer_active) {
151
- buffer_active = false;
152
-
153
- if (buffer.startsWith('for:')) {
154
- const loop_key = buffer.substring(4);
155
-
156
- const loop_entries = is_replacer_fn ? await replacements(loop_key) : replacements[loop_key];
157
- const loop_content_start_index = i + 1;
158
- const loop_close_index = template.indexOf('{/for}', loop_content_start_index);
159
-
160
- if (loop_close_index === -1) {
161
- if (!drop_missing)
162
- result += '{$' + buffer + '}';
163
- } else {
164
- const loop_content = template.substring(loop_content_start_index, loop_close_index);
165
- if (loop_entries !== undefined) {
166
- for (const loop_entry of loop_entries) {
167
- const inner_content = loop_content.replaceAll('%s', loop_entry);
168
- result += await parse_template(inner_content, replacements, drop_missing);
169
- }
419
+ let result = template;
420
+ let previous_result = '';
421
+
422
+ // Keep processing until no more changes occur (handles nested tags)
423
+ while (result !== previous_result) {
424
+ previous_result = result;
425
+
426
+ // Parse t-for tags first (outermost structures)
427
+ const for_regex = /<t-for\s+items="([^"]+)"\s+as="([^"]+)"\s*>(.*?)<\/t-for>/gs;
428
+ result = await replace_async(result, for_regex, async (match, entries_key, alias_name, loop_content) => {
429
+ const loop_entries = is_replacer_fn ? await replacements(entries_key) : replacements[entries_key];
430
+
431
+ if (loop_entries !== undefined && Array.isArray(loop_entries)) {
432
+ let loop_result = '';
433
+ for (const loop_entry of loop_entries) {
434
+ let scoped_replacements: Replacements;
435
+
436
+ if (typeof replacements === 'function') {
437
+ scoped_replacements = async (key: string) => {
438
+ if (key === alias_name) return loop_entry;
439
+ if (key.startsWith(alias_name + '.')) {
440
+ const prop_path = key.substring(alias_name.length + 1);
441
+ return get_nested_property(loop_entry, prop_path);
442
+ }
443
+ return await replacements(key);
444
+ };
170
445
  } else {
171
- if (!drop_missing)
172
- result += '{$' + buffer + '}' + loop_content + '{/for}';
446
+ scoped_replacements = {
447
+ ...replacements,
448
+ [alias_name]: loop_entry
449
+ };
173
450
  }
174
- i += loop_content.length + 6;
451
+
452
+ loop_result += await parse_template(loop_content, scoped_replacements, drop_missing);
175
453
  }
176
- } else if (buffer.startsWith('if:')) {
177
- const if_key = buffer.substring(3);
178
- const if_content_start_index = i + 1;
179
- const if_close_index = template.indexOf('{/if}', if_content_start_index);
180
-
181
- if (if_close_index === -1) {
182
- if (!drop_missing)
183
- result += '{$' + buffer + '}';
184
- } else {
185
- const if_content = template.substring(if_content_start_index, if_close_index);
186
- const condition_value = is_replacer_fn ? await replacements(if_key) : replacements[if_key];
187
-
188
- if (!drop_missing) {
189
- result += '{$' + buffer + '}' + if_content + '{/if}';
190
- } else if (condition_value) {
191
- result += await parse_template(if_content, replacements, drop_missing);
454
+ return loop_result;
455
+ } else {
456
+ if (!drop_missing)
457
+ return match;
458
+
459
+ return '';
460
+ }
461
+ });
462
+
463
+ // Parse t-if tags
464
+ const if_regex = /<t-if\s+test="([^"]+)"\s*>(.*?)<\/t-if>/gs;
465
+ result = await replace_async(result, if_regex, async (match, condition_key, if_content) => {
466
+ const condition_value = is_replacer_fn ? await replacements(condition_key) : replacements[condition_key];
467
+
468
+ if (!drop_missing && !condition_value)
469
+ return match;
470
+ if (condition_value)
471
+ return await parse_template(if_content, replacements, drop_missing);
472
+
473
+ return '';
474
+ });
475
+
476
+ // Parse {{variable}} tags (innermost)
477
+ const var_regex = /\{\{([^}]+)\}\}/g;
478
+ result = await replace_async(result, var_regex, async (match, var_name) => {
479
+ // Trim whitespace from variable name
480
+ var_name = var_name.trim();
481
+ let replacement;
482
+
483
+ if (is_replacer_fn) {
484
+ replacement = await replacements(var_name);
485
+ } else {
486
+ // First try direct key lookup (handles hash keys with dots like "hash=.gitignore")
487
+ replacement = replacements[var_name];
488
+
489
+ // If direct lookup fails and variable contains dots, try nested property access
490
+ if (replacement === undefined && var_name.includes('.')) {
491
+ const dot_index = var_name.indexOf('.');
492
+ const base_key = var_name.substring(0, dot_index);
493
+ const prop_path = var_name.substring(dot_index + 1);
494
+ const base_obj = replacements[base_key];
495
+
496
+ if (base_obj !== undefined) {
497
+ replacement = get_nested_property(base_obj, prop_path);
192
498
  }
193
- i += if_content.length + 5;
194
499
  }
195
- } else {
196
- const replacement = is_replacer_fn ? await replacements(buffer) : replacements[buffer];
197
- if (replacement !== undefined)
198
- result += replacement;
199
- else if (!drop_missing)
200
- result += '{$' + buffer + '}';
201
500
  }
202
- buffer = '';
203
- } else if (buffer_active) {
204
- buffer += char;
205
- } else {
206
- result += char;
207
- }
501
+
502
+ if (replacement !== undefined)
503
+ return replacement;
504
+
505
+ if (!drop_missing)
506
+ return match;
507
+
508
+ return '';
509
+ });
208
510
  }
511
+
512
+ return result;
513
+ }
209
514
 
515
+ async function replace_async(str: string, regex: RegExp, replacer_fn: (match: string, ...args: any[]) => Promise<string>): Promise<string> {
516
+ const matches = Array.from(str.matchAll(regex));
517
+ let result = str;
518
+
519
+ for (let i = matches.length - 1; i >= 0; i--) {
520
+ const match = matches[i];
521
+ const replacement = await replacer_fn(match[0], ...match.slice(1));
522
+ result = result.substring(0, match.index!) + replacement + result.substring(match.index! + match[0].length);
523
+ }
524
+
210
525
  return result;
211
526
  }
212
527
 
@@ -216,347 +531,204 @@ export async function get_git_hashes(length = 7): Promise<Record<string, string>
216
531
  stdout: 'pipe',
217
532
  stderr: 'pipe'
218
533
  });
219
-
534
+
220
535
  await process.exited;
221
-
536
+
222
537
  if (process.exitCode as number > 0)
223
538
  throw new Error('get_git_hashes() failed, `' + cmd.join(' ') + '` exited with non-zero exit code.');
224
-
539
+
225
540
  const stdout = await Bun.readableStreamToText(process.stdout as ReadableStream);
226
541
  const hash_map: Record<string, string> = {};
227
-
542
+
228
543
  const regex = /([^\s]+)\s([^\s]+)\s([^\s]+)\t(.+)/g;
229
544
  let match: RegExpExecArray | null;
230
-
545
+
231
546
  while (match = regex.exec(stdout))
232
547
  hash_map[match[4]] = match[3].substring(0, length);
233
-
548
+
234
549
  return hash_map;
235
550
  }
236
551
 
237
552
  export async function generate_hash_subs(length = 7, prefix = 'hash=', hashes?: Record<string, string>): Promise<Record<string, string>> {
238
553
  const hash_map: Record<string, string> = {};
239
-
554
+
240
555
  if (!hashes)
241
556
  hashes = await get_git_hashes(length);
242
-
557
+
243
558
  for (const [file, hash] of Object.entries(hashes))
244
559
  hash_map[prefix + file] = hash;
245
-
560
+
246
561
  return hash_map;
247
562
  }
248
-
249
- interface DependencyTarget {
250
- file_name: string;
251
- deps: string[];
252
- }
253
-
254
- function order_schema_dep_tree<T extends DependencyTarget>(deps: T[]): T[] {
255
- const visited = new Set<string>();
256
- const temp = new Set<string>();
257
- const result: T[] = [];
258
- const map = new Map(deps.map(d => [d.file_name, d]));
259
-
260
- function visit(node: T): void {
261
- if (temp.has(node.file_name))
262
- throw new Error(`Cyclic dependency {${node.file_name}}`);
263
-
264
- if (visited.has(node.file_name))
265
- return;
266
-
267
- temp.add(node.file_name);
268
-
269
- for (const dep of node.deps) {
270
- const dep_node = map.get(dep);
271
- if (!dep_node)
272
- throw new Error(`Missing dependency {${dep}}`);
273
-
274
- visit(dep_node as T);
275
- }
276
-
277
- temp.delete(node.file_name);
278
- visited.add(node.file_name);
279
- result.push(node);
280
- }
281
-
282
- for (const dep of deps)
283
- if (!visited.has(dep.file_name))
284
- visit(dep);
285
-
286
- return result;
287
- }
288
-
289
- type Row_DBSchema = { db_schema_table_name: string, db_schema_version: number };
290
- type SchemaVersionMap = Map<string, number>;
291
-
292
- async function db_load_schema(schema_dir: string, schema_versions: SchemaVersionMap) {
293
- const schema_out = [];
294
- const schema_files = await fs.readdir(schema_dir, { recursive: true, withFileTypes: true });
295
-
296
- for (const schema_file_ent of schema_files) {
297
- if (schema_file_ent.isDirectory())
298
- continue;
299
-
300
- const schema_file = schema_file_ent.name;
301
- const schema_file_lower = schema_file.toLowerCase();
302
- if (!schema_file_lower.endsWith('.sql'))
303
- continue;
304
-
305
- log('[{db}] parsing schema file {%s}', schema_file_lower);
306
-
307
- const schema_name = path.basename(schema_file_lower, '.sql');
308
- const schema_path = path.join(schema_file_ent.parentPath, schema_file);
309
- const schema = await fs.readFile(schema_path, 'utf8');
310
-
311
- const deps = new Array<string>();
312
-
313
- const revisions = new Map();
314
- let current_rev_id = 0;
315
- let current_rev = '';
316
-
317
- for (const line of schema.split(/\r?\n/)) {
318
- const line_identifier = line.match(/^--\s*\[(\d+|deps)\]/);
319
- if (line_identifier !== null) {
320
- if (line_identifier[1] === 'deps') {
321
- // Line contains schema dependencies, example: -- [deps] schema_b.sql,schema_c.sql
322
- const deps_raw = line.substring(line.indexOf(']') + 1);
323
- deps.push(...deps_raw.split(',').map(e => e.trim().toLowerCase()));
324
- } else {
325
- // New chunk definition detected, store the current chunk and start a new one.
326
- if (current_rev_id > 0) {
327
- revisions.set(current_rev_id, current_rev);
328
- current_rev = '';
329
- }
330
-
331
- const rev_number = parseInt(line_identifier[1]);
332
- if (isNaN(rev_number) || rev_number < 1)
333
- throw new Error(rev_number + ' is not a valid revision number in ' + schema_file_lower);
334
- current_rev_id = rev_number;
335
- }
336
- } else {
337
- // Append to existing revision.
338
- current_rev += line + '\n';
339
- }
340
- }
341
-
342
- // There may be something left in current_chunk once we reach end of the file.
343
- if (current_rev_id > 0)
344
- revisions.set(current_rev_id, current_rev);
345
-
346
- if (revisions.size === 0) {
347
- log('[{db}] {%s} contains no valid revisions', schema_file);
348
- continue;
349
- }
350
-
351
- if (deps.length > 0)
352
- log('[{db}] {%s} dependencies: %s', schema_file, deps.map(e => '{' + e +'}').join(', '));
353
-
354
- const current_schema_version = schema_versions.get(schema_name) ?? 0;
355
- schema_out.push({
356
- revisions,
357
- file_name: schema_file_lower,
358
- name: schema_name,
359
- current_version: current_schema_version,
360
- deps,
361
- chunk_keys: Array.from(revisions.keys()).filter(chunk_id => chunk_id > current_schema_version).sort((a, b) => a - b)
362
- });
363
- }
364
-
365
- return order_schema_dep_tree(schema_out);
366
- }
367
-
368
- export async function db_update_schema_sqlite(db: Database, schema_dir: string, schema_table_name = 'db_schema') {
369
- log('[{db}] updating database schema for {%s}', db.filename);
370
-
371
- const schema_versions = new Map();
372
-
373
- try {
374
- const query = db.query('SELECT db_schema_table_name, db_schema_version FROM ' + schema_table_name);
375
- for (const row of query.all() as Array<Row_DBSchema>)
376
- schema_versions.set(row.db_schema_table_name, row.db_schema_version);
377
- } catch (e) {
378
- log('[{db}] creating {%s} table', schema_table_name);
379
- db.run(`CREATE TABLE ${schema_table_name} (db_schema_table_name TEXT PRIMARY KEY, db_schema_version INTEGER)`);
380
- }
563
+ // endregion
564
+
565
+ // region serving
566
+ export const HTTP_STATUS_TEXT: Record<number, string> = {
567
+ // 1xx Informational Response
568
+ 100: 'Continue',
569
+ 101: 'Switching Protocols',
570
+ 102: 'Processing',
571
+ 103: 'Early Hints',
381
572
 
382
- db.transaction(async () => {
383
- const update_schema_query = db.prepare(`
384
- INSERT INTO ${schema_table_name} (db_schema_version, db_schema_table_name) VALUES (?1, ?2)
385
- ON CONFLICT(db_schema_table_name) DO UPDATE SET db_schema_version = EXCLUDED.db_schema_version
386
- `);
387
-
388
- const schemas = await db_load_schema(schema_dir, schema_versions);
389
-
390
- for (const schema of schemas) {
391
- let newest_schema_version = schema.current_version;
392
- for (const rev_id of schema.chunk_keys) {
393
- const revision = schema.revisions.get(rev_id);
394
- log('[{db}] applying revision {%d} to {%s}', rev_id, schema.name);
395
- db.transaction(() => db.run(revision))();
396
- newest_schema_version = rev_id;
397
- }
573
+ // 2xx Success
574
+ 200: 'OK',
575
+ 201: 'Created',
576
+ 202: 'Accepted',
577
+ 203: 'Non-Authoritative Information',
578
+ 204: 'No Content',
579
+ 205: 'Reset Content',
580
+ 206: 'Partial Content',
581
+ 207: 'Multi-Status',
582
+ 208: 'Already Reported',
583
+ 226: 'IM Used',
398
584
 
399
- if (newest_schema_version > schema.current_version) {
400
- log('[{db}] updated table {%s} to revision {%d}', schema.name, newest_schema_version);
401
- update_schema_query.run(newest_schema_version, schema.name);
402
- }
403
- }
404
- })();
405
- }
406
-
407
- export async function db_update_schema_mysql(db: mysql_types.Connection, schema_dir: string, schema_table_name = 'db_schema') {
408
- if (mysql === undefined)
409
- throw new Error('{db_update_schema_mysql} cannot be called without optional dependency {mysql2} installed');
410
-
411
- log('[{db}] updating database schema for {%s}', db.config.database);
412
-
413
- const schema_versions = new Map();
414
-
415
- try {
416
- const [rows] = await db.query('SELECT db_schema_table_name, db_schema_version FROM ' + schema_table_name);
417
- for (const row of rows as Array<Row_DBSchema>)
418
- schema_versions.set(row.db_schema_table_name, row.db_schema_version);
419
- } catch (e) {
420
- log('[{db}] creating {%s} table', schema_table_name);
421
- await db.query(`CREATE TABLE ${schema_table_name} (db_schema_table_name VARCHAR(255) PRIMARY KEY, db_schema_version INT)`);
422
- }
423
-
424
- await db.beginTransaction();
585
+ // 3xx Redirection
586
+ 300: 'Multiple Choices',
587
+ 301: 'Moved Permanently',
588
+ 302: 'Found',
589
+ 303: 'See Other',
590
+ 304: 'Not Modified',
591
+ 305: 'Use Proxy',
592
+ 307: 'Temporary Redirect',
593
+ 308: 'Permanent Redirect',
425
594
 
426
- const update_schema_query = await db.prepare(`
427
- INSERT INTO ${schema_table_name} (db_schema_version, db_schema_table_name) VALUES (?, ?)
428
- ON DUPLICATE KEY UPDATE db_schema_version = VALUES(db_schema_version);
429
- `);
430
-
431
- const schemas = await db_load_schema(schema_dir, schema_versions);
432
- for (const schema of schemas) {
433
- let newest_schema_version = schema.current_version;
434
- for (const rev_id of schema.chunk_keys) {
435
- const revision = schema.revisions.get(rev_id);
436
- log('[{db}] applying revision {%d} to {%s}', rev_id, schema.name);
437
-
438
- await db.query(revision);
439
- newest_schema_version = rev_id;
440
- }
441
-
442
- if (newest_schema_version > schema.current_version) {
443
- log('[{db}] updated table {%s} to revision {%d}', schema.name, newest_schema_version);
444
-
445
- await update_schema_query.execute([newest_schema_version, schema.name]);
446
- }
447
- }
448
-
449
- await db.commit();
450
- }
451
-
452
- export async function db_init_sqlite(db_path: string, schema_dir?: string): Promise<Database> {
453
- const db = new Database(db_path, { create: true });
454
-
455
- if (schema_dir !== undefined)
456
- await db_update_schema_sqlite(db, schema_dir);
457
-
458
- return db;
459
- }
460
-
461
- export async function db_init_mysql<T extends boolean = false>(db_info: mysql_types.ConnectionOptions, schema_dir?: string, pool: T = false as T): Promise<T extends true ? mysql_types.Pool : mysql_types.Connection> {
462
- if (mysql === undefined)
463
- throw new Error('db_init_mysql cannot be called without optional dependency {mysql2} installed');
464
-
465
- // required for parsing multiple statements from schema files
466
- db_info.multipleStatements = true;
467
-
468
- if (pool) {
469
- const pool = mysql.createPool(db_info);
470
- const connection = await pool.getConnection();
471
-
472
- if (schema_dir !== undefined)
473
- await db_update_schema_mysql(connection, schema_dir);
474
-
475
- connection.release();
476
-
477
- return pool as any;
478
- } else {
479
- const connection = await mysql.createConnection(db_info);
480
-
481
- if (schema_dir !== undefined)
482
- await db_update_schema_mysql(connection, schema_dir);
483
-
484
- return connection as any;
485
- }
486
- }
487
-
488
- export type CookieOptions = {
489
- same_site?: 'Strict' | 'Lax' | 'None',
490
- secure?: boolean,
491
- http_only?: boolean,
492
- path?: string,
493
- expires?: number,
494
- encode?: boolean,
495
- max_age?: number
595
+ // 4xx Client Errors
596
+ 400: 'Bad Request',
597
+ 401: 'Unauthorized',
598
+ 403: 'Forbidden',
599
+ 404: 'Not Found',
600
+ 405: 'Method Not Allowed',
601
+ 406: 'Not Acceptable',
602
+ 407: 'Proxy Authentication Required',
603
+ 408: 'Request Timeout',
604
+ 409: 'Conflict',
605
+ 410: 'Gone',
606
+ 411: 'Length Required',
607
+ 412: 'Precondition Failed',
608
+ 413: 'Payload Too Large',
609
+ 414: 'URI Too Long',
610
+ 415: 'Unsupported Media Type',
611
+ 416: 'Range Not Satisfiable',
612
+ 417: 'Expectation Failed',
613
+ 418: 'I\'m a Teapot',
614
+ 421: 'Misdirected Request',
615
+ 422: 'Unprocessable Content',
616
+ 423: 'Locked',
617
+ 424: 'Failed Dependency',
618
+ 425: 'Too Early',
619
+ 426: 'Upgrade Required',
620
+ 428: 'Precondition Required',
621
+ 429: 'Too Many Requests',
622
+ 431: 'Request Header Fields Too Large',
623
+ 451: 'Unavailable For Legal Reasons',
624
+
625
+ // 5xx Server Errors
626
+ 500: 'Internal Server Error',
627
+ 501: 'Not Implemented',
628
+ 502: 'Bad Gateway',
629
+ 503: 'Service Unavailable',
630
+ 504: 'Gateway Timeout',
631
+ 505: 'HTTP Version Not Supported',
632
+ 506: 'Variant Also Negotiates',
633
+ 507: 'Insufficient Storage',
634
+ 508: 'Loop Detected',
635
+ 510: 'Not Extended',
636
+ 511: 'Network Authentication Required'
496
637
  };
497
638
 
498
- export function set_cookie(res: Response, name: string, value: string, options?: CookieOptions): void {
499
- let cookie = name + '=';
500
- if (options !== undefined) {
501
- cookie += options.encode ? encodeURIComponent(value) : value;
502
-
503
- if (options.same_site !== undefined)
504
- cookie += '; SameSite=' + options.same_site;
505
-
506
- if (options.secure)
507
- cookie += '; Secure';
508
-
509
- if (options.http_only)
510
- cookie += '; HttpOnly';
511
-
512
- if (options.path !== undefined)
513
- cookie += '; Path=' + options.path;
514
-
515
- if (options.expires !== undefined) {
516
- const date = new Date(Date.now() + options.expires);
517
- cookie += '; Expires=' + date.toUTCString();
518
- }
519
-
520
- if (options.max_age !== undefined)
521
- cookie += '; Max-Age=' + options.max_age;
522
- } else {
523
- cookie += value;
524
- }
525
-
526
- res.headers.append('Set-Cookie', cookie);
527
- }
528
-
529
- export function get_cookies(source: Request | Response, decode: boolean = false): Record<string, string> {
530
- const parsed_cookies: Record<string, string> = {};
531
- const cookie_header = source.headers.get('cookie');
532
-
533
- if (cookie_header !== null) {
534
- const cookies = cookie_header.split('; ');
535
- for (const cookie of cookies) {
536
- const [name, value] = cookie.split('=');
537
- parsed_cookies[name] = decode ? decodeURIComponent(value) : value;
538
- }
539
- }
639
+ export const HTTP_STATUS_CODE = {
640
+ // 1xx Informational Response
641
+ Continue_100: 100,
642
+ SwitchingProtocols_101: 101,
643
+ Processing_102: 102,
644
+ EarlyHints_103: 103,
645
+
646
+ // 2xx Success
647
+ OK_200: 200,
648
+ Created_201: 201,
649
+ Accepted_202: 202,
650
+ NonAuthoritativeInformation_203: 203,
651
+ NoContent_204: 204,
652
+ ResetContent_205: 205,
653
+ PartialContent_206: 206,
654
+ MultiStatus_207: 207,
655
+ AlreadyReported_208: 208,
656
+ IMUsed_226: 226,
657
+
658
+ // 3xx Redirection
659
+ MultipleChoices_300: 300,
660
+ MovedPermanently_301: 301,
661
+ Found_302: 302,
662
+ SeeOther_303: 303,
663
+ NotModified_304: 304,
664
+ UseProxy_305: 305,
665
+ TemporaryRedirect_307: 307,
666
+ PermanentRedirect_308: 308,
667
+
668
+ // 4xx Client Errors
669
+ BadRequest_400: 400,
670
+ Unauthorized_401: 401,
671
+ Forbidden_403: 403,
672
+ NotFound_404: 404,
673
+ MethodNotAllowed_405: 405,
674
+ NotAcceptable_406: 406,
675
+ ProxyAuthenticationRequired_407: 407,
676
+ RequestTimeout_408: 408,
677
+ Conflict_409: 409,
678
+ Gone_410: 410,
679
+ LengthRequired_411: 411,
680
+ PreconditionFailed_412: 412,
681
+ PayloadTooLarge_413: 413,
682
+ URITooLong_414: 414,
683
+ UnsupportedMediaType_415: 415,
684
+ RangeNotSatisfiable_416: 416,
685
+ ExpectationFailed_417: 417,
686
+ ImATeapot_418: 418,
687
+ MisdirectedRequest_421: 421,
688
+ UnprocessableContent_422: 422,
689
+ Locked_423: 423,
690
+ FailedDependency_424: 424,
691
+ TooEarly_425: 425,
692
+ UpgradeRequired_426: 426,
693
+ PreconditionRequired_428: 428,
694
+ TooManyRequests_429: 429,
695
+ RequestHeaderFieldsTooLarge_431: 431,
696
+ UnavailableForLegalReasons_451: 451,
697
+
698
+ // 5xx Server Errors
699
+ InternalServerError_500: 500,
700
+ NotImplemented_501: 501,
701
+ BadGateway_502: 502,
702
+ ServiceUnavailable_503: 503,
703
+ GatewayTimeout_504: 504,
704
+ HTTPVersionNotSupported_505: 505,
705
+ VariantAlsoNegotiates_506: 506,
706
+ InsufficientStorage_507: 507,
707
+ LoopDetected_508: 508,
708
+ NotExtended_510: 510,
709
+ NetworkAuthenticationRequired_511: 511
710
+ } as const;
540
711
 
541
- return parsed_cookies;
542
- }
712
+ // Create enum containing HTTP methods
713
+ type HTTP_METHOD = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'CONNECT' | 'TRACE';
714
+ type HTTP_METHODS = HTTP_METHOD|HTTP_METHOD[];
543
715
 
544
- export function apply_range(file: BunFile, request: Request): BunFile {
716
+ export function http_apply_range(file: BunFile, request: Request): BunFile {
545
717
  const range_header = request.headers.get('range');
546
718
  if (range_header !== null) {
547
719
  const regex = /bytes=(\d*)-(\d*)/;
548
720
  const match = range_header.match(regex);
549
-
721
+
550
722
  if (match !== null) {
551
723
  const start = parseInt(match[1]);
552
724
  const end = parseInt(match[2]);
553
-
725
+
554
726
  const start_is_nan = isNaN(start);
555
727
  const end_is_nan = isNaN(end);
556
-
728
+
557
729
  if (start_is_nan && end_is_nan)
558
730
  return file;
559
-
731
+
560
732
  file = file.slice(start_is_nan ? file.size - end : start, end_is_nan || start_is_nan ? undefined : end);
561
733
  }
562
734
  }
@@ -606,124 +778,267 @@ type DirStat = PromiseType<ReturnType<typeof fs.stat>>;
606
778
 
607
779
  type DirHandler = (file_path: string, file: BunFile, stat: DirStat, request: Request, url: URL) => HandlerReturnType;
608
780
 
609
- function default_directory_handler(file_path: string, file: BunFile, stat: DirStat, request: Request): HandlerReturnType {
610
- // ignore hidden files by default, return 404 to prevent file sniffing
611
- if (path.basename(file_path).startsWith('.'))
612
- return 404; // Not Found
781
+ interface DirOptions {
782
+ ignore_hidden?: boolean;
783
+ index_directories?: boolean;
784
+ support_ranges?: boolean;
785
+ }
786
+
787
+ let directory_index_template: string | null = null;
788
+
789
+ async function get_directory_index_template(): Promise<string> {
790
+ if (directory_index_template === null) {
791
+ const template_path = path.join(import.meta.dir, 'template', 'directory_index.html');
792
+ const template_file = Bun.file(template_path);
793
+ directory_index_template = await template_file.text();
794
+ }
795
+ return directory_index_template;
796
+ }
797
+
798
+ function format_date(date: Date): string {
799
+ const options: Intl.DateTimeFormatOptions = {
800
+ year: 'numeric',
801
+ month: 'short',
802
+ day: '2-digit',
803
+ hour: '2-digit',
804
+ minute: '2-digit',
805
+ hour12: true
806
+ };
807
+ return date.toLocaleDateString('en-US', options);
808
+ }
613
809
 
614
- if (stat.isDirectory())
615
- return 401; // Unauthorized
810
+ function format_date_mobile(date: Date): string {
811
+ const options: Intl.DateTimeFormatOptions = {
812
+ year: 'numeric',
813
+ month: 'short',
814
+ day: '2-digit'
815
+ };
816
+ return date.toLocaleDateString('en-US', options);
817
+ }
616
818
 
617
- return apply_range(file, request);
819
+ async function generate_directory_index(file_path: string, request_path: string): Promise<Response> {
820
+ try {
821
+ const entries = await fs.readdir(file_path, { withFileTypes: true });
822
+ let filtered_entries = entries.filter(entry => !entry.name.startsWith('.'));
823
+ filtered_entries.sort((a, b) => {
824
+ if (a.isDirectory() === b.isDirectory())
825
+ return a.name.localeCompare(b.name);
826
+
827
+ return a.isDirectory() ? -1 : 1;
828
+ });
829
+
830
+ const base_url = request_path.endsWith('/') ? request_path.slice(0, -1) : request_path;
831
+ const entry_data = await Promise.all(filtered_entries.map(async entry => {
832
+ const entry_path = path.join(file_path, entry.name);
833
+ const stat = await fs.stat(entry_path);
834
+ return {
835
+ name: entry.name,
836
+ type: entry.isDirectory() ? 'directory' : 'file',
837
+ size: entry.isDirectory() ? '-' : filesize(stat.size),
838
+ modified: format_date(stat.mtime),
839
+ modified_mobile: format_date_mobile(stat.mtime),
840
+ raw_size: entry.isDirectory() ? 0 : stat.size
841
+ };
842
+ }));
843
+
844
+ const template = await get_directory_index_template();
845
+ const html = await parse_template(template, {
846
+ title: path.basename(file_path) || 'Root',
847
+ path: request_path,
848
+ base_url: base_url,
849
+ entries: entry_data,
850
+ version: packageJson.version
851
+ }, true);
852
+
853
+ return new Response(html, {
854
+ status: 200,
855
+ headers: { 'Content-Type': 'text/html' }
856
+ });
857
+ } catch (err) {
858
+ return new Response('Error reading directory', {
859
+ status: 500,
860
+ headers: { 'Content-Type': 'text/plain' }
861
+ });
862
+ }
618
863
  }
619
864
 
620
- function route_directory(route_path: string, dir: string, handler: DirHandler): RequestHandler {
865
+
866
+ function route_directory(route_path: string, dir: string, handler_or_options: DirHandler | DirOptions): RequestHandler {
867
+ const is_handler = typeof handler_or_options === 'function';
868
+ const handler = is_handler ? handler_or_options as DirHandler : null;
869
+ const default_options = { ignore_hidden: true, index_directories: false, support_ranges: true };
870
+ const options = is_handler ? default_options : { ...default_options, ...handler_or_options as DirOptions };
871
+
621
872
  return async (req: Request, url: URL) => {
622
873
  const file_path = path.join(dir, url.pathname.slice(route_path.length));
623
-
874
+
624
875
  try {
625
876
  const file_stat = await fs.stat(file_path);
626
877
  const bun_file = Bun.file(file_path);
627
-
628
- return await handler(file_path, bun_file, file_stat, req, url);
878
+
879
+ if (handler)
880
+ return await handler(file_path, bun_file, file_stat, req, url);
881
+
882
+ // Options-based handling
883
+ if (options.ignore_hidden && path.basename(file_path).startsWith('.'))
884
+ return 404; // Not Found
885
+
886
+ if (file_stat.isDirectory()) {
887
+ if (options.index_directories)
888
+ return await generate_directory_index(file_path, url.pathname);
889
+
890
+ return 401; // Unauthorized
891
+ }
892
+
893
+ return options.support_ranges ? http_apply_range(bun_file, req) : bun_file;
629
894
  } catch (e) {
630
895
  const err = e as NodeJS.ErrnoException;
631
896
  if (err?.code === 'ENOENT')
632
897
  return 404; // Not Found
633
-
898
+
634
899
  return 500; // Internal Server Error
635
900
  }
636
901
  };
637
902
  }
638
903
 
639
- export function validate_req_json(json_handler: JSONRequestHandler): RequestHandler {
640
- return async (req: Request, url: URL) => {
641
- try {
642
- // validate content type header
643
- if (req.headers.get('Content-Type') !== 'application/json')
644
- return 400; // Bad Request
645
-
646
- const json = await req.json();
647
-
648
- // validate json is a plain object
649
- if (json === null || typeof json !== 'object' || Array.isArray(json))
650
- return 400; // Bad Request
651
-
652
- return json_handler(req, url, json as JsonObject);
653
- } catch (e) {
654
- return 400; // Bad Request
655
- }
656
- };
657
- }
658
-
659
904
  function format_query_parameters(search_params: URLSearchParams): string {
660
905
  let result_parts = [];
661
-
906
+
662
907
  for (let [key, value] of search_params)
663
908
  result_parts.push(`${key}: ${value}`);
664
-
909
+
665
910
  return '\x1b[90m( ' + result_parts.join(', ') + ' )\x1b[0m';
666
911
  }
667
912
 
668
913
  function print_request_info(req: Request, res: Response, url: URL, request_time: number): Response {
669
914
  const search_params = url.search.length > 0 ? format_query_parameters(url.searchParams) : '';
670
-
915
+
671
916
  // format status code based on range (2xx is green, 4xx is yellow, 5xx is red), use ansi colors.
672
917
  const status_fmt = res.status < 300 ? '\x1b[32m' : res.status < 500 ? '\x1b[33m' : '\x1b[31m';
673
918
  const status_code = status_fmt + res.status + '\x1b[0m';
674
-
919
+
675
920
  // format request time based on range (0-100ms is green, 100-500ms is yellow, 500ms+ is red), use ansi colors.
676
921
  const time_fmt = request_time < 100 ? '\x1b[32m' : request_time < 500 ? '\x1b[33m' : '\x1b[31m';
677
922
  const request_time_str = time_fmt + request_time + 'ms\x1b[0m';
678
-
679
- log('[%s] {%s} %s %s [{%s}]', status_code, req.method, url.pathname, search_params, request_time_str);
923
+
924
+ log_spooder(`[${status_code}] {${req.method}} ${url.pathname} ${search_params} [{${request_time_str}}]`);
680
925
  return res;
681
926
  }
682
927
 
683
928
  function is_valid_method(method: HTTP_METHODS, req: Request): boolean {
684
929
  if (Array.isArray(method))
685
930
  return method.includes(req.method as HTTP_METHOD);
686
-
931
+
687
932
  return req.method === method;
688
933
  }
689
934
 
690
- export function serve(port: number, hostname?: string) {
935
+ function is_bun_file(obj: any): obj is BunFile {
936
+ return obj.constructor === Blob;
937
+ }
938
+
939
+ function sub_table_merge(target: Record<string, any>, ...sources: (Record<string, any> | undefined | null)[]): Record<string, any> {
940
+ const result = { ...target };
941
+
942
+ for (const source of sources) {
943
+ if (source == null)
944
+ continue;
945
+
946
+ for (const key in source) {
947
+ if (source.hasOwnProperty(key)) {
948
+ const sourceValue = source[key];
949
+ const targetValue = result[key];
950
+
951
+ if (Array.isArray(targetValue) && Array.isArray(sourceValue))
952
+ result[key] = [...targetValue, ...sourceValue];
953
+ else
954
+ result[key] = sourceValue;
955
+ }
956
+ }
957
+ }
958
+
959
+ return result;
960
+ }
961
+
962
+ async function resolve_bootstrap_content(content: string | BunFile): Promise<string> {
963
+ if (is_bun_file(content))
964
+ return await content.text();
965
+
966
+ return content;
967
+ }
968
+
969
+ type WebsocketAcceptReturn = object | boolean;
970
+ type WebsocketHandlers = {
971
+ accept?: (req: Request) => WebsocketAcceptReturn | Promise<WebsocketAcceptReturn>,
972
+ message?: (ws: WebSocket, message: string) => void,
973
+ message_json?: (ws: WebSocket, message: JsonSerializable) => void,
974
+ open?: (ws: WebSocket) => void,
975
+ close?: (ws: WebSocket, code: number, reason: string) => void,
976
+ drain?: (ws: WebSocket) => void
977
+ };
978
+
979
+ type BootstrapSub = string | string[];
980
+
981
+ type BootstrapRoute = {
982
+ content: string | BunFile;
983
+ subs?: Record<string, BootstrapSub>;
984
+ };
985
+
986
+ type BootstrapOptions = {
987
+ base?: string | BunFile;
988
+ routes: Record<string, BootstrapRoute>;
989
+ cache?: ReturnType<typeof cache_http> | CacheOptions;
990
+ cache_bust?: boolean;
991
+ error?: {
992
+ use_canary_reporting?: boolean;
993
+ error_page: string | BunFile;
994
+ },
995
+
996
+ static?: {
997
+ route: string;
998
+ directory: string;
999
+ sub_ext?: Array<string>;
1000
+ },
1001
+
1002
+ global_subs?: Record<string, BootstrapSub>;
1003
+ };
1004
+
1005
+ export function http_serve(port: number, hostname?: string) {
691
1006
  const routes = new Array<[string[], RequestHandler, HTTP_METHODS]>();
692
1007
  const handlers = new Map<number, StatusCodeHandler>();
693
1008
 
694
1009
  let error_handler: ErrorHandler | undefined;
695
1010
  let default_handler: DefaultHandler | undefined;
696
-
1011
+
697
1012
  async function resolve_handler(response: HandlerReturnType | Promise<HandlerReturnType>, status_code: number, return_status_code = false): Promise<Response | number> {
698
1013
  if (response instanceof Promise)
699
1014
  response = await response;
700
-
1015
+
701
1016
  if (response === undefined || response === null)
702
1017
  throw new Error('HandlerReturnType cannot resolve to undefined or null');
703
-
1018
+
704
1019
  // Pre-assembled responses are returned as-is.
705
1020
  if (response instanceof Response)
706
1021
  return response;
707
-
1022
+
708
1023
  // Content-type/content-length are automatically set for blobs.
709
1024
  if (response instanceof Blob)
710
1025
  // @ts-ignore Response does accept Blob in Bun, typing disagrees.
711
- return new Response(response, { status: status_code });
712
-
1026
+ return new Response(response, { status: status_code });
1027
+
713
1028
  // Status codes can be returned from some handlers.
714
1029
  if (return_status_code && typeof response === 'number')
715
1030
  return response;
716
-
1031
+
717
1032
  // This should cover objects, arrays, etc.
718
1033
  if (typeof response === 'object')
719
1034
  return Response.json(response, { status: status_code });
720
-
1035
+
721
1036
  return new Response(String(response), { status: status_code, headers: { 'Content-Type': 'text/html' } });
722
1037
  }
723
-
1038
+
724
1039
  async function generate_response(req: Request, url: URL): Promise<Response> {
725
1040
  let status_code = 200;
726
-
1041
+
727
1042
  try {
728
1043
  let pathname = url.pathname;
729
1044
  if (pathname.length > 1 && pathname.endsWith('/'))
@@ -731,44 +1046,44 @@ export function serve(port: number, hostname?: string) {
731
1046
  const route_array = pathname.split('/').filter(e => !(e === '..' || e === '.'));
732
1047
  let handler: RequestHandler | undefined;
733
1048
  let methods: HTTP_METHODS | undefined;
734
-
1049
+
735
1050
  for (const [path, route_handler, route_methods] of routes) {
736
1051
  const is_trailing_wildcard = path[path.length - 1] === '*';
737
1052
  if (!is_trailing_wildcard && path.length !== route_array.length)
738
1053
  continue;
739
-
1054
+
740
1055
  let match = true;
741
1056
  for (let i = 0; i < path.length; i++) {
742
1057
  const path_part = path[i];
743
-
1058
+
744
1059
  if (path_part === '*')
745
1060
  continue;
746
-
1061
+
747
1062
  if (path_part.startsWith(':')) {
748
1063
  url.searchParams.append(path_part.slice(1), route_array[i]);
749
1064
  continue;
750
1065
  }
751
-
1066
+
752
1067
  if (path_part !== route_array[i]) {
753
1068
  match = false;
754
1069
  break;
755
1070
  }
756
1071
  }
757
-
1072
+
758
1073
  if (match) {
759
1074
  handler = route_handler;
760
1075
  methods = route_methods;
761
1076
  break;
762
1077
  }
763
1078
  }
764
-
1079
+
765
1080
  // Check for a handler for the route.
766
1081
  if (handler !== undefined) {
767
1082
  if (is_valid_method(methods!, req)) {
768
1083
  const response = await resolve_handler(handler(req, url), status_code, true);
769
1084
  if (response instanceof Response)
770
1085
  return response;
771
-
1086
+
772
1087
  // If the handler returned a status code, use that instead.
773
1088
  status_code = response;
774
1089
  } else {
@@ -777,7 +1092,7 @@ export function serve(port: number, hostname?: string) {
777
1092
  } else {
778
1093
  status_code = 404; // Not Found
779
1094
  }
780
-
1095
+
781
1096
  // Fallback to checking for a handler for the status code.
782
1097
  const status_code_handler = handlers.get(status_code);
783
1098
  if (status_code_handler !== undefined) {
@@ -785,70 +1100,70 @@ export function serve(port: number, hostname?: string) {
785
1100
  if (response instanceof Response)
786
1101
  return response;
787
1102
  }
788
-
1103
+
789
1104
  // Fallback to the default handler, if any.
790
1105
  if (default_handler !== undefined) {
791
1106
  const response = await resolve_handler(default_handler(req, status_code), status_code);
792
1107
  if (response instanceof Response)
793
1108
  return response;
794
1109
  }
795
-
1110
+
796
1111
  // Fallback to returning a basic response.
797
1112
  return new Response(http.STATUS_CODES[status_code], { status: status_code });
798
1113
  } catch (e) {
799
1114
  if (error_handler !== undefined)
800
1115
  return await error_handler(e as Error, req, url);
801
-
802
- return new Response(http.STATUS_CODES[500], { status: 500 });
1116
+
1117
+ return new Response(HTTP_STATUS_TEXT[500], { status: 500 });
803
1118
  }
804
1119
  }
805
-
1120
+
806
1121
  type SlowRequestCallback = (req: Request, request_time: number, url: URL) => void;
807
-
1122
+
808
1123
  let slow_request_callback: SlowRequestCallback | null = null;
809
1124
  let slow_request_threshold: number = 1000;
810
-
1125
+
811
1126
  const slow_requests = new WeakSet();
812
-
1127
+
813
1128
  let ws_message_handler: any = undefined;
814
1129
  let ws_message_json_handler: any = undefined;
815
1130
  let ws_open_handler: any = undefined;
816
1131
  let ws_close_handler: any = undefined;
817
1132
  let ws_drain_handler: any = undefined;
818
-
1133
+
819
1134
  const server = Bun.serve({
820
1135
  port,
821
1136
  hostname,
822
1137
  development: false,
823
-
1138
+
824
1139
  async fetch(req: Request): Promise<Response> {
825
1140
  const url = new URL(req.url) as URL;
826
1141
  const request_start = Date.now();
827
-
1142
+
828
1143
  const response = await generate_response(req, url);
829
1144
  const request_time = Date.now() - request_start;
830
-
1145
+
831
1146
  const is_known_slow = slow_requests.has(req);
832
1147
  if (slow_request_callback !== null && request_time > slow_request_threshold && !is_known_slow)
833
1148
  slow_request_callback(req, request_time, url);
834
-
1149
+
835
1150
  if (is_known_slow)
836
1151
  slow_requests.delete(req);
837
-
1152
+
838
1153
  return print_request_info(req, response, url, request_time);
839
1154
  },
840
-
1155
+
841
1156
  websocket: {
842
1157
  message(ws, message) {
843
1158
  ws_message_handler?.(ws, message);
844
-
1159
+
845
1160
  if (ws_message_json_handler) {
846
1161
  try {
847
1162
  if (message instanceof ArrayBuffer)
848
1163
  message = new TextDecoder().decode(message);
849
1164
  else if (message instanceof Buffer)
850
1165
  message = message.toString('utf8');
851
-
1166
+
852
1167
  const parsed = JSON.parse(message as string);
853
1168
  ws_message_json_handler(ws, parsed);
854
1169
  } catch (e) {
@@ -856,23 +1171,23 @@ export function serve(port: number, hostname?: string) {
856
1171
  }
857
1172
  }
858
1173
  },
859
-
1174
+
860
1175
  open(ws) {
861
1176
  ws_open_handler?.(ws);
862
1177
  },
863
-
1178
+
864
1179
  close(ws, code, reason) {
865
1180
  ws_close_handler?.(ws, code, reason);
866
1181
  },
867
-
1182
+
868
1183
  drain(ws) {
869
1184
  ws_drain_handler?.(ws);
870
1185
  }
871
1186
  }
872
1187
  });
873
-
874
- log('server started on port {%d} (host: {%s})', port, hostname ?? 'unspecified');
875
-
1188
+
1189
+ log_spooder(`server started on port {${port}} (host: {${hostname ?? 'unspecified'}})`);
1190
+
876
1191
  return {
877
1192
  /** Register a handler for a specific route. */
878
1193
  route: (path: string, handler: RequestHandler, method: HTTP_METHODS = 'GET'): void => {
@@ -880,34 +1195,58 @@ export function serve(port: number, hostname?: string) {
880
1195
  path = path.slice(0, -1);
881
1196
  routes.push([path.split('/'), handler, method]);
882
1197
  },
883
-
1198
+
1199
+ /** Register a JSON endpoint with automatic content validation. */
1200
+ json: (path: string, handler: JSONRequestHandler, method: HTTP_METHODS = 'POST'): void => {
1201
+ const json_wrapper: RequestHandler = async (req: Request, url: URL) => {
1202
+ try {
1203
+ if (req.headers.get('Content-Type') !== 'application/json')
1204
+ return 400; // Bad Request
1205
+
1206
+ const json = await req.json();
1207
+ if (json === null || typeof json !== 'object' || Array.isArray(json))
1208
+ return 400; // Bad Request
1209
+
1210
+ return handler(req, url, json as JsonObject);
1211
+ } catch (e) {
1212
+ return 400; // Bad Request
1213
+ }
1214
+ };
1215
+
1216
+ if (path.length > 1 && path.endsWith('/'))
1217
+ path = path.slice(0, -1);
1218
+
1219
+ routes.push([path.split('/'), json_wrapper, method]);
1220
+ },
1221
+
884
1222
  /** Unregister a specific route */
885
1223
  unroute: (path: string): void => {
886
1224
  const path_parts = path.split('/');
887
1225
  routes.splice(routes.findIndex(([route_parts]) => {
888
1226
  if (route_parts.length !== path_parts.length)
889
1227
  return false;
890
-
1228
+
891
1229
  for (let i = 0; i < route_parts.length; i++) {
892
1230
  if (route_parts[i] !== path_parts[i])
893
1231
  return false;
894
1232
  }
895
-
1233
+
896
1234
  return true;
897
1235
  }, 1));
898
1236
  },
899
-
1237
+
900
1238
  /** Serve a directory for a specific route. */
901
- dir: (path: string, dir: string, handler?: DirHandler, method: HTTP_METHODS = 'GET'): void => {
1239
+ dir: (path: string, dir: string, handler_or_options?: DirHandler | DirOptions, method: HTTP_METHODS = 'GET'): void => {
902
1240
  if (path.endsWith('/'))
903
1241
  path = path.slice(0, -1);
904
-
905
- routes.push([[...path.split('/'), '*'], route_directory(path, dir, handler ?? default_directory_handler), method]);
1242
+
1243
+ const final_handler_or_options = handler_or_options ?? { ignore_hidden: true, index_directories: false, support_ranges: true };
1244
+ routes.push([[...path.split('/'), '*'], route_directory(path, dir, final_handler_or_options), method]);
906
1245
  },
907
-
1246
+
908
1247
  /** Add a route to upgrade connections to websockets. */
909
1248
  websocket: (path: string, handlers: WebsocketHandlers): void => {
910
- routes.push([path.split('/'), async (req: Request, url: URL) => {
1249
+ routes.push([path.split('/'), async (req: Request) => {
911
1250
  let context_data = undefined;
912
1251
  if (handlers.accept) {
913
1252
  const res = await handlers.accept(req);
@@ -918,94 +1257,108 @@ export function serve(port: number, hostname?: string) {
918
1257
  return 401; // Unauthorized
919
1258
  }
920
1259
  }
921
-
1260
+
922
1261
  if (server.upgrade(req, { data: context_data }))
923
1262
  return 101; // Switching Protocols
924
-
1263
+
925
1264
  return new Response('WebSocket upgrade failed', { status: 500 });
926
1265
  }, 'GET']);
927
-
1266
+
928
1267
  ws_message_json_handler = handlers.message_json;
929
1268
  ws_open_handler = handlers.open;
930
1269
  ws_close_handler = handlers.close;
931
1270
  ws_message_handler = handlers.message;
932
1271
  ws_drain_handler = handlers.drain;
933
1272
  },
934
-
935
- webhook: (secret: string, path: string, handler: WebhookHandler): void => {
936
- routes.push([path.split('/'), async (req: Request, url: URL) => {
1273
+
1274
+ webhook: (secret: string, path: string, handler: WebhookHandler, branches?: string | string[]): void => {
1275
+ routes.push([path.split('/'), async (req: Request) => {
937
1276
  if (req.headers.get('Content-Type') !== 'application/json')
938
1277
  return 400; // Bad Request
939
-
1278
+
940
1279
  const signature = req.headers.get('X-Hub-Signature-256');
941
1280
  if (signature === null)
942
1281
  return 401; // Unauthorized
943
-
1282
+
944
1283
  const body = await req.json() as JsonSerializable;
945
1284
  const hmac = crypto.createHmac('sha256', secret);
946
1285
  hmac.update(JSON.stringify(body));
947
-
948
- if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from('sha256=' + hmac.digest('hex'))))
1286
+
1287
+ const sig_buffer = new Uint8Array(Buffer.from(signature));
1288
+ const hmac_buffer = new Uint8Array(Buffer.from('sha256=' + hmac.digest('hex')));
1289
+
1290
+ if (!crypto.timingSafeEqual(sig_buffer, hmac_buffer))
949
1291
  return 401; // Unauthorized
950
-
1292
+
1293
+ // Branch filtering logic
1294
+ if (branches !== undefined) {
1295
+ const branch_list = Array.isArray(branches) ? branches : [branches];
1296
+ const payload = body as any;
1297
+
1298
+ if (payload.ref && typeof payload.ref === 'string') {
1299
+ const branch_name = payload.ref.split('/').pop() || payload.ref;
1300
+
1301
+ if (!branch_list.includes(branch_name))
1302
+ return 200; // OK
1303
+ }
1304
+ }
1305
+
951
1306
  return handler(body);
952
1307
  }, 'POST']);
953
1308
  },
954
-
1309
+
955
1310
  /** Register a callback for slow requests. */
956
1311
  on_slow_request: (callback: SlowRequestCallback, threshold = 1000): void => {
957
1312
  slow_request_callback = callback;
958
1313
  slow_request_threshold = threshold;
959
1314
  },
960
-
1315
+
961
1316
  /** Mark a request as slow, preventing it from triggering slow request callback. */
962
1317
  allow_slow_request: (req: Request): void => {
963
1318
  slow_requests.add(req);
964
1319
  },
965
-
1320
+
966
1321
  /** Register a default handler for all status codes. */
967
1322
  default: (handler: DefaultHandler): void => {
968
1323
  default_handler = handler;
969
1324
  },
970
-
1325
+
971
1326
  /** Register a handler for a specific status code. */
972
1327
  handle: (status_code: number, handler: StatusCodeHandler): void => {
973
1328
  handlers.set(status_code, handler);
974
1329
  },
975
-
1330
+
976
1331
  /** Register a handler for uncaught errors. */
977
1332
  error: (handler: ErrorHandler): void => {
978
1333
  error_handler = handler;
979
1334
  },
980
-
1335
+
981
1336
  /** Stops the server. */
982
1337
  stop: async (immediate = false): Promise<void> => {
983
1338
  server.stop(immediate);
984
-
1339
+
985
1340
  while (server.pendingRequests > 0)
986
1341
  await Bun.sleep(1000);
987
1342
  },
988
-
1343
+
989
1344
  /** Register a handler for server-sent events. */
990
1345
  sse: (path: string, handler: ServerSentEventHandler) => {
991
1346
  routes.push([path.split('/'), (req: Request, url: URL) => {
992
1347
  let stream_controller: ReadableStreamDirectController;
993
1348
  let close_resolver: () => void;
994
-
1349
+
995
1350
  function close_controller() {
996
1351
  stream_controller?.close();
997
1352
  close_resolver?.();
998
1353
  }
999
-
1354
+
1000
1355
  let lastEventTime = Date.now();
1001
1356
  const KEEP_ALIVE_INTERVAL = 15000;
1002
-
1357
+
1003
1358
  const stream = new ReadableStream({
1004
- // @ts-ignore Bun implements a "direct" mode which does not exist in the spec.
1005
1359
  type: 'direct',
1006
-
1360
+
1007
1361
  async pull(controller) {
1008
- // @ts-ignore `controller` in "direct" mode is ReadableStreamDirectController.
1009
1362
  stream_controller = controller as ReadableStreamDirectController;
1010
1363
 
1011
1364
  while (!req.signal.aborted) {
@@ -1020,23 +1373,23 @@ export function serve(port: number, hostname?: string) {
1020
1373
  }
1021
1374
  }
1022
1375
  });
1023
-
1376
+
1024
1377
  const closed = new Promise<void>(resolve => close_resolver = resolve);
1025
1378
  req.signal.onabort = close_controller;
1026
-
1379
+
1027
1380
  handler(req, url, {
1028
1381
  message: (message: string) => {
1029
1382
  stream_controller.write('data: ' + message + '\n\n');
1030
1383
  stream_controller.flush();
1031
1384
  lastEventTime = Date.now();
1032
1385
  },
1033
-
1386
+
1034
1387
  event: (event_name: string, message: string) => {
1035
1388
  stream_controller.write('event: ' + event_name + '\ndata: ' + message + '\n\n');
1036
1389
  stream_controller.flush();
1037
1390
  lastEventTime = Date.now();
1038
1391
  },
1039
-
1392
+
1040
1393
  close: close_controller,
1041
1394
  closed
1042
1395
  });
@@ -1050,6 +1403,98 @@ export function serve(port: number, hostname?: string) {
1050
1403
  }
1051
1404
  });
1052
1405
  }, 'GET']);
1406
+ },
1407
+
1408
+ /* Bootstrap a static web server */
1409
+ bootstrap: async function(options: BootstrapOptions) {
1410
+ const hash_sub_table = options.cache_bust ? await generate_hash_subs() : {};
1411
+ const global_sub_table = sub_table_merge(hash_sub_table, options.global_subs);
1412
+
1413
+ let cache = options.cache;
1414
+ if (cache !== undefined && !is_cache_http(cache))
1415
+ cache = cache_http(cache);
1416
+
1417
+ for (const [route, route_opts] of Object.entries(options.routes)) {
1418
+ const content_generator = async () => {
1419
+ let content = await resolve_bootstrap_content(route_opts.content);
1420
+
1421
+ if (options.base !== undefined)
1422
+ content = await parse_template(await resolve_bootstrap_content(options.base), { content }, false);
1423
+
1424
+ const sub_table = sub_table_merge({}, global_sub_table, route_opts.subs);
1425
+ content = await parse_template(content, sub_table, true);
1426
+
1427
+ return content;
1428
+ };
1429
+
1430
+ const handler = cache
1431
+ ? async (req: Request) => cache.request(req, route, content_generator)
1432
+ : async () => content_generator();
1433
+
1434
+ this.route(route, handler);
1435
+ }
1436
+
1437
+ const error_options = options.error;
1438
+ if (error_options !== undefined) {
1439
+ const create_error_content_generator = (status_code: number) => async () => {
1440
+ const error_text = HTTP_STATUS_TEXT[status_code] as string;
1441
+ let content = await resolve_bootstrap_content(error_options.error_page);
1442
+
1443
+ if (options.base !== undefined)
1444
+ content = await parse_template(await resolve_bootstrap_content(options.base), { content }, false);
1445
+
1446
+ const sub_table = sub_table_merge({
1447
+ error_code: status_code.toString(),
1448
+ error_text: error_text
1449
+ }, global_sub_table);
1450
+
1451
+ content = await parse_template(content, sub_table, true);
1452
+ return content;
1453
+ };
1454
+
1455
+ const default_handler = async (req: Request, status_code: number): Promise<Response> => {
1456
+ if (cache) {
1457
+ return cache.request(req, `error_${status_code}`, create_error_content_generator(status_code), status_code);
1458
+ } else {
1459
+ const content = await create_error_content_generator(status_code)();
1460
+ return new Response(content, { status: status_code });
1461
+ }
1462
+ };
1463
+
1464
+ this.error((err, req) => {
1465
+ if (options.error?.use_canary_reporting)
1466
+ caution(err?.message ?? err);
1467
+
1468
+ return default_handler(req, 500);
1469
+ });
1470
+
1471
+ this.default((req, status_code) => default_handler(req, status_code));
1472
+ }
1473
+
1474
+ const static_options = options.static;
1475
+ if (static_options) {
1476
+ this.dir(static_options.route, static_options.directory, async (file_path, file, stat, request) => {
1477
+ // ignore hidden files by default, return 404 to prevent file sniffing
1478
+ if (path.basename(file_path).startsWith('.'))
1479
+ return 404; // Not Found
1480
+
1481
+ if (stat.isDirectory())
1482
+ return 401; // Unauthorized
1483
+
1484
+ const ext = path.extname(file_path);
1485
+ if (static_options.sub_ext?.includes(ext)) {
1486
+ const content = await parse_template(await file.text(), global_sub_table, true);
1487
+ return new Response(content, {
1488
+ headers: {
1489
+ 'Content-Type': file.type
1490
+ }
1491
+ });
1492
+ }
1493
+
1494
+ return http_apply_range(file, request);
1495
+ });
1496
+ }
1053
1497
  }
1054
1498
  };
1055
- }
1499
+ }
1500
+ // endregion