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/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.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
- type HandlerReturnType = any;
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 ErrorHandler = (err: Error) => Response;
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 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;
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 ServerStop = typeof ServerStop[keyof typeof ServerStop];
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, options?: DirOptions) => void;
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: (method?: ServerStop) => void;
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
- type HandlerReturnType = any;
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 ErrorHandler = (err: Error) => Response;
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 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
- };
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
- function route_directory(route_path: string, dir: string, options: DirOptions): RequestHandler {
105
- const ignore_hidden = options.ignoreHidden ?? true;
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
- 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);
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
- export const ServerStop = {
139
- /** Stops the server immediately, terminating in-flight requests. */
140
- IMMEDIATE: 0,
286
+ function format_query_parameters(search_params: URLSearchParams): string {
287
+ let result_parts = [];
141
288
 
142
- /** Stops the server after all in-flight requests have completed. */
143
- GRACEFUL: 1
144
- };
289
+ for (let [key, value] of search_params)
290
+ result_parts.push(`${key}: ${value}`);
145
291
 
146
- type ServerStop = typeof ServerStop[keyof typeof ServerStop];
292
+ return '{ ' + result_parts.join(', ') + ' }';
293
+ }
147
294
 
148
- function print_request_info(req: Request, res: Response, url: URL): Response {
149
- console.log(`[${res.status}] ${req.method} ${url.pathname}`);
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 Map<string[], RequestHandler>();
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 new Response(JSON.stringify(response), { status: status_code, headers: { 'Content-Type': 'application/json' } });
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
- 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;
335
+ async function generate_response(req: Request, url: URL): Promise<Response> {
336
+ let status_code = 200;
194
337
 
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;
338
+ try {
339
+ const route_array = url.pathname.split('/').filter(e => !(e === '..' || e === '.'));
340
+ let handler: RequestHandler | undefined;
199
341
 
200
- let match = true;
201
- for (let i = 0; i < path.length; i++) {
202
- const path_part = path[i];
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
- if (path_part === '*')
205
- continue;
347
+ let match = true;
348
+ for (let i = 0; i < path.length; i++) {
349
+ const path_part = path[i];
206
350
 
207
- if (path_part.startsWith(':')) {
208
- url.searchParams.append(path_part.slice(1), route_array[i]);
209
- continue;
210
- }
351
+ if (path_part === '*')
352
+ continue;
211
353
 
212
- if (path_part !== route_array[i]) {
213
- match = false;
214
- break;
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 (match) {
219
- handler = route_handler;
359
+ if (path_part !== route_array[i]) {
360
+ match = false;
220
361
  break;
221
362
  }
222
363
  }
223
364
 
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;
365
+ if (match) {
366
+ handler = route_handler;
367
+ break;
234
368
  }
369
+ }
235
370
 
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
- }
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
- // 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
- }
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
- // 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);
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
- return print_request_info(req, new Response(http.STATUS_CODES[500], { status: 500 }), url);
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
- console.log(`Server started on port ${port}`);
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.set(path.split('/'), handler);
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, options?: DirOptions): void => {
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.set([...path.split('/'), '*'], route_directory(path, dir, options ?? {}));
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: (method: ServerStop = ServerStop.GRACEFUL): void => {
295
- server.stop(method === ServerStop.IMMEDIATE);
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
  }