Skip to content

Commit

Permalink
feat: support Node.js
Browse files Browse the repository at this point in the history
  • Loading branch information
kitsonk committed Jun 6, 2024
1 parent ee5b869 commit 3088469
Show file tree
Hide file tree
Showing 12 changed files with 313 additions and 25 deletions.
2 changes: 1 addition & 1 deletion _examples/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
3 changes: 2 additions & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -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"
},
Expand Down
16 changes: 8 additions & 8 deletions deps.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -25,4 +25,4 @@ export {
isSuccessfulStatus,
Status,
STATUS_TEXT,
} from "jsr:@oak/commons@0.7/status";
} from "jsr:@oak/commons@0.10/status";
6 changes: 3 additions & 3 deletions deps_test.ts
Original file line number Diff line number Diff line change
@@ -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";
12 changes: 6 additions & 6 deletions handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions http_server_bun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RequestEvent>;
#options: Omit<ServeOptions | ServeTlsOptions, "signal">;
Expand All @@ -150,8 +152,10 @@ export default class Steamer implements Server {

close(): void | Promise<void> {
this.#controller?.close();
this.#controller = undefined;
this.#server?.stop();
this.#server = undefined;
this.#stream = undefined;
}

listen(): Listener | Promise<Listener> {
Expand Down
33 changes: 33 additions & 0 deletions http_server_node.test.ts
Original file line number Diff line number Diff line change
@@ -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();
},
});
229 changes: 229 additions & 0 deletions http_server_node.ts
Original file line number Diff line number Diff line change
@@ -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<Response>;
// deno-lint-ignore no-explicit-any
#reject: (reason?: any) => void;
#request: Request;
#resolve: (value: Response | PromiseLike<Response>) => void;
#resolved = false;
#serverResponse: ServerResponse<IncomingMessage>;

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<Response> {
return this.#promise;
}

constructor(
req: IncomingMessage,
res: ServerResponse<IncomingMessage>,
host: string,
address: string | AddressInfo | null,
) {
this.#incomingMessage = req;
this.#serverResponse = res;
const { resolve, reject, promise } = createPromiseWithResolvers<Response>();
this.#resolve = resolve;
this.#reject = reject;
this.#promise = promise;
const headers = req.headers as Record<string, string>;
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<Uint8Array>({
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<Response>): Promise<void> {
if (this.#resolved) {
throw new Error("Request already responded to.");
}
this.#resolved = true;
const res = await response;
const headers = new Map<string, string[]>();
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<void>();
this.#serverResponse.write(chunk, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
await promise;
}
}
const { promise, resolve } = createPromiseWithResolvers<void>();
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<RequestEvent>;
#host: string;
#port: number;
#server?: NodeServer;
#stream?: ReadableStream<RequestEvent>;

constructor(options: ServeOptions | ServeTlsOptions) {
this.#host = options.hostname ?? "localhost";
this.#port = options.port ?? 80;
}

close(): void | Promise<void> {
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<Listener> {
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<RequestEvent>({
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<RequestEvent> {
if (!this.#stream) {
throw new TypeError("Serer hasn't started listening.");
}
return this.#stream[Symbol.asyncIterator]();
}
}
5 changes: 4 additions & 1 deletion router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
isBun,
isHtmlLike,
isJsonLike,
isNode,
responseFromHttpError,
} from "./util.ts";

Expand Down Expand Up @@ -906,7 +907,7 @@ export class Router extends EventTarget {
performance.mark(`${HANDLE_START} ${uid}`);
const { promise, resolve } = Promise.withResolvers<Response>();
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);
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit 3088469

Please sign in to comment.