diff --git a/deno.json b/deno.json index c440a37..19e5dcf 100644 --- a/deno.json +++ b/deno.json @@ -1,7 +1,7 @@ { "name": "@andyburke/serverus", "description": "A flexible HTTP server for mixed content. Throw static files, markdown, Typescript and (hopefully, eventually) more into a directory and serverus can serve it up a bit more like old-school CGI.", - "version": "0.9.7", + "version": "0.9.8", "license": "MIT", "exports": { ".": "./serverus.ts", diff --git a/server.ts b/server.ts index ed5b251..ea25b86 100644 --- a/server.ts +++ b/server.ts @@ -6,6 +6,8 @@ import * as colors from '@std/fmt/colors'; import * as path from '@std/path'; +const EVENTS_TO_SHUTDOWN_ON: Deno.Signal[] = ['SIGTERM', 'SIGINT']; + const DEFAULT_HANDLER_DIRECTORIES = [import.meta.resolve('./handlers')]; /** A `HANDLER` must take a `Request` and return a `Response` if it can handle it. */ @@ -17,7 +19,7 @@ type LOGGER = (request: Request, response: Response, processing_time: number) => /** A `HANDLER_MODULE` must export a default method and may export an unload method to be called at shutdown. */ interface HANDLER_MODULE { default: HANDLER; - unload?: () => void; + unload?: () => void | Promise; } /** @@ -178,12 +180,15 @@ export class SERVER { ); this.shutdown_binding = this.stop.bind(this); - Deno.addSignalListener('SIGTERM', this.shutdown_binding); - Deno.addSignalListener('SIGINT', this.shutdown_binding); - if (this.options.watch) { - Deno.watchFs; + for (const event of EVENTS_TO_SHUTDOWN_ON) { + Deno.addSignalListener(event, this.shutdown_binding); } + + // if (this.options.watch) { + // Deno.watchFs; + // } + return this; } @@ -194,8 +199,9 @@ export class SERVER { if (this.server) { this.server.finished.finally(() => { if (this.shutdown_binding) { - Deno.removeSignalListener('SIGTERM', this.shutdown_binding); - Deno.removeSignalListener('SIGINT', this.shutdown_binding); + for (const event of EVENTS_TO_SHUTDOWN_ON) { + Deno.removeSignalListener(event, this.shutdown_binding); + } } this.shutdown_binding = undefined; @@ -203,6 +209,7 @@ export class SERVER { this.controller?.abort(); await this.server.shutdown(); + await this.server.finished; } if (this.original_directory) { @@ -215,7 +222,7 @@ export class SERVER { for (const handler_module of this.handlers) { if (typeof handler_module.unload === 'function') { - handler_module.unload(); + await handler_module.unload(); } } this.handlers = []; diff --git a/tests/09_test_event_handlers.test.ts b/tests/09_test_event_handlers.test.ts new file mode 100644 index 0000000..ea23f1a --- /dev/null +++ b/tests/09_test_event_handlers.test.ts @@ -0,0 +1,94 @@ +import * as asserts from '@std/assert'; +import { EPHEMERAL_SERVER, get_ephemeral_listen_server } from './helpers.ts'; + +Deno.test({ + name: 'test that event handlers are cleaned up properly', + permissions: { + env: true, + read: true, + write: true, + net: true + }, + + sanitizeResources: false, + sanitizeOps: false, + + // TODO: why does this leak an event handler necessitating the above settings??? spent almost a day on this, cannot figure it out yet. + fn: async () => { + let test_server_info: EPHEMERAL_SERVER | null = null; + + try { + test_server_info = await get_ephemeral_listen_server({ + root: './tests/www' + }); + + const NUM_INITIAL_EVENTS = 5; + const events_initial_batch: any[] = []; + for (let i = 0; i < NUM_INITIAL_EVENTS; ++i) { + const event_response = await fetch(`http://${test_server_info.hostname}:${test_server_info.port}/api/events`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + value: `${i}` + }) + }); + + const event = await event_response.json(); + + asserts.assert(event); + events_initial_batch.push(event); + } + + asserts.assertEquals(events_initial_batch.length, NUM_INITIAL_EVENTS); + + const events_from_server_initial_batch = + await (await fetch(`http://${test_server_info.hostname}:${test_server_info.port}/api/events`, { + method: 'GET' + })).json(); + + asserts.assertEquals(events_from_server_initial_batch.length, NUM_INITIAL_EVENTS); + + const latest_event = events_from_server_initial_batch.at(-1); + asserts.assert(latest_event); + asserts.assertEquals(latest_event, events_from_server_initial_batch[events_from_server_initial_batch.length - 1]); + + const long_poll_request_promise = fetch( + `http://${test_server_info.hostname}:${test_server_info.port}/api/events?wait=true&after=${latest_event.timestamp}`, + { + method: 'GET' + } + ); + + const wait_and_then_create_an_event = new Promise((resolve) => { + setTimeout(async () => { + const event = await (await fetch(`http://${test_server_info?.hostname}:${test_server_info?.port}/api/events`, { + method: 'POST', + body: JSON.stringify({ + value: 'new latest' + }) + })).json(); + + resolve(event); + }, 2_000); + }); + + await (Promise.all([wait_and_then_create_an_event, long_poll_request_promise]).then(async (values) => { + const new_event = values.shift(); + asserts.assert(new_event); + + const long_poll_response: Response | undefined = values.shift() as Response; + asserts.assert(long_poll_response); + + const long_polled_events = await long_poll_response.json(); + asserts.assert(Array.isArray(long_polled_events)); + asserts.assertEquals(long_polled_events, [new_event]); + })); + } finally { + if (test_server_info) { + await test_server_info.server.stop(); + } + } + } +}); diff --git a/tests/www/api/events/index.ts b/tests/www/api/events/index.ts new file mode 100644 index 0000000..3da2a4c --- /dev/null +++ b/tests/www/api/events/index.ts @@ -0,0 +1,86 @@ +type EVENT = { + value: string; + timestamp: string; +}; + +const events: EVENT[] = []; + +export function GET(request: Request, meta: Record): Promise | Response { + function get_events() { + return events.filter((event) => meta.query.after ? event.timestamp > meta.query.after : true); + } + + const results = get_events(); + + // long-polling support + if (results.length === 0 && meta.query.wait) { + const last_event = events.at(-1); + return new Promise((resolve, reject) => { + let timeout: number | undefined; + + const final_timeout = setTimeout(() => { + if (timeout) clearTimeout(timeout); + resolve(Response.json([], { + status: 200 + })); + }, 60_000); // 60 seconds max wait + + (function check_for_new_events() { + const latest_event = events.at(-1); + if (latest_event !== last_event) { + clearTimeout(final_timeout); + if (timeout) clearTimeout(timeout); + return resolve(Response.json(get_events(), { + status: 200 + })); + } + + timeout = setTimeout(check_for_new_events, 1_000); + })(); + + request.signal.addEventListener('abort', () => { + clearTimeout(final_timeout); + if (timeout) clearTimeout(timeout); + reject(new Error('request aborted')); + }); + + Deno.addSignalListener('SIGINT', () => { + clearTimeout(final_timeout); + if (timeout) clearTimeout(timeout); + return resolve(Response.json(results, { + status: 200 + })); + }); + }); + } + + return Response.json(results, { + status: 200 + }); +} + +export async function POST(req: Request, _meta: Record): Promise { + try { + const now = new Date().toISOString(); + + const body = await req.json(); + const event: EVENT = { + value: '', + ...body, + timestamp: now + }; + + events.push(event); + + return Response.json(event, { + status: 201 + }); + } catch (error) { + return Response.json({ + error: { + message: (error as Error).message ?? 'Unknown Error!', + cause: (error as Error).cause ?? 'unknown' + } + }, { status: 500 }); + } +}