diff --git a/_examples/server.ts b/_examples/server.ts index 6ff96a6..b7f105f 100644 --- a/_examples/server.ts +++ b/_examples/server.ts @@ -6,8 +6,7 @@ import { } from "jsr:@oak/commons@0.7/server_sent_event"; import { auth, immutable, Router } from "../mod.ts"; -import { createHttpError, Status } from "../deps.ts"; -import { assert } from "../util.ts"; +import { assert, createHttpError, Status } from "../deps.ts"; // A mock datastore where we index our books based on id. const BOOK_DB: Record = { diff --git a/context.ts b/context.ts index 85d3e21..c02d41d 100644 --- a/context.ts +++ b/context.ts @@ -13,12 +13,12 @@ import { Status, UserAgent, } from "./deps.ts"; -import { - type Addr, - type Deserializer, - type UpgradeWebSocketOptions, - type WebSocketUpgrade, -} from "./types.ts"; +import type { Deserializer } from "./types.ts"; +import type { + Addr, + UpgradeWebSocketOptions, + WebSocketUpgrade, +} from "./types_internal.ts"; interface ContextOptions> { cookies: SecureCookieMap; diff --git a/deno.json b/deno.json index 69c801c..29c9bf9 100644 --- a/deno.json +++ b/deno.json @@ -5,7 +5,7 @@ ".": "./mod.ts", "./context": "./context.ts", "./handlers": "./handlers.ts", - "./http_server_native": "./http_server_native.ts", + "./http_server_deno": "./http_server_deno.ts", "./router": "./router.ts", "./types": "./types.ts" }, diff --git a/deps.ts b/deps.ts index 0adc9ee..aebebd4 100644 --- a/deps.ts +++ b/deps.ts @@ -1,5 +1,6 @@ // Copyright 2022-2024 the oak authors. All rights reserved. +export { assert } from "jsr:@std/assert@0.218/assert"; export { type Data as SigningData, type Key as SigningKey, diff --git a/http_server_deno.ts b/http_server_deno.ts new file mode 100644 index 0000000..321d668 --- /dev/null +++ b/http_server_deno.ts @@ -0,0 +1,166 @@ +// Copyright 2022-2024 the oak authors. All rights reserved. + +import type { + Addr, + Listener, + RequestEvent as _RequestEvent, + ServeOptions, + Server, + ServeTlsOptions, + UpgradeWebSocketOptions, +} from "./types_internal.ts"; +import { createPromiseWithResolvers } from "./util.ts"; + +// `Deno.serve()` API + +interface ServeHandlerInfo { + remoteAddr: Deno.NetAddr; +} + +type ServeHandler = ( + request: Request, + info: ServeHandlerInfo, +) => Response | Promise; + +interface HttpServer extends AsyncDisposable { + finished: Promise; + ref(): void; + unref(): void; + shutdown(): Promise; +} + +interface ServeInit { + handler: ServeHandler; +} + +const serve: + | (( + options: ServeInit & (ServeOptions | ServeTlsOptions), + ) => HttpServer) + | undefined = "Deno" in globalThis && "serve" in globalThis.Deno + ? globalThis.Deno.serve.bind(globalThis.Deno) + : undefined; + +class RequestEvent implements _RequestEvent { + #addr: Addr; + //deno-lint-ignore no-explicit-any + #reject: (reason?: any) => void; + #request: Request; + #resolve: (value: Response) => void; + #resolved = false; + #response: Promise; + + get addr(): Addr { + return this.#addr; + } + + get request(): Request { + return this.#request; + } + + get response(): Promise { + return this.#response; + } + + constructor(request: Request, { remoteAddr }: ServeHandlerInfo) { + this.#addr = remoteAddr; + this.#request = request; + const { resolve, reject, promise } = createPromiseWithResolvers(); + this.#resolve = resolve; + this.#reject = reject; + this.#response = promise; + } + + //deno-lint-ignore no-explicit-any + error(reason?: any): void { + if (this.#resolved) { + throw new Error("Request already responded to."); + } + this.#resolved = true; + this.#reject(reason); + } + + respond(response: Response): void { + if (this.#resolved) { + throw new Error("Request already responded to."); + } + this.#resolved = true; + this.#resolve(response); + } + + upgrade(options?: UpgradeWebSocketOptions | undefined): WebSocket { + if (this.#resolved) { + throw new Error("Request already responded to."); + } + const { response, socket } = Deno.upgradeWebSocket(this.#request, options); + this.respond(response); + return socket; + } +} + +/** An abstraction for Deno's built in HTTP Server that is used to manage + * HTTP requests in a uniform way. */ +export default class DenoServer implements Server { + #closed = false; + #controller?: ReadableStreamDefaultController; + #httpServer?: HttpServer; + #options: Omit; + #stream?: ReadableStream; + + get closed(): boolean { + return this.#closed; + } + + constructor(options: Omit) { + this.#options = options; + } + + async close(): Promise { + if (this.#closed) { + return; + } + + if (this.#httpServer) { + this.#httpServer.unref(); + await this.#httpServer.shutdown(); + this.#httpServer = undefined; + } + this.#controller?.close(); + this.#closed = true; + } + + listen(): Promise { + if (this.#httpServer) { + throw new Error("Server already listening."); + } + const { onListen, ...options } = this.#options; + const { promise, resolve } = createPromiseWithResolvers(); + this.#stream = new ReadableStream({ + start: (controller) => { + this.#controller = controller; + this.#httpServer = serve?.({ + handler: (req, info) => { + const requestEvent = new RequestEvent(req, info); + controller.enqueue(requestEvent); + return requestEvent.response; + }, + onListen({ hostname, port }) { + if (onListen) { + onListen({ hostname, port }); + } + resolve({ addr: { transport: "tcp", hostname, port } }); + }, + ...options, + }); + }, + }); + return promise; + } + + [Symbol.asyncIterator](): AsyncIterableIterator { + if (!this.#stream) { + throw new TypeError("Server hasn't started listening."); + } + return this.#stream[Symbol.asyncIterator](); + } +} diff --git a/http_server_native.ts b/http_server_native.ts deleted file mode 100644 index b89b496..0000000 --- a/http_server_native.ts +++ /dev/null @@ -1,141 +0,0 @@ -// Copyright 2022-2024 the oak authors. All rights reserved. - -import { - type Addr, - type HttpConn, - type Listener, - type RequestEvent, - type Server, -} from "./types.ts"; -import { assert } from "./util.ts"; - -const serveHttp = - ("serveHttp" in Deno ? Deno.serveHttp.bind(Deno) : undefined) as ( - conn: Deno.Conn, - ) => HttpConn; - -function isListenTlsOptions(value: unknown): value is Deno.ListenTlsOptions { - return typeof value === "object" && value !== null && "certFile" in value && - "keyFile" in value && "port" in value; -} - -/** An abstraction for Deno's built in HTTP Server that is used to manage - * HTTP requests in a uniform way. */ -export class NativeHttpServer implements Server { - #closed = false; - #errorTarget: EventTarget; - #httpConnections = new Set(); - #listener?: Deno.Listener; - #options: Deno.ListenOptions | Deno.ListenTlsOptions; - - #track(httpConn: HttpConn): void { - this.#httpConnections.add(httpConn); - } - - #untrack(httpConn: HttpConn): void { - this.#httpConnections.delete(httpConn); - } - - get closed(): boolean { - return this.#closed; - } - - constructor( - errorTarget: EventTarget, - options: Deno.ListenOptions | Deno.ListenTlsOptions, - ) { - this.#errorTarget = errorTarget; - this.#options = options; - } - - close(): void { - this.#closed = true; - - if (this.#listener) { - this.#listener.close(); - this.#listener = undefined; - } - - for (const httpConn of this.#httpConnections) { - try { - httpConn.close(); - } catch (error) { - if (!(error instanceof Deno.errors.BadResource)) { - throw error; - } - } - } - - this.#httpConnections.clear(); - } - - listen(): Listener { - return (this.#listener = isListenTlsOptions(this.#options) - ? Deno.listenTls(this.#options) - : Deno.listen(this.#options)) as Listener; - } - - [Symbol.asyncIterator](): AsyncIterableIterator<[RequestEvent, Addr]> { - const start: ReadableStreamDefaultControllerCallback< - [RequestEvent, Addr] - > = ( - controller, - ) => { - // deno-lint-ignore no-this-alias - const server = this; - async function serve(conn: Deno.Conn) { - const httpConn = serveHttp(conn); - server.#track(httpConn); - - while (true) { - try { - const requestEvent = await httpConn.nextRequest(); - if (requestEvent === null) { - server.#untrack(httpConn); - return; - } - - controller.enqueue([requestEvent, conn.remoteAddr as Addr]); - } catch (error) { - server.#errorTarget.dispatchEvent( - new ErrorEvent("error", { error }), - ); - } - - if (server.closed) { - server.#untrack(httpConn); - httpConn.close(); - controller.close(); - } - } - } - - const listener = this.#listener; - - async function accept() { - assert(listener); - while (true) { - try { - const conn = await listener.accept(); - serve(conn); - } catch (error) { - if (!server.closed) { - server.#errorTarget.dispatchEvent( - new ErrorEvent("error", { error }), - ); - } - } - if (server.closed) { - controller.close(); - return; - } - } - } - - accept(); - }; - - const stream = new ReadableStream<[RequestEvent, Addr]>({ start }); - return stream[Symbol.asyncIterator](); - } -} diff --git a/mod.ts b/mod.ts index c127141..5e26488 100644 --- a/mod.ts +++ b/mod.ts @@ -28,7 +28,7 @@ export { type Context } from "./context.ts"; export { type SigningData, type SigningKey } from "./deps.ts"; export { auth, immutable } from "./handlers.ts"; -export { NativeHttpServer } from "./http_server_native.ts"; +export { default as DenoServer } from "./http_server_deno.ts"; export { HandledEvent, NotFoundEvent, diff --git a/router.test.ts b/router.test.ts index 36f0a0a..018028d 100644 --- a/router.test.ts +++ b/router.test.ts @@ -1,9 +1,8 @@ // Copyright 2022-2024 the oak authors. All rights reserved. import { Status } from "./deps.ts"; -import { assertEquals } from "./deps_test.ts"; +import { assert, assertEquals } from "./deps_test.ts"; import { Router, RouterRequestEvent } from "./router.ts"; -import { assert } from "./util.ts"; Deno.test({ name: "Router - basic usage", @@ -99,13 +98,12 @@ Deno.test({ ); return promise; }); - router.addEventListener("listen", ({ port, hostname }) => { + router.addEventListener("listen", ({ hostname, port }) => { rp.push(fetch(`http://${hostname}:${port}/`).then((r) => r.text())); rp.push(fetch(`http://${hostname}:${port}/`).then((r) => r.text())); setTimeout(() => abortController.abort(), 100); }); await router.listen({ signal }); - // deno-lint-ignore no-explicit-any - return Promise.all(rp) as Promise; + return Promise.all(rp).then(() => {}); }, }); diff --git a/router.ts b/router.ts index 521f628..e3ea820 100644 --- a/router.ts +++ b/router.ts @@ -26,6 +26,7 @@ import { Context } from "./context.ts"; import { + assert, createHttpError, isClientErrorStatus, isErrorStatus, @@ -37,22 +38,21 @@ import { SecureCookieMap, Status, } from "./deps.ts"; -import { NativeHttpServer } from "./http_server_native.ts"; +import Server from "./http_server_deno.ts"; +import type { Deserializer, KeyRing, Serializer } from "./types.ts"; import type { Addr, - Deserializer, Destroyable, - KeyRing, Listener, RequestEvent, - Serializer, + Server as _Server, ServerConstructor, -} from "./types.ts"; +} from "./types_internal.ts"; import { - assert, CONTENT_TYPE_HTML, CONTENT_TYPE_JSON, CONTENT_TYPE_TEXT, + createPromiseWithResolvers, isBodyInit, isHtmlLike, isJsonLike, @@ -900,15 +900,20 @@ export class Router extends EventTarget { return response; } - async #handle(requestEvent: RequestEvent, addr: Addr): Promise { + async #handle(requestEvent: RequestEvent): Promise { const uid = this.#uid++; performance.mark(`${HANDLE_START} ${uid}`); const { promise, resolve } = Promise.withResolvers(); this.#handling.add(promise); - requestEvent.respondWith(promise).catch((error) => - this.#error(requestEvent.request, error, false) + promise.then((response) => { + requestEvent.respond(response); + this.#handling.delete(promise); + }).catch( + (error) => { + this.#error(requestEvent.request, error, false); + }, ); - const { request } = requestEvent; + const { request, addr } = requestEvent; const responseHeaders = new Headers(); let cookies: SecureCookieMap; try { @@ -1442,15 +1447,20 @@ export class Router extends EventTarget { * This is intended to be used when the router isn't managing opening the * server and listening for requests. */ handle(request: Request, init: RouterHandleInit): Promise { - const { promise, resolve } = Promise.withResolvers(); + const { promise, resolve, reject } = createPromiseWithResolvers(); this.#secure = init.secure ?? false; this.#handle({ + get addr() { + return init.addr; + }, request, - respondWith(response: Response | Promise): Promise { + respond(response) { resolve(response); - return Promise.resolve(); }, - }, init.addr); + error(reason) { + reject(reason); + }, + }); return promise; } @@ -1463,18 +1473,18 @@ export class Router extends EventTarget { async listen(options: ListenOptions = { port: 0 }): Promise { const { secure = false, - server: Server = NativeHttpServer, + server: _Server = Server, signal, ...listenOptions } = options; if (!("port" in listenOptions)) { listenOptions.port = 0; } - const server = new Server(this, listenOptions as Deno.ListenOptions); + const server = new _Server(listenOptions); this.#state = { closed: false, closing: false, - server: Server, + server: _Server, }; this.#secure = secure; if (signal) { @@ -1482,6 +1492,7 @@ export class Router extends EventTarget { assert(this.#state, "router state should exist"); this.#state.closing = true; await Promise.all(this.#handling); + this.#handling.clear(); await server.close(); this.#state.closed = true; }); @@ -1497,8 +1508,8 @@ export class Router extends EventTarget { }), ); try { - for await (const [requestEvent, addr] of server) { - this.#handle(requestEvent, addr); + for await (const requestEvent of server) { + this.#handle(requestEvent); } await Promise.all(this.#handling); } catch (error) { diff --git a/types.ts b/types.ts index 371b6a4..32aee85 100644 --- a/types.ts +++ b/types.ts @@ -67,94 +67,3 @@ export interface Serializer> { request: Request, ): Response | Promise; } - -export interface RequestEvent { - readonly request: Request; - respondWith(r: Response | Promise): Promise; -} - -export interface Addr { - transport: "tcp" | "udp"; - hostname: string; - port: number; -} - -export interface Listener { - addr: Addr; -} - -export interface Server extends AsyncIterable<[RequestEvent, Addr]> { - close(): Promise | void; - listen(): Promise | Listener; - [Symbol.asyncIterator](): AsyncIterableIterator<[RequestEvent, Addr]>; -} - -export interface ListenOptions { - port: number; - hostname?: string; -} - -export interface ListenTlsOptions extends ListenOptions { - key?: string; - cert?: string; -} - -export interface ServerConstructor { - new ( - errorTarget: EventTarget, - options: ListenOptions | ListenTlsOptions, - ): Server; - prototype: Server; -} - -export type ServeHandler = ( - request: Request, -) => Response | Promise | void | Promise; - -export interface ServeInit { - port?: number; - hostname?: string; - signal?: AbortSignal; - onError?: (error: unknown) => Response | Promise; - onListen?: (params: { hostname: string; port: number }) => void; -} - -export interface ServeTlsInit extends ServeInit { - cert: string; - key: string; -} - -export interface Destroyable { - destroy(): void; -} - -export interface HttpConn extends AsyncIterable { - readonly rid: number; - nextRequest(): Promise; - close(): void; -} - -export interface UpgradeWebSocketOptions { - /** Sets the `.protocol` property on the client side web socket to the - * value provided here, which should be one of the strings specified in the - * `protocols` parameter when requesting the web socket. This is intended - * for clients and servers to specify sub-protocols to use to communicate to - * each other. */ - protocol?: string; - /** If the client does not respond to this frame with a - * `pong` within the timeout specified, the connection is deemed - * unhealthy and is closed. The `close` and `error` event will be emitted. - * - * The default is 120 seconds. Set to `0` to disable timeouts. */ - idleTimeout?: number; -} - -export interface WebSocketUpgrade { - /** The response object that represents the HTTP response to the client, - * which should be used to the {@linkcode RequestEvent} `.respondWith()` for - * the upgrade to be successful. */ - response: Response; - /** The {@linkcode WebSocket} interface to communicate to the client via a - * web socket. */ - socket: WebSocket; -} diff --git a/types_internal.ts b/types_internal.ts new file mode 100644 index 0000000..8179e16 --- /dev/null +++ b/types_internal.ts @@ -0,0 +1,72 @@ +export interface RequestEvent { + readonly addr: Addr; + readonly request: Request; + // deno-lint-ignore no-explicit-any + error(reason?: any): void; + respond(response: Response): void; + upgrade?(options?: UpgradeWebSocketOptions): WebSocket; +} + +export interface Addr { + transport: "tcp" | "udp"; + hostname: string; + port: number; +} + +export interface Listener { + addr: Addr; +} + +export interface Server extends AsyncIterable { + close(): Promise | void; + listen(): Promise | Listener; + [Symbol.asyncIterator](): AsyncIterableIterator; +} + +export interface ServeOptions { + port?: number; + hostname?: string; + signal?: AbortSignal; + reusePort?: boolean; + onError?: (error: unknown) => Response | Promise; + onListen?: (params: { hostname: string; port: number }) => void; +} + +export interface ServeTlsOptions extends ServeOptions { + cert: string; + key: string; +} + +export interface ServerConstructor { + new (options: Omit): Server; + prototype: Server; +} + +export interface Destroyable { + destroy(): void; +} + +export interface UpgradeWebSocketOptions { + /** Sets the `.protocol` property on the client side web socket to the + * value provided here, which should be one of the strings specified in the + * `protocols` parameter when requesting the web socket. This is intended + * for clients and servers to specify sub-protocols to use to communicate to + * each other. */ + protocol?: string; + /** If the client does not respond to this frame with a + * `pong` within the timeout specified, the connection is deemed + * unhealthy and is closed. The `close` and `error` event will be emitted. + * + * The default is 120 seconds. Set to `0` to disable timeouts. */ + idleTimeout?: number; +} + +export interface WebSocketUpgrade { + /** The response object that represents the HTTP response to the client, + * which should be used to the {@linkcode RequestEvent} `.respondWith()` for + * the upgrade to be successful. */ + response: Response; + /** The {@linkcode WebSocket} interface to communicate to the client via a + * web socket. */ + socket: WebSocket; +} diff --git a/util.ts b/util.ts index e62ca2d..60cd428 100644 --- a/util.ts +++ b/util.ts @@ -12,15 +12,6 @@ export const CONTENT_TYPE_HTML = contentType("html")!; export const CONTENT_TYPE_JSON = contentType("json")!; export const CONTENT_TYPE_TEXT = contentType("text/plain")!; -export function assert( - cond: unknown, - message = "Assertion Error", -): asserts cond { - if (!cond) { - throw new Error(message); - } -} - /** A type guard which determines if the value can be used as `BodyInit` for * creating a body of a `Response`. */ export function isBodyInit(value: unknown): value is BodyInit { @@ -40,6 +31,30 @@ export function isJsonLike(value: string): boolean { return /^\s*["{[]/.test(value); } +const hasPromiseWithResolvers = "withResolvers" in Promise; + +/** Offloads to the native `Promise.withResolvers` when available. + * + * Currently Node.js does not support it, while Deno and Bun do. + */ +export function createPromiseWithResolvers(): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + // deno-lint-ignore no-explicit-any + reject: (reason?: any) => void; +} { + if (hasPromiseWithResolvers) { + return Promise.withResolvers(); + } + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve: resolve!, reject: reject! }; +} + /** Generate a `Response` based on the original `Request` and an `HttpError`. * It will ensure negotiation of the content type and will provide the stack * trace in errors that are marked as expose-able. */