diff --git a/_examples/server.ts b/_examples/server.ts index b7f105f..d101b27 100644 --- a/_examples/server.ts +++ b/_examples/server.ts @@ -3,7 +3,7 @@ import { ServerSentEvent, ServerSentEventStreamTarget, -} from "jsr:@oak/commons@0.7/server_sent_event"; +} from "jsr:@oak/commons@0.10/server_sent_event"; import { auth, immutable, Router } from "../mod.ts"; import { assert, createHttpError, Status } from "../deps.ts"; diff --git a/deno.json b/deno.json index aec4def..c89599b 100644 --- a/deno.json +++ b/deno.json @@ -1,12 +1,13 @@ { "name": "@oak/acorn", - "version": "0.5.1", + "version": "0.6.0-alpha.1", "exports": { ".": "./mod.ts", "./context": "./context.ts", "./handlers": "./handlers.ts", "./http_server_bun": "./http_server_bun.ts", "./http_server_deno": "./http_server_deno.ts", + "./http_server_node": "./http_server_node.ts", "./router": "./router.ts", "./types": "./types.ts" }, diff --git a/deps.ts b/deps.ts index aebebd4..9cc3ac0 100644 --- a/deps.ts +++ b/deps.ts @@ -1,21 +1,21 @@ // Copyright 2022-2024 the oak authors. All rights reserved. -export { assert } from "jsr:@std/assert@0.218/assert"; +export { assert } from "jsr:@std/assert@0.226/assert"; export { type Data as SigningData, type Key as SigningKey, -} from "jsr:@std/crypto@0.218/unstable_keystack"; -export { accepts } from "jsr:@std/http@0.218/negotiation"; -export { UserAgent } from "jsr:@std/http@0.218/user_agent"; -export { contentType } from "jsr:@std/media-types@0.218/content_type"; +} from "jsr:@std/crypto@0.224/unstable-keystack"; +export { accepts } from "jsr:@std/http@0.224/negotiation"; +export { UserAgent } from "jsr:@std/http@0.224/user-agent"; +export { contentType } from "jsr:@std/media-types@0.224/content-type"; -export { SecureCookieMap } from "jsr:@oak/commons@0.7/cookie_map"; +export { SecureCookieMap } from "jsr:@oak/commons@0.10/cookie_map"; export { createHttpError, errors, type HttpError, isHttpError, -} from "jsr:@oak/commons@0.7/http_errors"; +} from "jsr:@oak/commons@0.10/http_errors"; export { isClientErrorStatus, isErrorStatus, @@ -25,4 +25,4 @@ export { isSuccessfulStatus, Status, STATUS_TEXT, -} from "jsr:@oak/commons@0.7/status"; +} from "jsr:@oak/commons@0.10/status"; diff --git a/deps_test.ts b/deps_test.ts index 9e7bea9..224967e 100644 --- a/deps_test.ts +++ b/deps_test.ts @@ -1,5 +1,5 @@ // Copyright 2022-2024 the oak authors. All rights reserved. -export { assert } from "jsr:@std/assert@0.218/assert"; -export { assertEquals } from "jsr:@std/assert@0.218/assert_equals"; -export { assertRejects } from "jsr:@std/assert@0.218/assert_rejects"; +export { assert } from "jsr:@std/assert@0.226/assert"; +export { assertEquals } from "jsr:@std/assert@0.226/assert-equals"; +export { assertRejects } from "jsr:@std/assert@0.226/assert-rejects"; diff --git a/handlers.ts b/handlers.ts index 3dd17a9..9bb6f81 100644 --- a/handlers.ts +++ b/handlers.ts @@ -5,13 +5,13 @@ * @module */ -import { type Context } from "./context.ts"; +import type { Context } from "./context.ts"; import { createHttpError, Status, STATUS_TEXT } from "./deps.ts"; -import { - type RouteHandler, - type RouteOptions, - type RouteOptionsWithHandler, - type RouteParameters, +import type { + RouteHandler, + RouteOptions, + RouteOptionsWithHandler, + RouteParameters, } from "./router.ts"; import { CONTENT_TYPE_HTML, diff --git a/http_server_bun.ts b/http_server_bun.ts index c7a23e3..3dea496 100644 --- a/http_server_bun.ts +++ b/http_server_bun.ts @@ -138,6 +138,8 @@ class RequestEvent implements _RequestEvent { } } +/** An abstraction for Bun's built in HTTP Server that is used to manage + * HTTP requests in a uniform way. */ export default class Steamer implements Server { #controller?: ReadableStreamDefaultController; #options: Omit; @@ -150,8 +152,10 @@ export default class Steamer implements Server { close(): void | Promise { this.#controller?.close(); + this.#controller = undefined; this.#server?.stop(); this.#server = undefined; + this.#stream = undefined; } listen(): Listener | Promise { diff --git a/http_server_node.test.ts b/http_server_node.test.ts new file mode 100644 index 0000000..97b6770 --- /dev/null +++ b/http_server_node.test.ts @@ -0,0 +1,33 @@ +// Copyright 2018-2024 the oak authors. All rights reserved. MIT license. + +import { assertEquals } from "./deps_test.ts"; + +import Server from "./http_server_node.ts"; + +Deno.test({ + name: "node server can listen", + async fn() { + const server = new Server({ port: 8080 }); + const listener = await server.listen(); + assertEquals(listener, { + addr: { hostname: "localhost", port: 8080, transport: "tcp" }, + }); + await server.close(); + }, +}); + +Deno.test({ + name: "node server can process requests", + async fn() { + const server = new Server({ port: 8080, hostname: "localhost" }); + await server.listen(); + const promise = fetch("http://localhost:8080/"); + for await (const req of server) { + req.respond(new Response("hello world")); + break; + } + const res = await promise; + assertEquals(await res.text(), "hello world"); + await server.close(); + }, +}); diff --git a/http_server_node.ts b/http_server_node.ts new file mode 100644 index 0000000..ff74955 --- /dev/null +++ b/http_server_node.ts @@ -0,0 +1,229 @@ +// Copyright 2022-2024 the oak authors. All rights reserved. + +import type { + Addr, + Listener, + RequestEvent as _RequestEvent, + ServeOptions, + Server, + ServeTlsOptions, +} from "./types_internal.ts"; +import type { + IncomingMessage, + Server as NodeServer, + ServerResponse, +} from "node:http"; +import type { AddressInfo } from "node:net"; +import { createPromiseWithResolvers } from "./util.ts"; + +class RequestEvent implements _RequestEvent { + #incomingMessage: IncomingMessage; + #promise: Promise; + // deno-lint-ignore no-explicit-any + #reject: (reason?: any) => void; + #request: Request; + #resolve: (value: Response | PromiseLike) => void; + #resolved = false; + #serverResponse: ServerResponse; + + get addr(): Addr { + // deno-lint-ignore no-explicit-any + const value: any = this.#incomingMessage.socket.address(); + return { + transport: "tcp", + hostname: value?.address ?? "", + port: value?.port ?? 0, + }; + } + + get request(): Request { + return this.#request; + } + + get response(): Promise { + return this.#promise; + } + + constructor( + req: IncomingMessage, + res: ServerResponse, + host: string, + address: string | AddressInfo | null, + ) { + this.#incomingMessage = req; + this.#serverResponse = res; + const { resolve, reject, promise } = createPromiseWithResolvers(); + this.#resolve = resolve; + this.#reject = reject; + this.#promise = promise; + const headers = req.headers as Record; + const method = req.method ?? "GET"; + const url = new URL( + req.url ?? "/", + address + ? typeof address === "string" + ? `http://${host}` + : `http://${host}:${address.port}/` + : `http://${host}/`, + ); + const body = (method === "GET" || method === "HEAD") + ? null + : new ReadableStream({ + start: (controller) => { + req.on("data", (chunk) => controller.enqueue(chunk)); + req.on("error", (err) => controller.error(err)); + req.on("end", () => { + try { + controller.close(); + } catch { + // just swallow here + } + }); + }, + }); + this.#incomingMessage; + this.#request = new Request(url, { body, headers }); + } + + // deno-lint-ignore no-explicit-any + error(reason?: any): void { + this.#reject(reason); + } + + async respond(response: Response | PromiseLike): Promise { + if (this.#resolved) { + throw new Error("Request already responded to."); + } + this.#resolved = true; + const res = await response; + const headers = new Map(); + for (const [key, value] of res.headers) { + if (!headers.has(key)) { + headers.set(key, []); + } + headers.get(key)!.push(value); + } + for (const [key, value] of headers) { + this.#serverResponse.setHeader(key, value); + } + if (res.body) { + for await (const chunk of res.body) { + const { promise, resolve, reject } = createPromiseWithResolvers(); + this.#serverResponse.write(chunk, (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + await promise; + } + } + const { promise, resolve } = createPromiseWithResolvers(); + this.#serverResponse.end(resolve); + await promise; + this.#resolve(response); + } +} + +/** An abstraction for Node.js's built in HTTP Server that is used to manage + * HTTP requests in a uniform way. */ +export default class HttpServer implements Server { + #abortController = new AbortController(); + #address: string | AddressInfo | null = null; + #controller?: ReadableStreamDefaultController; + #host: string; + #port: number; + #server?: NodeServer; + #stream?: ReadableStream; + + constructor(options: ServeOptions | ServeTlsOptions) { + this.#host = options.hostname ?? "localhost"; + this.#port = options.port ?? 80; + } + + close(): void | Promise { + this.#abortController.abort(); + try { + this.#controller?.close(); + } catch { + // just swallowing here + } + this.#controller = undefined; + this.#server?.close(); + this.#server?.unref(); + this.#server = undefined; + this.#stream = undefined; + } + + async listen(): Promise { + if (!("Request" in globalThis) || !("Response" in globalThis)) { + const { Request, Response } = await import("npm:undici@^6.18"); + Object.defineProperties(globalThis, { + "Request": { + value: Request, + writable: true, + enumerable: false, + configurable: true, + }, + "Response": { + value: Response, + writable: true, + enumerable: false, + configurable: true, + }, + }); + } + if (!("ReadableStream" in globalThis)) { + const { ReadableStream } = await import("node:stream/web"); + Object.defineProperty(globalThis, "ReadableStream", { + value: ReadableStream, + writable: true, + enumerable: false, + configurable: true, + }); + } + const { createServer } = await import("node:http"); + this.#stream = new ReadableStream({ + start: (controller) => { + this.#controller = controller; + const server = this.#server = createServer((req, res) => { + controller.enqueue( + new RequestEvent(req, res, this.#host, this.#address), + ); + }); + this.#abortController.signal.addEventListener( + "abort", + () => { + try { + controller.close(); + } catch { + // just swallow here + } + }, + { once: true }, + ); + server.listen({ + port: this.#port, + host: this.#host, + signal: this.#abortController.signal, + }); + this.#address = server.address(); + }, + }); + return { + addr: { + port: this.#port, + hostname: this.#host, + transport: "tcp", + }, + }; + } + + [Symbol.asyncIterator](): AsyncIterableIterator { + if (!this.#stream) { + throw new TypeError("Serer hasn't started listening."); + } + return this.#stream[Symbol.asyncIterator](); + } +} diff --git a/router.ts b/router.ts index 16ef4e4..91fd66c 100644 --- a/router.ts +++ b/router.ts @@ -56,6 +56,7 @@ import { isBun, isHtmlLike, isJsonLike, + isNode, responseFromHttpError, } from "./util.ts"; @@ -906,7 +907,7 @@ export class Router extends EventTarget { performance.mark(`${HANDLE_START} ${uid}`); const { promise, resolve } = Promise.withResolvers(); this.#handling.add(promise); - requestEvent.respond(promise); + await requestEvent.respond(promise); promise.then(() => this.#handling.delete(promise)).catch( (error) => { this.#error(requestEvent.request, error, false); @@ -1470,6 +1471,8 @@ export class Router extends EventTarget { server: _Server = ServerCtor ?? (ServerCtor = isBun() ? (await import("./http_server_bun.ts")).default + : isNode() + ? (await import("./http_server_node.ts")).default : (await import("./http_server_deno.ts")).default), signal, ...listenOptions diff --git a/routing.bench.ts b/routing.bench.ts index 16d3c2e..656ada7 100644 --- a/routing.bench.ts +++ b/routing.bench.ts @@ -1,22 +1,34 @@ import { pathToRegexp } from "npm:path-to-regexp@6.2.1"; +import { URLPattern as URLPatternPolyfill } from "npm:urlpattern-polyfill@10.0.0"; -const urlPattern = new URLPattern("/", "http://localhost/"); +const urlPatternPolyfill = new URLPatternPolyfill("/:id", "http://localhost/"); + +Deno.bench({ + name: "URLPattern polyfill", + fn() { + if (urlPatternPolyfill.exec("http://localhost/1234")) { + true; + } + }, +}); + +const urlPattern = new URLPattern("/:id", "http://localhost/"); Deno.bench({ name: "URLPattern", fn() { - if (urlPattern.test("http://localhost/")) { + if (urlPattern.exec("http://localhost/1234")) { true; } }, }); -const regexp = pathToRegexp("/"); +const regexp = pathToRegexp("/:id"); Deno.bench({ name: "pathToRegexp", fn() { - if (regexp.test("/")) { + if (regexp.exec("/1234")) { true; } }, diff --git a/types_internal.ts b/types_internal.ts index 8ac62d8..97a9a52 100644 --- a/types_internal.ts +++ b/types_internal.ts @@ -3,7 +3,7 @@ export interface RequestEvent { readonly request: Request; // deno-lint-ignore no-explicit-any error(reason?: any): void; - respond(response: Response | PromiseLike): void; + respond(response: Response | PromiseLike): void | Promise; upgrade?(options?: UpgradeWebSocketOptions): WebSocket; } diff --git a/util.ts b/util.ts index db8ff92..057e720 100644 --- a/util.ts +++ b/util.ts @@ -36,6 +36,12 @@ export function isBun(): boolean { return "Bun" in globalThis; } +/** Determines if the runtime is Node.js or not. */ +export function isNode(): boolean { + return "process" in globalThis && "global" in globalThis && + !("Bun" in globalThis) && !("WebSocketPair" in globalThis); +} + const hasPromiseWithResolvers = "withResolvers" in Promise; /** Offloads to the native `Promise.withResolvers` when available.