spooder 3.2.8 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +584 -277
- package/package.json +1 -4
- package/src/api.d.ts +46 -16
- package/src/api.ts +355 -114
- package/src/config.ts +10 -1
- package/src/dispatch.ts +32 -43
- package/src/github.d.ts +11 -0
- package/src/github.ts +121 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spooder",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "4.0.1",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
7
7
|
"bun": "./src/api.ts",
|
|
@@ -15,8 +15,5 @@
|
|
|
15
15
|
},
|
|
16
16
|
"bin": {
|
|
17
17
|
"spooder": "./src/cli.ts"
|
|
18
|
-
},
|
|
19
|
-
"dependencies": {
|
|
20
|
-
"@octokit/app": "^13.1.5"
|
|
21
18
|
}
|
|
22
19
|
}
|
package/src/api.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
1
2
|
/// <reference types="bun-types" />
|
|
2
3
|
/// <reference types="node" />
|
|
4
|
+
/// <reference types="node" />
|
|
5
|
+
import fs from 'node:fs/promises';
|
|
6
|
+
import { Blob } from 'node:buffer';
|
|
3
7
|
export declare class ErrorWithMetadata extends Error {
|
|
4
8
|
metadata: Record<string, unknown>;
|
|
5
9
|
constructor(message: string, metadata: Record<string, unknown>);
|
|
@@ -7,29 +11,53 @@ export declare class ErrorWithMetadata extends Error {
|
|
|
7
11
|
}
|
|
8
12
|
export declare function panic(err_message_or_obj: string | object, ...err: object[]): Promise<void>;
|
|
9
13
|
export declare function caution(err_message_or_obj: string | object, ...err: object[]): Promise<void>;
|
|
10
|
-
|
|
14
|
+
export declare function template_sub_file(template_file: string, replacements: Record<string, string>): Promise<string>;
|
|
15
|
+
export declare function template_sub(template: string, replacements: Record<string, string>): string;
|
|
16
|
+
export declare function generate_hash_subs(length?: number, prefix?: string): Promise<Record<string, string>>;
|
|
17
|
+
type CookieOptions = {
|
|
18
|
+
same_site?: 'Strict' | 'Lax' | 'None';
|
|
19
|
+
secure?: boolean;
|
|
20
|
+
http_only?: boolean;
|
|
21
|
+
path?: string;
|
|
22
|
+
expires?: number;
|
|
23
|
+
encode?: boolean;
|
|
24
|
+
};
|
|
25
|
+
export declare function set_cookie(res: Response, name: string, value: string, options?: CookieOptions): void;
|
|
26
|
+
export declare function get_cookies(source: Request | Response, decode?: boolean): Record<string, string>;
|
|
27
|
+
export declare function apply_range(file: BunFile, request: Request): BunFile;
|
|
28
|
+
type Resolvable<T> = T | Promise<T>;
|
|
29
|
+
type PromiseType<T extends Promise<any>> = T extends Promise<infer U> ? U : never;
|
|
30
|
+
type JsonPrimitive = string | number | boolean | null;
|
|
31
|
+
type JsonArray = JsonSerializable[];
|
|
32
|
+
interface JsonObject {
|
|
33
|
+
[key: string]: JsonSerializable;
|
|
34
|
+
}
|
|
35
|
+
interface ToJson {
|
|
36
|
+
toJSON(): any;
|
|
37
|
+
}
|
|
38
|
+
type JsonSerializable = JsonPrimitive | JsonObject | JsonArray | ToJson;
|
|
39
|
+
type HandlerReturnType = Resolvable<string | number | BunFile | Response | JsonSerializable | Blob>;
|
|
11
40
|
type RequestHandler = (req: Request, url: URL) => HandlerReturnType;
|
|
12
|
-
type
|
|
41
|
+
type WebhookHandler = (payload: JsonSerializable) => HandlerReturnType;
|
|
42
|
+
type ErrorHandler = (err: Error, req: Request, url: URL) => Response;
|
|
13
43
|
type DefaultHandler = (req: Request, status_code: number) => HandlerReturnType;
|
|
14
44
|
type StatusCodeHandler = (req: Request) => HandlerReturnType;
|
|
15
|
-
type
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
export declare function route_location(redirect_url: string): (req: Request, url: URL) => Response;
|
|
21
|
-
export declare const ServerStop: {
|
|
22
|
-
/** Stops the server immediately, terminating in-flight requests. */
|
|
23
|
-
IMMEDIATE: number;
|
|
24
|
-
/** Stops the server after all in-flight requests have completed. */
|
|
25
|
-
GRACEFUL: number;
|
|
45
|
+
type ServerSentEventClient = {
|
|
46
|
+
message: (message: string) => void;
|
|
47
|
+
event: (event_name: string, message: string) => void;
|
|
48
|
+
close: () => void;
|
|
49
|
+
closed: Promise<void>;
|
|
26
50
|
};
|
|
27
|
-
type
|
|
51
|
+
type ServerSentEventHandler = (req: Request, url: URL, client: ServerSentEventClient) => void;
|
|
52
|
+
type BunFile = ReturnType<typeof Bun.file>;
|
|
53
|
+
type DirStat = PromiseType<ReturnType<typeof fs.stat>>;
|
|
54
|
+
type DirHandler = (file_path: string, file: BunFile, stat: DirStat, request: Request, url: URL) => HandlerReturnType;
|
|
28
55
|
export declare function serve(port: number): {
|
|
29
56
|
/** Register a handler for a specific route. */
|
|
30
57
|
route: (path: string, handler: RequestHandler) => void;
|
|
31
58
|
/** Serve a directory for a specific route. */
|
|
32
|
-
dir: (path: string, dir: string,
|
|
59
|
+
dir: (path: string, dir: string, handler?: DirHandler) => void;
|
|
60
|
+
webhook: (secret: string, path: string, handler: WebhookHandler) => void;
|
|
33
61
|
/** Register a default handler for all status codes. */
|
|
34
62
|
default: (handler: DefaultHandler) => void;
|
|
35
63
|
/** Register a handler for a specific status code. */
|
|
@@ -37,6 +65,8 @@ export declare function serve(port: number): {
|
|
|
37
65
|
/** Register a handler for uncaught errors. */
|
|
38
66
|
error: (handler: ErrorHandler) => void;
|
|
39
67
|
/** Stops the server. */
|
|
40
|
-
stop: (
|
|
68
|
+
stop: (immediate?: boolean) => void;
|
|
69
|
+
/** Register a handler for server-sent events. */
|
|
70
|
+
sse: (path: string, handler: ServerSentEventHandler) => void;
|
|
41
71
|
};
|
|
42
72
|
export {};
|
package/src/api.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { dispatch_report } from './dispatch';
|
|
2
|
+
import { get_config } from './config';
|
|
2
3
|
import http from 'node:http';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import fs from 'node:fs/promises';
|
|
6
|
+
import { log } from './utils';
|
|
7
|
+
import crypto from 'crypto';
|
|
8
|
+
import { Blob } from 'node:buffer';
|
|
5
9
|
|
|
6
10
|
export class ErrorWithMetadata extends Error {
|
|
7
11
|
constructor(message: string, public metadata: Record<string, unknown>) {
|
|
@@ -78,80 +82,232 @@ export async function caution(err_message_or_obj: string | object, ...err: objec
|
|
|
78
82
|
await handle_error('caution: ', err_message_or_obj, ...err);
|
|
79
83
|
}
|
|
80
84
|
|
|
81
|
-
|
|
85
|
+
export async function template_sub_file(template_file: string, replacements: Record<string, string>): Promise<string> {
|
|
86
|
+
const file = Bun.file(template_file);
|
|
87
|
+
const file_contents = await file.text();
|
|
88
|
+
|
|
89
|
+
return template_sub(file_contents, replacements);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function template_sub(template: string, replacements: Record<string, string>): string {
|
|
93
|
+
let result = '';
|
|
94
|
+
let buffer = '';
|
|
95
|
+
let buffer_active = false;
|
|
96
|
+
|
|
97
|
+
const template_length = template.length;
|
|
98
|
+
for (let i = 0; i < template_length; i++) {
|
|
99
|
+
const char = template[i];
|
|
100
|
+
|
|
101
|
+
if (char === '{') {
|
|
102
|
+
buffer_active = true;
|
|
103
|
+
buffer = '';
|
|
104
|
+
} else if (char === '}') {
|
|
105
|
+
buffer_active = false;
|
|
106
|
+
|
|
107
|
+
result += replacements[buffer] ?? '{' + buffer + '}';
|
|
108
|
+
|
|
109
|
+
buffer = '';
|
|
110
|
+
} else if (buffer_active) {
|
|
111
|
+
buffer += char;
|
|
112
|
+
} else {
|
|
113
|
+
result += char;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function generate_hash_subs(length = 7, prefix = 'hash='): Promise<Record<string, string>> {
|
|
121
|
+
const cmd = ['git', 'ls-tree', '-r', 'HEAD'];
|
|
122
|
+
const process = Bun.spawn(cmd, {
|
|
123
|
+
stdout: 'pipe',
|
|
124
|
+
stderr: 'pipe'
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await process.exited;
|
|
128
|
+
|
|
129
|
+
if (process.exitCode as number > 0)
|
|
130
|
+
throw new Error('generate_hash_subs() failed, `' + cmd.join(' ') + '` exited with non-zero exit code.');
|
|
131
|
+
|
|
132
|
+
const stdout = await Bun.readableStreamToText(process.stdout as ReadableStream);
|
|
133
|
+
const hash_map: Record<string, string> = {};
|
|
134
|
+
|
|
135
|
+
const regex = /([^\s]+)\s([^\s]+)\s([^\s]+)\t(.+)/g;
|
|
136
|
+
let match: RegExpExecArray | null;
|
|
137
|
+
|
|
138
|
+
let hash_count = 0;
|
|
139
|
+
while (match = regex.exec(stdout)) {
|
|
140
|
+
hash_map[prefix + match[4]] = match[3].substring(0, length);
|
|
141
|
+
hash_count++;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return hash_map;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
type CookieOptions = {
|
|
148
|
+
same_site?: 'Strict' | 'Lax' | 'None',
|
|
149
|
+
secure?: boolean,
|
|
150
|
+
http_only?: boolean,
|
|
151
|
+
path?: string,
|
|
152
|
+
expires?: number,
|
|
153
|
+
encode?: boolean
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export function set_cookie(res: Response, name: string, value: string, options?: CookieOptions): void {
|
|
157
|
+
let cookie = name + '=';
|
|
158
|
+
if (options !== undefined) {
|
|
159
|
+
cookie += options.encode ? encodeURIComponent(value) : value;
|
|
160
|
+
|
|
161
|
+
if (options.same_site !== undefined)
|
|
162
|
+
cookie += '; SameSite=' + options.same_site;
|
|
163
|
+
|
|
164
|
+
if (options.secure)
|
|
165
|
+
cookie += '; Secure';
|
|
166
|
+
|
|
167
|
+
if (options.http_only)
|
|
168
|
+
cookie += '; HttpOnly';
|
|
169
|
+
|
|
170
|
+
if (options.path !== undefined)
|
|
171
|
+
cookie += '; Path=' + options.path;
|
|
172
|
+
|
|
173
|
+
if (options.expires !== undefined) {
|
|
174
|
+
const date = new Date(Date.now() + options.expires);
|
|
175
|
+
cookie += '; Expires=' + date.toUTCString();
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
cookie += value;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
res.headers.append('Set-Cookie', cookie);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function get_cookies(source: Request | Response, decode: boolean = false): Record<string, string> {
|
|
185
|
+
const parsed_cookies: Record<string, string> = {};
|
|
186
|
+
const cookie_header = source.headers.get('cookie');
|
|
187
|
+
|
|
188
|
+
if (cookie_header !== null) {
|
|
189
|
+
const cookies = cookie_header.split('; ');
|
|
190
|
+
for (const cookie of cookies) {
|
|
191
|
+
const [name, value] = cookie.split('=');
|
|
192
|
+
parsed_cookies[name] = decode ? decodeURIComponent(value) : value;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return parsed_cookies;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function apply_range(file: BunFile, request: Request): BunFile {
|
|
200
|
+
const range_header = request.headers.get('range');
|
|
201
|
+
if (range_header !== null) {
|
|
202
|
+
console.log(range_header);
|
|
203
|
+
const regex = /bytes=(\d*)-(\d*)/;
|
|
204
|
+
const match = range_header.match(regex);
|
|
205
|
+
|
|
206
|
+
if (match !== null) {
|
|
207
|
+
const start = parseInt(match[1]);
|
|
208
|
+
const end = parseInt(match[2]);
|
|
209
|
+
|
|
210
|
+
const start_is_nan = isNaN(start);
|
|
211
|
+
const end_is_nan = isNaN(end);
|
|
212
|
+
|
|
213
|
+
if (start_is_nan && end_is_nan)
|
|
214
|
+
return file;
|
|
215
|
+
|
|
216
|
+
file = file.slice(start_is_nan ? file.size - end : start, end_is_nan || start_is_nan ? undefined : end);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return file;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Resolvable represents T that is both T or a promise resolving to T.
|
|
223
|
+
type Resolvable<T> = T | Promise<T>;
|
|
224
|
+
|
|
225
|
+
// PromiseType infers the resolved type of a promise (T) or just T if not a promise.
|
|
226
|
+
type PromiseType<T extends Promise<any>> = T extends Promise<infer U> ? U : never;
|
|
227
|
+
|
|
228
|
+
// The following types cover JSON serializable objects/classes.
|
|
229
|
+
type JsonPrimitive = string | number | boolean | null;
|
|
230
|
+
type JsonArray = JsonSerializable[];
|
|
231
|
+
|
|
232
|
+
interface JsonObject {
|
|
233
|
+
[key: string]: JsonSerializable;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
interface ToJson {
|
|
237
|
+
toJSON(): any;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
type JsonSerializable = JsonPrimitive | JsonObject | JsonArray | ToJson;
|
|
241
|
+
|
|
242
|
+
type HandlerReturnType = Resolvable<string | number | BunFile | Response | JsonSerializable | Blob>;
|
|
82
243
|
type RequestHandler = (req: Request, url: URL) => HandlerReturnType;
|
|
83
|
-
type
|
|
244
|
+
type WebhookHandler = (payload: JsonSerializable) => HandlerReturnType;
|
|
245
|
+
type ErrorHandler = (err: Error, req: Request, url: URL) => Response;
|
|
84
246
|
type DefaultHandler = (req: Request, status_code: number) => HandlerReturnType;
|
|
85
247
|
type StatusCodeHandler = (req: Request) => HandlerReturnType;
|
|
86
248
|
|
|
87
|
-
type
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
/** Built-in route handler for redirecting to a different URL. */
|
|
93
|
-
export function route_location(redirect_url: string) {
|
|
94
|
-
return (req: Request, url: URL) => {
|
|
95
|
-
return new Response(null, {
|
|
96
|
-
status: 301,
|
|
97
|
-
headers: {
|
|
98
|
-
Location: redirect_url
|
|
99
|
-
}
|
|
100
|
-
});
|
|
101
|
-
};
|
|
249
|
+
type ServerSentEventClient = {
|
|
250
|
+
message: (message: string) => void;
|
|
251
|
+
event: (event_name: string, message: string) => void;
|
|
252
|
+
close: () => void;
|
|
253
|
+
closed: Promise<void>;
|
|
102
254
|
}
|
|
103
255
|
|
|
104
|
-
|
|
105
|
-
|
|
256
|
+
type ServerSentEventHandler = (req: Request, url: URL, client: ServerSentEventClient) => void;
|
|
257
|
+
|
|
258
|
+
type BunFile = ReturnType<typeof Bun.file>;
|
|
259
|
+
type DirStat = PromiseType<ReturnType<typeof fs.stat>>;
|
|
260
|
+
|
|
261
|
+
type DirHandler = (file_path: string, file: BunFile, stat: DirStat, request: Request, url: URL) => HandlerReturnType;
|
|
262
|
+
|
|
263
|
+
function default_directory_handler(file_path: string, file: BunFile, stat: DirStat, request: Request): HandlerReturnType {
|
|
264
|
+
// ignore hidden files by default, return 404 to prevent file sniffing
|
|
265
|
+
if (path.basename(file_path).startsWith('.'))
|
|
266
|
+
return 404; // Not Found
|
|
106
267
|
|
|
268
|
+
if (stat.isDirectory())
|
|
269
|
+
return 401; // Unauthorized
|
|
270
|
+
|
|
271
|
+
return apply_range(file, request);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function route_directory(route_path: string, dir: string, handler: DirHandler): RequestHandler {
|
|
107
275
|
return async (req: Request, url: URL) => {
|
|
108
276
|
const file_path = path.join(dir, url.pathname.slice(route_path.length));
|
|
109
277
|
|
|
110
|
-
if (ignore_hidden && path.basename(file_path).startsWith('.'))
|
|
111
|
-
return 404;
|
|
112
|
-
|
|
113
278
|
try {
|
|
114
279
|
const file_stat = await fs.stat(file_path);
|
|
280
|
+
const bun_file = Bun.file(file_path);
|
|
115
281
|
|
|
116
|
-
|
|
117
|
-
if (options.index !== undefined) {
|
|
118
|
-
const index_path = path.join(file_path, options.index);
|
|
119
|
-
const index = Bun.file(index_path);
|
|
120
|
-
|
|
121
|
-
if (index.size !== 0)
|
|
122
|
-
return index;
|
|
123
|
-
}
|
|
124
|
-
return 401;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
return Bun.file(file_path);
|
|
282
|
+
return handler(file_path, bun_file, file_stat, req, url);
|
|
128
283
|
} catch (e) {
|
|
129
284
|
const err = e as NodeJS.ErrnoException;
|
|
130
285
|
if (err?.code === 'ENOENT')
|
|
131
|
-
return 404;
|
|
286
|
+
return 404; // Not Found
|
|
132
287
|
|
|
133
|
-
return 500;
|
|
288
|
+
return 500; // Internal Server Error
|
|
134
289
|
}
|
|
135
290
|
};
|
|
136
291
|
}
|
|
137
292
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
IMMEDIATE: 0,
|
|
293
|
+
function format_query_parameters(search_params: URLSearchParams): string {
|
|
294
|
+
let result_parts = [];
|
|
141
295
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
};
|
|
296
|
+
for (let [key, value] of search_params)
|
|
297
|
+
result_parts.push(`${key}: ${value}`);
|
|
145
298
|
|
|
146
|
-
|
|
299
|
+
return '{ ' + result_parts.join(', ') + ' }';
|
|
300
|
+
}
|
|
147
301
|
|
|
148
|
-
function print_request_info(req: Request, res: Response, url: URL): Response {
|
|
149
|
-
|
|
302
|
+
function print_request_info(req: Request, res: Response, url: URL, request_start: number): Response {
|
|
303
|
+
const request_time = Date.now() - request_start;
|
|
304
|
+
const search_params = url.search.length > 0 ? format_query_parameters(url.searchParams) : '';
|
|
305
|
+
console.log(`[${res.status}] ${req.method} ${url.pathname} ${search_params} [${request_time}ms]`);
|
|
150
306
|
return res;
|
|
151
307
|
}
|
|
152
308
|
|
|
153
309
|
export function serve(port: number) {
|
|
154
|
-
const routes = new
|
|
310
|
+
const routes = new Array<[string[], RequestHandler]>();
|
|
155
311
|
const handlers = new Map<number, StatusCodeHandler>();
|
|
156
312
|
|
|
157
313
|
let error_handler: ErrorHandler | undefined;
|
|
@@ -161,13 +317,16 @@ export function serve(port: number) {
|
|
|
161
317
|
if (response instanceof Promise)
|
|
162
318
|
response = await response;
|
|
163
319
|
|
|
320
|
+
if (response === undefined || response === null)
|
|
321
|
+
throw new Error('HandlerReturnType cannot resolve to undefined or null');
|
|
322
|
+
|
|
164
323
|
// Pre-assembled responses are returned as-is.
|
|
165
324
|
if (response instanceof Response)
|
|
166
325
|
return response;
|
|
167
326
|
|
|
168
327
|
// Content-type/content-length are automatically set for blobs.
|
|
169
328
|
if (response instanceof Blob)
|
|
170
|
-
return new Response(response, { status: status_code });
|
|
329
|
+
return new Response(response as Blob, { status: status_code });
|
|
171
330
|
|
|
172
331
|
// Status codes can be returned from some handlers.
|
|
173
332
|
if (return_status_code && typeof response === 'number')
|
|
@@ -175,104 +334,134 @@ export function serve(port: number) {
|
|
|
175
334
|
|
|
176
335
|
// This should cover objects, arrays, etc.
|
|
177
336
|
if (typeof response === 'object')
|
|
178
|
-
return
|
|
337
|
+
return Response.json(response, { status: status_code });
|
|
179
338
|
|
|
180
|
-
return new Response(String(response), { status: status_code })
|
|
339
|
+
return new Response(String(response), { status: status_code, headers: { 'Content-Type': 'text/html' } });
|
|
181
340
|
}
|
|
182
341
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
development: false,
|
|
186
|
-
|
|
187
|
-
async fetch(req: Request): Promise<Response> {
|
|
188
|
-
const url = new URL(req.url);
|
|
189
|
-
let status_code = 200;
|
|
190
|
-
|
|
191
|
-
try {
|
|
192
|
-
const route_array = url.pathname.split('/').filter(e => !(e === '..' || e === '.'));
|
|
193
|
-
let handler: RequestHandler | undefined;
|
|
342
|
+
async function generate_response(req: Request, url: URL): Promise<Response> {
|
|
343
|
+
let status_code = 200;
|
|
194
344
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
continue;
|
|
345
|
+
try {
|
|
346
|
+
const route_array = url.pathname.split('/').filter(e => !(e === '..' || e === '.'));
|
|
347
|
+
let handler: RequestHandler | undefined;
|
|
199
348
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
349
|
+
for (const [path, route_handler] of routes) {
|
|
350
|
+
const is_trailing_wildcard = path[path.length - 1] === '*';
|
|
351
|
+
if (!is_trailing_wildcard && path.length !== route_array.length)
|
|
352
|
+
continue;
|
|
203
353
|
|
|
204
|
-
|
|
205
|
-
|
|
354
|
+
let match = true;
|
|
355
|
+
for (let i = 0; i < path.length; i++) {
|
|
356
|
+
const path_part = path[i];
|
|
206
357
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
continue;
|
|
210
|
-
}
|
|
358
|
+
if (path_part === '*')
|
|
359
|
+
continue;
|
|
211
360
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
361
|
+
if (path_part.startsWith(':')) {
|
|
362
|
+
url.searchParams.append(path_part.slice(1), route_array[i]);
|
|
363
|
+
continue;
|
|
216
364
|
}
|
|
217
365
|
|
|
218
|
-
if (
|
|
219
|
-
|
|
366
|
+
if (path_part !== route_array[i]) {
|
|
367
|
+
match = false;
|
|
220
368
|
break;
|
|
221
369
|
}
|
|
222
370
|
}
|
|
223
371
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (response instanceof Response)
|
|
228
|
-
return print_request_info(req, response, url);
|
|
229
|
-
|
|
230
|
-
// If the handler returned a status code, use that instead.
|
|
231
|
-
status_code = response;
|
|
232
|
-
} else {
|
|
233
|
-
status_code = 404;
|
|
372
|
+
if (match) {
|
|
373
|
+
handler = route_handler;
|
|
374
|
+
break;
|
|
234
375
|
}
|
|
376
|
+
}
|
|
235
377
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
return print_request_info(req, response, url);
|
|
242
|
-
}
|
|
378
|
+
// Check for a handler for the route.
|
|
379
|
+
if (handler !== undefined) {
|
|
380
|
+
const response = await resolve_handler(handler(req, url), status_code, true);
|
|
381
|
+
if (response instanceof Response)
|
|
382
|
+
return response;
|
|
243
383
|
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
384
|
+
// If the handler returned a status code, use that instead.
|
|
385
|
+
status_code = response;
|
|
386
|
+
} else {
|
|
387
|
+
status_code = 404;
|
|
388
|
+
}
|
|
250
389
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
390
|
+
// Fallback to checking for a handler for the status code.
|
|
391
|
+
const status_code_handler = handlers.get(status_code);
|
|
392
|
+
if (status_code_handler !== undefined) {
|
|
393
|
+
const response = await resolve_handler(status_code_handler(req), status_code);
|
|
394
|
+
if (response instanceof Response)
|
|
395
|
+
return response;
|
|
396
|
+
}
|
|
256
397
|
|
|
257
|
-
|
|
398
|
+
// Fallback to the default handler, if any.
|
|
399
|
+
if (default_handler !== undefined) {
|
|
400
|
+
const response = await resolve_handler(default_handler(req, status_code), status_code);
|
|
401
|
+
if (response instanceof Response)
|
|
402
|
+
return response;
|
|
258
403
|
}
|
|
404
|
+
|
|
405
|
+
// Fallback to returning a basic response.
|
|
406
|
+
return new Response(http.STATUS_CODES[status_code], { status: status_code });
|
|
407
|
+
} catch (e) {
|
|
408
|
+
if (error_handler !== undefined)
|
|
409
|
+
return error_handler(e as Error, req, url);
|
|
410
|
+
|
|
411
|
+
return new Response(http.STATUS_CODES[500], { status: 500 });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const server = Bun.serve({
|
|
416
|
+
port,
|
|
417
|
+
development: false,
|
|
418
|
+
|
|
419
|
+
async fetch(req: Request): Promise<Response> {
|
|
420
|
+
const url = new URL(req.url);
|
|
421
|
+
const request_start = Date.now();
|
|
422
|
+
|
|
423
|
+
const response = await generate_response(req, url);
|
|
424
|
+
return print_request_info(req, response, url, request_start);
|
|
259
425
|
}
|
|
260
426
|
});
|
|
261
427
|
|
|
262
|
-
|
|
428
|
+
log('server started on port ' + port);
|
|
263
429
|
|
|
264
430
|
return {
|
|
265
431
|
/** Register a handler for a specific route. */
|
|
266
432
|
route: (path: string, handler: RequestHandler): void => {
|
|
267
|
-
routes.
|
|
433
|
+
routes.push([path.split('/'), handler]);
|
|
268
434
|
},
|
|
269
435
|
|
|
270
436
|
/** Serve a directory for a specific route. */
|
|
271
|
-
dir: (path: string, dir: string,
|
|
437
|
+
dir: (path: string, dir: string, handler?: DirHandler): void => {
|
|
272
438
|
if (path.endsWith('/'))
|
|
273
439
|
path = path.slice(0, -1);
|
|
274
440
|
|
|
275
|
-
routes.
|
|
441
|
+
routes.push([[...path.split('/'), '*'], route_directory(path, dir, handler ?? default_directory_handler)]);
|
|
442
|
+
},
|
|
443
|
+
|
|
444
|
+
webhook: (secret: string, path: string, handler: WebhookHandler): void => {
|
|
445
|
+
routes.push([path.split('/'), async (req: Request, url: URL) => {
|
|
446
|
+
if (req.method !== 'POST')
|
|
447
|
+
return 405; // Method Not Allowed
|
|
448
|
+
|
|
449
|
+
if (req.headers.get('Content-Type') !== 'application/json')
|
|
450
|
+
return 400; // Bad Request
|
|
451
|
+
|
|
452
|
+
const signature = req.headers.get('X-Hub-Signature-256');
|
|
453
|
+
if (signature === null)
|
|
454
|
+
return 401; // Unauthorized
|
|
455
|
+
|
|
456
|
+
const body = await req.json() as JsonSerializable;
|
|
457
|
+
const hmac = crypto.createHmac('sha256', secret);
|
|
458
|
+
hmac.update(JSON.stringify(body));
|
|
459
|
+
|
|
460
|
+
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from('sha256=' + hmac.digest('hex'))))
|
|
461
|
+
return 401; // Unauthorized
|
|
462
|
+
|
|
463
|
+
return handler(body);
|
|
464
|
+
}]);
|
|
276
465
|
},
|
|
277
466
|
|
|
278
467
|
/** Register a default handler for all status codes. */
|
|
@@ -291,8 +480,60 @@ export function serve(port: number) {
|
|
|
291
480
|
},
|
|
292
481
|
|
|
293
482
|
/** Stops the server. */
|
|
294
|
-
stop: (
|
|
295
|
-
server.stop(
|
|
483
|
+
stop: (immediate = false): void => {
|
|
484
|
+
server.stop(immediate);
|
|
485
|
+
},
|
|
486
|
+
|
|
487
|
+
/** Register a handler for server-sent events. */
|
|
488
|
+
sse: (path: string, handler: ServerSentEventHandler) => {
|
|
489
|
+
routes.push([path.split('/'), (req: Request, url: URL) => {
|
|
490
|
+
let stream_controller: ReadableStreamDirectController;
|
|
491
|
+
let close_resolver: () => void;
|
|
492
|
+
|
|
493
|
+
function close_controller() {
|
|
494
|
+
stream_controller?.close();
|
|
495
|
+
close_resolver?.();
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const queue = Array<string>();
|
|
499
|
+
const stream = new ReadableStream({
|
|
500
|
+
type: 'direct',
|
|
501
|
+
|
|
502
|
+
async pull(controller: ReadableStreamDirectController) {
|
|
503
|
+
stream_controller = controller;
|
|
504
|
+
while (!req.signal.aborted) {
|
|
505
|
+
if (queue.length > 0) {
|
|
506
|
+
controller.write(queue.shift()!);
|
|
507
|
+
controller.flush();
|
|
508
|
+
} else {
|
|
509
|
+
await Bun.sleep(50);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
const closed = new Promise<void>(resolve => close_resolver = resolve);
|
|
516
|
+
req.signal.onabort = close_controller;
|
|
517
|
+
|
|
518
|
+
handler(req, url, {
|
|
519
|
+
message: (message: string) => {
|
|
520
|
+
queue.push('data:' + message + '\n\n');
|
|
521
|
+
},
|
|
522
|
+
|
|
523
|
+
event: (event_name: string, message: string) => {
|
|
524
|
+
queue.push('event:' + event_name + '\ndata:' + message + '\n\n');
|
|
525
|
+
},
|
|
526
|
+
|
|
527
|
+
close: close_controller,
|
|
528
|
+
closed
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
return new Response(stream, { headers: {
|
|
532
|
+
'Content-Type': 'text/event-stream',
|
|
533
|
+
'Cache-Control': 'no-cache',
|
|
534
|
+
'Connection': 'keep-alive'
|
|
535
|
+
}});
|
|
536
|
+
}]);
|
|
296
537
|
}
|
|
297
538
|
}
|
|
298
539
|
}
|