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