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/README.md +1119 -342
- package/bun.lock +9 -5
- package/package.json +2 -2
- package/src/api.ts +976 -531
- package/src/api_db.ts +670 -0
- package/src/cli.ts +93 -19
- package/src/config.ts +13 -8
- package/src/dispatch.ts +136 -11
- package/src/template/directory_index.html +303 -0
- package/src/github.ts +0 -121
- package/src/utils.ts +0 -57
package/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 {
|
|
9
|
-
import
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
|
402
|
+
type Replacements = Record<string, string | Array<string> | object | object[]> | ReplacerFn | AsyncReplaceFn;
|
|
134
403
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
let
|
|
138
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
172
|
-
|
|
446
|
+
scoped_replacements = {
|
|
447
|
+
...replacements,
|
|
448
|
+
[alias_name]: loop_entry
|
|
449
|
+
};
|
|
173
450
|
}
|
|
174
|
-
|
|
451
|
+
|
|
452
|
+
loop_result += await parse_template(loop_content, scoped_replacements, drop_missing);
|
|
175
453
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
615
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|