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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "spooder",
3
3
  "type": "module",
4
- "version": "3.2.8",
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
- type HandlerReturnType = any;
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 ErrorHandler = (err: Error) => Response;
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 DirOptions = {
16
- ignoreHidden?: boolean;
17
- index?: string;
18
- };
19
- /** Built-in route handler for redirecting to a different URL. */
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 ServerStop = typeof ServerStop[keyof typeof ServerStop];
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, options?: DirOptions) => void;
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: (method?: ServerStop) => void;
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
- type HandlerReturnType = any;
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 ErrorHandler = (err: Error) => Response;
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 DirOptions = {
88
- ignoreHidden?: boolean;
89
- index?: string;
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
- function route_directory(route_path: string, dir: string, options: DirOptions): RequestHandler {
105
- const ignore_hidden = options.ignoreHidden ?? true;
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
- if (file_stat.isDirectory()) {
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
- export const ServerStop = {
139
- /** Stops the server immediately, terminating in-flight requests. */
140
- IMMEDIATE: 0,
293
+ function format_query_parameters(search_params: URLSearchParams): string {
294
+ let result_parts = [];
141
295
 
142
- /** Stops the server after all in-flight requests have completed. */
143
- GRACEFUL: 1
144
- };
296
+ for (let [key, value] of search_params)
297
+ result_parts.push(`${key}: ${value}`);
145
298
 
146
- type ServerStop = typeof ServerStop[keyof typeof ServerStop];
299
+ return '{ ' + result_parts.join(', ') + ' }';
300
+ }
147
301
 
148
- function print_request_info(req: Request, res: Response, url: URL): Response {
149
- console.log(`[${res.status}] ${req.method} ${url.pathname}`);
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 Map<string[], RequestHandler>();
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 new Response(JSON.stringify(response), { status: status_code, headers: { 'Content-Type': 'application/json' } });
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
- const server = Bun.serve({
184
- port,
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
- for (const [path, route_handler] of routes) {
196
- const is_trailing_wildcard = path[path.length - 1] === '*';
197
- if (!is_trailing_wildcard && path.length !== route_array.length)
198
- continue;
345
+ try {
346
+ const route_array = url.pathname.split('/').filter(e => !(e === '..' || e === '.'));
347
+ let handler: RequestHandler | undefined;
199
348
 
200
- let match = true;
201
- for (let i = 0; i < path.length; i++) {
202
- const path_part = path[i];
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
- if (path_part === '*')
205
- continue;
354
+ let match = true;
355
+ for (let i = 0; i < path.length; i++) {
356
+ const path_part = path[i];
206
357
 
207
- if (path_part.startsWith(':')) {
208
- url.searchParams.append(path_part.slice(1), route_array[i]);
209
- continue;
210
- }
358
+ if (path_part === '*')
359
+ continue;
211
360
 
212
- if (path_part !== route_array[i]) {
213
- match = false;
214
- break;
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 (match) {
219
- handler = route_handler;
366
+ if (path_part !== route_array[i]) {
367
+ match = false;
220
368
  break;
221
369
  }
222
370
  }
223
371
 
224
- // Check for a handler for the route.
225
- if (handler !== undefined) {
226
- const response = await resolve_handler(handler(req, url), status_code, true);
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
- // Fallback to checking for a handler for the status code.
237
- const status_code_handler = handlers.get(status_code);
238
- if (status_code_handler !== undefined) {
239
- const response = await resolve_handler(status_code_handler(req), status_code);
240
- if (response instanceof Response)
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
- // Fallback to the default handler, if any.
245
- if (default_handler !== undefined) {
246
- const response = await resolve_handler(default_handler(req, status_code), status_code);
247
- if (response instanceof Response)
248
- return print_request_info(req, response, url);
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
- // Fallback to returning a basic response.
252
- return print_request_info(req, new Response(http.STATUS_CODES[status_code], { status: status_code }), url);
253
- } catch (e) {
254
- if (error_handler !== undefined)
255
- return print_request_info(req, error_handler(e as Error), url);
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
- return print_request_info(req, new Response(http.STATUS_CODES[500], { status: 500 }), url);
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
- console.log(`Server started on port ${port}`);
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.set(path.split('/'), handler);
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, options?: DirOptions): void => {
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.set([...path.split('/'), '*'], route_directory(path, dir, options ?? {}));
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: (method: ServerStop = ServerStop.GRACEFUL): void => {
295
- server.stop(method === ServerStop.IMMEDIATE);
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
  }