diff --git a/.github/workflows/oak-ci.yml b/.github/workflows/oak-ci.yml index 15d45e46..32ed255e 100644 --- a/.github/workflows/oak-ci.yml +++ b/.github/workflows/oak-ci.yml @@ -26,19 +26,20 @@ jobs: run: deno lint - name: generate bundle - run: deno bundle mod.ts oak.bundle.js + run: deno bundle --import-map import-map.json mod.ts oak.bundle.js - name: run tests - run: deno test --allow-read --allow-write --allow-net --jobs 4 + run: deno test --import-map import-map.json --allow-read --allow-write --allow-net --jobs 4 --ignore=npm - name: run tests no check - run: deno test --allow-read --allow-write --allow-net --no-check --jobs 4 + run: deno test --import-map import-map.json --allow-read --allow-write --allow-net --no-check --jobs 4 --ignore=npm - name: run tests unstable - run: deno test --coverage=./cov --allow-read --allow-write --allow-net --unstable --jobs 4 + run: deno test --coverage=./cov --import-map import-map.json --allow-read --allow-write --allow-net --unstable --jobs 4 --ignore=npm - - name: run tests using dom libs - run: deno test --unstable --allow-read --allow-write --allow-net --config dom.tsconfig.json --jobs 4 + - name: test build for Node.js + if: matrix.os == 'ubuntu-latest' + run: deno run --allow-read --allow-write --allow-net --allow-env --allow-run _build_npm.ts - name: generate lcov if: matrix.os == 'ubuntu-latest' diff --git a/.gitignore b/.gitignore index c4c9db5b..59f34696 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ !.vscode oak.bundle.js cov.lcov -cov/ \ No newline at end of file +cov/ +npm/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index ae4dcf8a..49dc338f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,7 @@ "deno.enable": true, "deno.unstable": true, "deno.lint": true, + "deno.importMap": "./import-map.json", "deno.codeLens.testArgs": [ "--allow-net", "--allow-read", diff --git a/README.md b/README.md index 0bf3921b..714b7402 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,9 @@ ![Custom badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fdeno-visualizer.danopia.net%2Fshields%2Fupdates%2Fx%2Foak%2Fmod.ts) [![Custom badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fdeno-visualizer.danopia.net%2Fshields%2Flatest-version%2Fx%2Foak%2Fmod.ts)](https://doc.deno.land/https/deno.land/x/oak/mod.ts) -A middleware framework for Deno's native HTTP server and -[Deno Deploy](https://deno.com/deploy). It also includes a middleware router. +A middleware framework for Deno's native HTTP server, +[Deno Deploy](https://deno.com/deploy) and Node.js 16.5 and later. It also +includes a middleware router. This middleware framework is inspired by [Koa](https://github.com/koajs/koa/) and middleware router inspired by @@ -25,13 +26,13 @@ Also, check out our [FAQs](https://oakserver.github.io/oak/FAQ) and the [awesome-oak](https://oakserver.github.io/awesome-oak/) site of community resources. -> ⚠️ _Warning_ The examples in this README pull from `main`, which may not make -> sense to do when you are looking to actually deploy a workload. You would want -> to "pin" to a particular version which is compatible with the version of Deno -> you are using and has a fixed set of APIs you would expect. -> `https://deno.land/x/` supports using git tags in the URL to direct you at a -> particular version. So to use version 3.0.0 of oak, you would want to import -> `https://deno.land/x/oak@v3.0.0/mod.ts`. +> ⚠️ _Warning_ The examples in this README pull from `main` and are designed for +> Deno CLI or Deno Deploy, which may not make sense to do when you are looking +> to actually deploy a workload. You would want to "pin" to a particular version +> which is compatible with the version of Deno you are using and has a fixed set +> of APIs you would expect. `https://deno.land/x/` supports using git tags in +> the URL to direct you at a particular version. So to use version 3.0.0 of oak, +> you would want to import `https://deno.land/x/oak@v3.0.0/mod.ts`. ## Application, middleware, and context @@ -848,6 +849,38 @@ testing oak middleware you might create. See the [Testing with oak](https://oakserver.github.io/oak/testing) for more information. +## Node.js + +As of oak v10.3, oak is experimentally supported on Node.js 16.5 and later. The +package is available on npm as `@oakserver/oak`. The package exports are the +same as the exports of the `mod.ts` when using under Deno and the package +auto-detects it is running under Node.js. + +A basic example: + +**main.mjs** + +```js +import { Application } from "@oakserver/oak"; + +const app = new Application(); + +app.use((ctx) => { + ctx.response.body = "Hello from oak under Node.js"; +}); + +app.listen({ port: 8000 }); +``` + +There are a few notes about the support: + +- The package is only available as an ESM distribution. This is because there + are a couple places where the framework takes advantage of top level await, + which can only be supported in ES modules under Node.js. +- Currently only HTTP/1.1 support is available. There are plans to add HTTP/2. +- Web Socket upgrades are not currently supported. This is planned for the + future. Trying to upgrade to a web socket will cause an error to be thrown. + --- There are several modules that are directly adapted from other modules. They diff --git a/_build_npm.ts b/_build_npm.ts new file mode 100755 index 00000000..76474dbe --- /dev/null +++ b/_build_npm.ts @@ -0,0 +1,67 @@ +#!/usr/bin/env -S deno run --allow-read --allow-write --allow-net --allow-env --allow-run +// Copyright 2018-2022 the oak authors. All rights reserved. MIT license. + +/** + * This is the build script for building the oak framework into a Node.js + * compatible npm package. + * + * @module + */ + +import { build, emptyDir } from "https://deno.land/x/dnt@0.19.0/mod.ts"; +import { copy } from "https://deno.land/std@0.126.0/fs/copy.ts"; + +async function start() { + await emptyDir("./npm"); + await copy("fixtures", "npm/esm/fixtures", { overwrite: true }); + + await build({ + entryPoints: ["./mod.ts"], + outDir: "./npm", + shims: { + blob: true, + crypto: true, + deno: true, + undici: true, + custom: [{ + package: { + name: "stream/web", + }, + globalNames: ["ReadableStream", "TransformStream"], + }], + }, + scriptModule: false, + test: true, + compilerOptions: { + importHelpers: true, + target: "ES2021", + }, + package: { + name: "@oakserver/oak", + version: Deno.args[0], + description: "A middleware framework for handling HTTP requests", + license: "MIT", + engines: { + node: ">=16.5.0 <18", + }, + repository: { + type: "git", + url: "git+https://github.com/oakserver/oak.git", + }, + bugs: { + url: "https://github.com/oakserver/oak/issues", + }, + dependencies: { + "tslib": "~2.3.1", + }, + devDependencies: { + "@types/node": "^16", + }, + }, + }); + + await Deno.copyFile("LICENSE", "npm/LICENSE"); + await Deno.copyFile("README.md", "npm/README.md"); +} + +start(); diff --git a/application.ts b/application.ts index 48d3c8a6..b25081c9 100644 --- a/application.ts +++ b/application.ts @@ -6,8 +6,14 @@ import { HttpServerNative, NativeRequest } from "./http_server_native.ts"; import { KeyStack } from "./keyStack.ts"; import { compose, Middleware } from "./middleware.ts"; import { cloneState } from "./structured_clone.ts"; -import { Key, Server, ServerConstructor } from "./types.d.ts"; -import { assert, isConn } from "./util.ts"; +import { + Key, + Listener, + Server, + ServerConstructor, + ServerRequest, +} from "./types.d.ts"; +import { assert, isConn, isNode } from "./util.ts"; export interface ListenOptionsBase extends Deno.ListenOptions { secure?: false; @@ -69,7 +75,7 @@ interface ApplicationListenEventListenerObject { interface ApplicationListenEventInit extends EventInit { hostname: string; - listener: Deno.Listener; + listener: Listener; port: number; secure: boolean; serverType: "native" | "custom"; @@ -81,7 +87,7 @@ type ApplicationListenEventListenerOrEventListenerObject = /** Available options that are used when creating a new instance of * {@linkcode Application}. */ -export interface ApplicationOptions { +export interface ApplicationOptions { /** Determine how when creating a new context, the state from the application * should be applied. A value of `"clone"` will set the state as a clone of * the app state. Any non-cloneable or non-enumerable properties will not be @@ -124,7 +130,7 @@ export interface ApplicationOptions { * requests. * * Generally this is only used for testing. */ - serverConstructor?: ServerConstructor; + serverConstructor?: ServerConstructor; /** The initial state object for the application, of which the type can be * used to infer the type of the state for both the application and any of the @@ -136,7 +142,7 @@ interface RequestState { handling: Set>; closing: boolean; closed: boolean; - server: Server; + server: Server; } // deno-lint-ignore no-explicit-any @@ -144,8 +150,15 @@ export type State = Record; const ADDR_REGEXP = /^\[?([^\]]*)\]?:([0-9]{1,5})$/; +const DEFAULT_SERVER: ServerConstructor = isNode() + ? (await import("./http_server_node.ts")).HttpServerNode + : HttpServerNative; +// deno-lint-ignore no-explicit-any +const LocalErrorEvent: typeof ErrorEvent = (globalThis as any).ErrorEvent ?? + (await import("./node_shims.ts")).ErrorEvent; + export class ApplicationErrorEvent - extends ErrorEvent { + extends LocalErrorEvent { context?: Context; constructor(eventInitDict: ApplicationErrorEventInit) { @@ -190,7 +203,7 @@ function logErrorListener( export class ApplicationListenEvent extends Event { hostname: string; - listener: Deno.Listener; + listener: Listener; port: number; secure: boolean; serverType: "native" | "custom"; @@ -239,7 +252,7 @@ export class Application> #contextState: "clone" | "prototype" | "alias" | "empty"; #keys?: KeyStack; #middleware: Middleware>[] = []; - #serverConstructor: ServerConstructor; + #serverConstructor: ServerConstructor; /** A set of keys, or an instance of `KeyStack` which will be used to sign * cookies read and set by the application to avoid tampering with the @@ -278,13 +291,13 @@ export class Application> */ state: AS; - constructor(options: ApplicationOptions = {}) { + constructor(options: ApplicationOptions = {}) { super(); const { state, keys, proxy, - serverConstructor = HttpServerNative, + serverConstructor = DEFAULT_SERVER, contextState = "clone", logErrors = true, } = options; @@ -354,7 +367,7 @@ export class Application> /** Processing registered middleware on each request. */ async #handleRequest( - request: NativeRequest, + request: ServerRequest, secure: boolean, state: RequestState, ): Promise { @@ -581,4 +594,26 @@ export class Application> inspect({ "#middleware": this.#middleware, keys, proxy, state }) }`; } + + [Symbol.for("nodejs.util.inspect.custom")]( + depth: number, + // deno-lint-ignore no-explicit-any + options: any, + inspect: (value: unknown, options?: unknown) => string, + ) { + if (depth < 0) { + return options.stylize(`[${this.constructor.name}]`, "special"); + } + + const newOptions = Object.assign({}, options, { + depth: options.depth === null ? null : options.depth - 1, + }); + const { keys, proxy, state } = this; + return `${options.stylize(this.constructor.name, "special")} ${ + inspect( + { "#middleware": this.#middleware, keys, proxy, state }, + newOptions, + ) + }`; + } } diff --git a/application_test.ts b/application_test.ts index 66585237..ec9ab24d 100644 --- a/application_test.ts +++ b/application_test.ts @@ -21,7 +21,14 @@ import { Status } from "./deps.ts"; import { HttpServerNative, NativeRequest } from "./http_server_native.ts"; import { httpErrors } from "./httpError.ts"; import { KeyStack } from "./keyStack.ts"; -import type { Data, Server, ServerConstructor } from "./types.d.ts"; +import type { + Data, + Listener, + Server, + ServerConstructor, + ServerRequest, +} from "./types.d.ts"; +import { isNode } from "./util.ts"; const { test } = Deno; @@ -56,7 +63,7 @@ function setup( return [ class MockNativeServer> - implements Server { + implements Server { constructor( _app: Application, private options: Deno.ListenOptions | Deno.ListenTlsOptions, @@ -68,14 +75,14 @@ function setup( serverClosed = true; } - listen(): Deno.Listener { + listen(): Listener { return { addr: { transport: "tcp", - hostname: this.options.hostname, + hostname: this.options.hostname ?? "localhost", port: this.options.port, }, - } as Deno.Listener; + } as Listener; } async *[Symbol.asyncIterator]() { @@ -901,7 +908,9 @@ test({ fn() { assertEquals( Deno.inspect(new Application()), - `Application { "#middleware": [], keys: undefined, proxy: false, state: {} }`, + isNode() + ? `Application { '#middleware': [], keys: undefined, proxy: false, state: {} }` + : `Application { "#middleware": [], keys: undefined, proxy: false, state: {} }`, ); teardown(); }, diff --git a/body.ts b/body.ts index 7afe4fa0..990ad510 100644 --- a/body.ts +++ b/body.ts @@ -4,6 +4,7 @@ import { readerFromStreamReader } from "./deps.ts"; import { httpErrors } from "./httpError.ts"; import { isMediaType } from "./isMediaType.ts"; import { FormDataReader } from "./multipart.ts"; +import type { ServerRequestBody } from "./types.d.ts"; import { assert } from "./util.ts"; /** The type of the body, where: @@ -183,20 +184,22 @@ function resolveType( const decoder = new TextDecoder(); export class RequestBody { + #body: ReadableStream | null; #formDataReader?: FormDataReader; + #headers: Headers; #stream?: ReadableStream; #readAllBody?: Promise; - #request: Request; + #readBody: () => Promise; #type?: "bytes" | "form-data" | "reader" | "stream" | "undefined"; #exceedsLimit(limit: number): boolean { if (!limit || limit === Infinity) { return false; } - if (!this.#request.body) { + if (!this.#body) { return false; } - const contentLength = this.#request.headers.get("content-length"); + const contentLength = this.#headers.get("content-length"); if (!contentLength) { return true; } @@ -222,13 +225,15 @@ export class RequestBody { case "form-data": this.#type = "form-data"; return () => { - const contentType = this.#request.headers.get("content-type"); + const contentType = this.#headers.get("content-type"); assert(contentType); - const readableStream = this.#request.body ?? new ReadableStream(); + const readableStream = this.#body ?? new ReadableStream(); return this.#formDataReader ?? (this.#formDataReader = new FormDataReader( contentType, - readerFromStreamReader(readableStream.getReader()), + readerFromStreamReader( + (readableStream as ReadableStream).getReader(), + ), )); }; case "json": @@ -300,14 +305,13 @@ export class RequestBody { } #valuePromise() { - return this.#readAllBody ?? - (this.#readAllBody = this.#request.arrayBuffer().then((ab) => - new Uint8Array(ab) - )); + return this.#readAllBody ?? (this.#readAllBody = this.#readBody()); } - constructor(request: Request) { - this.#request = request; + constructor({ body, readBody }: ServerRequestBody, headers: Headers) { + this.#body = body; + this.#headers = headers; + this.#readBody = readBody; } get( @@ -315,7 +319,7 @@ export class RequestBody { ): Body | BodyReader | BodyStream { this.#validateGetArgs(type, contentTypes); if (type === "reader") { - if (!this.#request.body) { + if (!this.#body) { this.#type = "undefined"; throw new TypeError( `Body is undefined and cannot be returned as "reader".`, @@ -324,25 +328,27 @@ export class RequestBody { this.#type = "reader"; return { type, - value: readerFromStreamReader(this.#request.body.getReader()), + value: readerFromStreamReader(this.#body.getReader()), }; } if (type === "stream") { - if (!this.#request.body) { + if (!this.#body) { this.#type = "undefined"; throw new TypeError( `Body is undefined and cannot be returned as "stream".`, ); } this.#type = "stream"; - const streams = (this.#stream ?? this.#request.body).tee(); + const streams = + ((this.#stream ?? this.#body) as ReadableStream) + .tee(); this.#stream = streams[1]; return { type, value: streams[0] }; } if (!this.has()) { this.#type = "undefined"; } else if (!this.#type) { - const encoding = this.#request.headers.get("content-encoding") ?? + const encoding = this.#headers.get("content-encoding") ?? "identity"; if (encoding !== "identity") { throw new httpErrors.UnsupportedMediaType( @@ -354,7 +360,7 @@ export class RequestBody { return { type: "undefined", value: undefined }; } if (!type) { - const contentType = this.#request.headers.get("content-type"); + const contentType = this.#headers.get("content-type"); assert( contentType, "The Content-Type header is missing from the request", @@ -387,6 +393,6 @@ export class RequestBody { * HTTP/2 behaviour. */ has(): boolean { - return this.#request.body != null; + return this.#body != null; } } diff --git a/body_test.ts b/body_test.ts index 051e3c78..217b22d0 100644 --- a/body_test.ts +++ b/body_test.ts @@ -8,6 +8,7 @@ import { assertRejects, assertStrictEquals, } from "./test_deps.ts"; +import type { ServerRequestBody } from "./types.d.ts"; const { test } = Deno; @@ -24,21 +25,34 @@ world --OAK-SERVER-BOUNDARY-- `; +function toServerRequestBody(request: Request): [ServerRequestBody, Headers] { + return [{ + // deno-lint-ignore no-explicit-any + body: request.body as any, + readBody: async () => { + const ab = await request.arrayBuffer(); + return new Uint8Array(ab); + }, + }, request.headers]; +} + test({ name: "body - form", async fn() { const rBody = `foo=bar&bar=1&baz=qux+%2B+quux`; const requestBody = new RequestBody( - new Request( - "http://localhost/index.html", - { - body: rBody, - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "Content-Length": String(rBody.length), + ...toServerRequestBody( + new Request( + "http://localhost/index.html", + { + body: rBody, + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Content-Length": String(rBody.length), + }, }, - }, + ), ), ); assert(requestBody.has()); @@ -55,7 +69,7 @@ test({ test({ name: "body - form-data", async fn() { - const requestBody = new RequestBody( + const requestBody = new RequestBody(...toServerRequestBody( new Request( "http://localhost/index.html", { @@ -66,7 +80,7 @@ test({ }, }, ), - ); + )); assert(requestBody.has()); const body = requestBody.get({}); assert(body.type === "form-data"); @@ -80,16 +94,18 @@ test({ async fn() { const rBody = JSON.stringify({ hello: "world" }); const requestBody = new RequestBody( - new Request( - "http://localhost/index.html", - { - body: rBody, - method: "POST", - headers: { - "content-type": "application/json", - "content-length": String(rBody.length), + ...toServerRequestBody( + new Request( + "http://localhost/index.html", + { + body: rBody, + method: "POST", + headers: { + "content-type": "application/json", + "content-length": String(rBody.length), + }, }, - }, + ), ), ); assert(requestBody.has()); @@ -104,16 +120,18 @@ test({ async fn() { const rBody = `console.log("hello world!");\n`; const requestBody = new RequestBody( - new Request( - "http://localhost/index.html", - { - body: rBody, - method: "POST", - headers: { - "content-type": "application/javascript", - "content-length": String(rBody.length), + ...toServerRequestBody( + new Request( + "http://localhost/index.html", + { + body: rBody, + method: "POST", + headers: { + "content-type": "application/javascript", + "content-length": String(rBody.length), + }, }, - }, + ), ), ); assert(requestBody.has()); @@ -129,14 +147,16 @@ test({ async fn() { const rBody = "hello"; const requestBody = new RequestBody( - new Request("http://localhost/index.html", { - body: rBody, - method: "POST", - headers: { - "content-type": "text/plain", - "content-length": String(rBody.length), - }, - }), + ...toServerRequestBody( + new Request("http://localhost/index.html", { + body: rBody, + method: "POST", + headers: { + "content-type": "text/plain", + "content-length": String(rBody.length), + }, + }), + ), ); assert(requestBody.has()); const body = requestBody.get({}); @@ -149,7 +169,7 @@ test({ name: "body - undefined", fn() { const requestBody = new RequestBody( - new Request("http://localhost/index.html"), + ...toServerRequestBody(new Request("http://localhost/index.html")), ); assertEquals(requestBody.has(), false); const body = requestBody.get({}); @@ -162,13 +182,15 @@ test({ name: "body - type: reader", async fn() { const requestBody = new RequestBody( - new Request("http://localhost/index.html", { - body: "hello world", - method: "POST", - headers: { - "content-type": "text/plain", - }, - }), + ...toServerRequestBody( + new Request("http://localhost/index.html", { + body: "hello world", + method: "POST", + headers: { + "content-type": "text/plain", + }, + }), + ), ); const body = requestBody.get({ type: "reader" }); assert(body.type === "reader"); @@ -181,13 +203,15 @@ test({ name: "body - type: stream", async fn() { const requestBody = new RequestBody( - new Request("http://localhost/index.html", { - body: "hello world", - method: "POST", - headers: { - "content-type": "text/plain", - }, - }), + ...toServerRequestBody( + new Request("http://localhost/index.html", { + body: "hello world", + method: "POST", + headers: { + "content-type": "text/plain", + }, + }), + ), ); const body = requestBody.get({ type: "stream" }); assert(body.type === "stream"); @@ -201,14 +225,16 @@ test({ async fn() { const rBody = `foo=bar&bar=1&baz=qux+%2B+quux`; const requestBody = new RequestBody( - new Request("http://localhost/index.html", { - body: rBody, - method: "POST", - headers: { - "Content-Type": "application/javascript", - "content-length": String(rBody.length), - }, - }), + ...toServerRequestBody( + new Request("http://localhost/index.html", { + body: rBody, + method: "POST", + headers: { + "Content-Type": "application/javascript", + "content-length": String(rBody.length), + }, + }), + ), ); const body = requestBody.get({ type: "form" }); assert(body.type === "form"); @@ -224,14 +250,16 @@ test({ name: "body - type: form-data", async fn() { const requestBody = new RequestBody( - new Request("http://localhost/index.html", { - body: multipartFixture, - method: "POST", - headers: { - "content-type": - "application/javascript; boundary=OAK-SERVER-BOUNDARY", - }, - }), + ...toServerRequestBody( + new Request("http://localhost/index.html", { + body: multipartFixture, + method: "POST", + headers: { + "content-type": + "application/javascript; boundary=OAK-SERVER-BOUNDARY", + }, + }), + ), ); const body = requestBody.get({ type: "form-data" }); assert(body.type === "form-data"); @@ -245,14 +273,16 @@ test({ async fn() { const rBody = `console.log("hello world!");\n`; const requestBody = new RequestBody( - new Request("http://localhost/index.html", { - body: rBody, - method: "POST", - headers: { - "content-type": "text/plain", - "content-length": String(rBody.length), - }, - }), + ...toServerRequestBody( + new Request("http://localhost/index.html", { + body: rBody, + method: "POST", + headers: { + "content-type": "text/plain", + "content-length": String(rBody.length), + }, + }), + ), ); const body = requestBody.get({ type: "bytes" }); assert(body.type === "bytes"); @@ -266,14 +296,16 @@ test({ async fn() { const rBody = JSON.stringify({ hello: "world" }); const requestBody = new RequestBody( - new Request("http://localhost/index.html", { - body: rBody, - method: "POST", - headers: { - "content-type": "application/javascript", - "content-length": String(rBody.length), - }, - }), + ...toServerRequestBody( + new Request("http://localhost/index.html", { + body: rBody, + method: "POST", + headers: { + "content-type": "application/javascript", + "content-length": String(rBody.length), + }, + }), + ), ); const body = requestBody.get({ type: "json" }); assert(body.type === "json"); @@ -286,14 +318,16 @@ test({ async fn() { const rBody = "hello"; const requestBody = new RequestBody( - new Request("http://localhost/index.html", { - body: rBody, - method: "POST", - headers: { - "content-type": "application/javascript", - "content-length": String(rBody.length), - }, - }), + ...toServerRequestBody( + new Request("http://localhost/index.html", { + body: rBody, + method: "POST", + headers: { + "content-type": "application/javascript", + "content-length": String(rBody.length), + }, + }), + ), ); const body = requestBody.get({ type: "text" }); assert(body.type === "text"); @@ -305,7 +339,7 @@ test({ name: "body - type - body undefined", async fn() { const requestBody = new RequestBody( - new Request("http://localhost/index.html"), + ...toServerRequestBody(new Request("http://localhost/index.html")), ); assertEquals(requestBody.has(), false); const body = requestBody.get({ type: "text" }); @@ -319,14 +353,16 @@ test({ async fn() { const rBody = `foo=bar&bar=1&baz=qux+%2B+quux`; const requestBody = new RequestBody( - new Request("http://localhost/index.html", { - body: rBody, - method: "POST", - headers: { - "Content-Type": "application/javascript", - "content-length": String(rBody.length), - }, - }), + ...toServerRequestBody( + new Request("http://localhost/index.html", { + body: rBody, + method: "POST", + headers: { + "Content-Type": "application/javascript", + "content-length": String(rBody.length), + }, + }), + ), ); const body = requestBody.get( { contentTypes: { form: ["application/javascript"] } }, @@ -344,14 +380,16 @@ test({ name: "body - contentTypes: form-data", async fn() { const requestBody = new RequestBody( - new Request("http://localhost/index.html", { - body: multipartFixture, - method: "POST", - headers: { - "content-type": - "application/javascript; boundary=OAK-SERVER-BOUNDARY", - }, - }), + ...toServerRequestBody( + new Request("http://localhost/index.html", { + body: multipartFixture, + method: "POST", + headers: { + "content-type": + "application/javascript; boundary=OAK-SERVER-BOUNDARY", + }, + }), + ), ); const body = requestBody.get( { contentTypes: { formData: ["application/javascript"] } }, @@ -367,14 +405,16 @@ test({ async fn() { const rBody = `console.log("hello world!");\n`; const requestBody = new RequestBody( - new Request("http://localhost/index.html", { - body: rBody, - method: "POST", - headers: { - "content-type": "text/plain", - "content-length": String(rBody.length), - }, - }), + ...toServerRequestBody( + new Request("http://localhost/index.html", { + body: rBody, + method: "POST", + headers: { + "content-type": "text/plain", + "content-length": String(rBody.length), + }, + }), + ), ); const body = requestBody.get({ contentTypes: { bytes: ["text/plain"] } }); assert(body.type === "bytes"); @@ -388,14 +428,16 @@ test({ async fn() { const rBody = JSON.stringify({ hello: "world" }); const requestBody = new RequestBody( - new Request("http://localhost/index.html", { - body: rBody, - method: "POST", - headers: { - "content-type": "application/javascript", - "content-length": String(rBody.length), - }, - }), + ...toServerRequestBody( + new Request("http://localhost/index.html", { + body: rBody, + method: "POST", + headers: { + "content-type": "application/javascript", + "content-length": String(rBody.length), + }, + }), + ), ); const body = requestBody.get( { contentTypes: { json: ["application/javascript"] } }, @@ -410,14 +452,16 @@ test({ async fn() { const rBody = "hello"; const requestBody = new RequestBody( - new Request("http://localhost/index.html", { - body: rBody, - method: "POST", - headers: { - "content-type": "application/javascript", - "content-length": String(rBody.length), - }, - }), + ...toServerRequestBody( + new Request("http://localhost/index.html", { + body: rBody, + method: "POST", + headers: { + "content-type": "application/javascript", + "content-length": String(rBody.length), + }, + }), + ), ); const body = requestBody.get( { contentTypes: { text: ["application/javascript"] } }, @@ -432,14 +476,16 @@ test({ fn() { const rBody = `console.log("hello world!");\n`; const requestBody = new RequestBody( - new Request("http://localhost/index.html", { - body: rBody, - method: "POST", - headers: { - "content-type": "application/javascript", - "content-length": String(rBody.length), - }, - }), + ...toServerRequestBody( + new Request("http://localhost/index.html", { + body: rBody, + method: "POST", + headers: { + "content-type": "application/javascript", + "content-length": String(rBody.length), + }, + }), + ), ); const a = requestBody.get({}); const b = requestBody.get({}); @@ -454,14 +500,16 @@ test({ async fn() { const body = JSON.stringify({ hello: "world" }); const requestBody = new RequestBody( - new Request("http://localhost/index.html", { - body, - method: "POST", - headers: { - "content-type": "application/json", - "content-length": String(body.length), - }, - }), + ...toServerRequestBody( + new Request("http://localhost/index.html", { + body, + method: "POST", + headers: { + "content-type": "application/json", + "content-length": String(body.length), + }, + }), + ), ); const textBody = requestBody.get({ type: "text" }); assert(textBody.type === "text"); @@ -476,13 +524,15 @@ test({ name: "body - multiple streams", async fn() { const requestBody = new RequestBody( - new Request("http://localhost/index.html", { - body: "hello world", - method: "POST", - headers: { - "content-type": "text/plain", - }, - }), + ...toServerRequestBody( + new Request("http://localhost/index.html", { + body: "hello world", + method: "POST", + headers: { + "content-type": "text/plain", + }, + }), + ), ); const a = requestBody.get({ type: "stream" }); const b = requestBody.get({ type: "stream" }); @@ -498,13 +548,15 @@ test({ name: "body - default limit no content type", async fn() { const requestBody = new RequestBody( - new Request("http://localhost/index.html", { - body: "hello world", - method: "POST", - headers: { - "content-type": "text/plain", - }, - }), + ...toServerRequestBody( + new Request("http://localhost/index.html", { + body: "hello world", + method: "POST", + headers: { + "content-type": "text/plain", + }, + }), + ), ); const actual = requestBody.get(); await assertRejects( @@ -521,13 +573,15 @@ test({ name: "body - limit set to 0", async fn() { const requestBody = new RequestBody( - new Request("http://localhost/index.html", { - body: "hello world", - method: "POST", - headers: { - "content-type": "text/plain", - }, - }), + ...toServerRequestBody( + new Request("http://localhost/index.html", { + body: "hello world", + method: "POST", + headers: { + "content-type": "text/plain", + }, + }), + ), ); const actual = requestBody.get({ type: "text", limit: 0 }); assertEquals(await actual.value, "hello world"); @@ -538,13 +592,15 @@ test({ name: "body - limit set to Infinity", async fn() { const requestBody = new RequestBody( - new Request("http://localhost/index.html", { - body: "hello world", - method: "POST", - headers: { - "content-type": "text/plain", - }, - }), + ...toServerRequestBody( + new Request("http://localhost/index.html", { + body: "hello world", + method: "POST", + headers: { + "content-type": "text/plain", + }, + }), + ), ); const actual = requestBody.get({ type: "text", limit: Infinity }); assertEquals(await actual.value, "hello world"); @@ -556,14 +612,16 @@ test({ async fn() { const body = "hello world"; const requestBody = new RequestBody( - new Request("http://localhost/index.html", { - body, - method: "POST", - headers: { - "content-type": "text/plain", - "content-length": String(body.length), - }, - }), + ...toServerRequestBody( + new Request("http://localhost/index.html", { + body, + method: "POST", + headers: { + "content-type": "text/plain", + "content-length": String(body.length), + }, + }), + ), ); const actual = requestBody.get({ type: "text", limit: 1000 }); assertEquals(await actual.value, "hello world"); @@ -575,14 +633,16 @@ test({ async fn() { const body = "hello world"; const requestBody = new RequestBody( - new Request("http://localhost/index.html", { - body, - method: "POST", - headers: { - "content-type": "text/plain", - "content-length": String(body.length), - }, - }), + ...toServerRequestBody( + new Request("http://localhost/index.html", { + body, + method: "POST", + headers: { + "content-type": "text/plain", + "content-length": String(body.length), + }, + }), + ), ); const actual = requestBody.get({ type: "text", limit: 2 }); await assertRejects( @@ -600,14 +660,16 @@ test({ async fn() { const body = "hello world"; const requestBody = new RequestBody( - new Request("http://localhost/index.html", { - body, - method: "POST", - headers: { - "content-type": "text/plain", - "content-length": String(body.length), - }, - }), + ...toServerRequestBody( + new Request("http://localhost/index.html", { + body, + method: "POST", + headers: { + "content-type": "text/plain", + "content-length": String(body.length), + }, + }), + ), ); let actual = requestBody.get({ type: "text", limit: 2 }); await assertRejects( diff --git a/bundle_test.ts b/bundle_test.ts index 61886190..70160045 100644 --- a/bundle_test.ts +++ b/bundle_test.ts @@ -1,9 +1,14 @@ // Copyright 2018-2022 the oak authors. All rights reserved. MIT license. +import { isNode } from "./util.ts"; + +const BUNDLE = "./oak.bundle.js"; + Deno.test({ name: "bundle can load", + ignore: isNode(), async fn() { - const { Application, Router } = await import("./oak.bundle.js"); + const { Application, Router } = await import(BUNDLE); const router = new Router(); const app = new Application(); app.use(router.routes()); diff --git a/context.ts b/context.ts index 2dbc72bb..1e86addd 100644 --- a/context.ts +++ b/context.ts @@ -2,7 +2,6 @@ import type { Application, State } from "./application.ts"; import { Cookies } from "./cookies.ts"; -import { NativeRequest } from "./http_server_native.ts"; import { createHttpError } from "./httpError.ts"; import type { KeyStack } from "./keyStack.ts"; import { Request } from "./request.ts"; @@ -13,7 +12,11 @@ import { SSEStreamTarget, } from "./server_sent_event.ts"; import type { ServerSentEventTarget } from "./server_sent_event.ts"; -import type { ErrorStatus, UpgradeWebSocketOptions } from "./types.d.ts"; +import type { + ErrorStatus, + ServerRequest, + UpgradeWebSocketOptions, +} from "./types.d.ts"; export interface ContextSendOptions extends SendOptions { /** The filename to send, which will be resolved based on the other options. @@ -130,7 +133,7 @@ export class Context< constructor( app: Application, - serverRequest: NativeRequest, + serverRequest: ServerRequest, state: S, secure = false, ) { @@ -231,6 +234,11 @@ export class Context< if (this.#socket) { return this.#socket; } + if (!this.request.originalRequest.upgrade) { + throw new TypeError( + "Web socket upgrades not currently supported for this type of server.", + ); + } this.#socket = this.request.originalRequest.upgrade(options); this.respond = false; return this.#socket; @@ -260,4 +268,41 @@ export class Context< }) }`; } + + [Symbol.for("nodejs.util.inspect.custom")]( + depth: number, + // deno-lint-ignore no-explicit-any + options: any, + inspect: (value: unknown, options?: unknown) => string, + ) { + if (depth < 0) { + return options.stylize(`[${this.constructor.name}]`, "special"); + } + + const newOptions = Object.assign({}, options, { + depth: options.depth === null ? null : options.depth - 1, + }); + const { + app, + cookies, + isUpgradable, + respond, + request, + response, + socket, + state, + } = this; + return `${options.stylize(this.constructor.name, "special")} ${ + inspect({ + app, + cookies, + isUpgradable, + respond, + request, + response, + socket, + state, + }, newOptions) + }`; + } } diff --git a/context_test.ts b/context_test.ts index 89bff952..412bd5c0 100644 --- a/context_test.ts +++ b/context_test.ts @@ -18,6 +18,7 @@ import { Request as OakRequest } from "./request.ts"; import { Response as OakResponse } from "./response.ts"; import { cloneState } from "./structured_clone.ts"; import type { UpgradeWebSocketOptions } from "./types.d.ts"; +import { isNode } from "./util.ts"; const { test } = Deno; @@ -30,6 +31,22 @@ function createMockApp>( [Symbol.for("Deno.customInspect")]() { return `MockApplication {}`; }, + [Symbol.for("nodejs.util.inspect.custom")]( + depth: number, + options: any, + inspect: (value: unknown, options?: unknown) => string, + ) { + if (depth < 0) { + return options.stylize(`[MockApplication]`, "special"); + } + + const newOptions = Object.assign({}, options, { + depth: options.depth === null ? null : options.depth - 1, + }); + return `${options.stylize("MockApplication", "special")} ${ + inspect({}, newOptions) + }`; + }, } as any; } @@ -317,7 +334,9 @@ test({ const req = createMockNativeRequest(); assertEquals( Deno.inspect(new Context(app, req, {}), { depth: 1 }), - `Context {\n app: MockApplication {},\n cookies: Cookies [],\n isUpgradable: false,\n respond: true,\n request: Request {\n hasBody: false,\n headers: Headers { host: "localhost" },\n ip: "",\n ips: [],\n method: "GET",\n secure: false,\n url: "http://localhost/"\n},\n response: Response { body: undefined, headers: Headers {}, status: 404, type: undefined, writable: true },\n socket: undefined,\n state: {}\n}`, + isNode() + ? `Context {\n app: [MockApplication],\n cookies: [Cookies],\n isUpgradable: false,\n respond: true,\n request: [Request],\n response: [Response],\n socket: undefined,\n state: {}\n}` + : `Context {\n app: MockApplication {},\n cookies: Cookies [],\n isUpgradable: false,\n respond: true,\n request: Request {\n hasBody: false,\n headers: Headers { host: "localhost" },\n ip: "",\n ips: [],\n method: "GET",\n secure: false,\n url: "http://localhost/"\n},\n response: Response { body: undefined, headers: Headers {}, status: 404, type: undefined, writable: true },\n socket: undefined,\n state: {}\n}`, ); }, }); diff --git a/cookies.ts b/cookies.ts index 2638b8bf..223ffcb9 100644 --- a/cookies.ts +++ b/cookies.ts @@ -383,4 +383,22 @@ export class Cookies { [Symbol.for("Deno.customInspect")]() { return `${this.constructor.name} []`; } + + [Symbol.for("nodejs.util.inspect.custom")]( + depth: number, + // deno-lint-ignore no-explicit-any + options: any, + inspect: (value: unknown, options?: unknown) => string, + ) { + if (depth < 0) { + return options.stylize(`[${this.constructor.name}]`, "special"); + } + + const newOptions = Object.assign({}, options, { + depth: options.depth === null ? null : options.depth - 1, + }); + return `${options.stylize(this.constructor.name, "special")} ${ + inspect([], newOptions) + }`; + } } diff --git a/cookies_test.ts b/cookies_test.ts index e3d150ee..31ddcc63 100644 --- a/cookies_test.ts +++ b/cookies_test.ts @@ -8,6 +8,7 @@ import { Cookies } from "./cookies.ts"; import { KeyStack } from "./keyStack.ts"; import type { Request } from "./request.ts"; import type { Response } from "./response.ts"; +import { isNode } from "./util.ts"; const { test } = Deno; @@ -120,11 +121,17 @@ test({ await cookies.set("a", "a"); await cookies.set("b", "b"); await cookies.set("c", "c"); - assertEquals([...response.headers], [ - ["set-cookie", "a=a; path=/; httponly"], - ["set-cookie", "b=b; path=/; httponly"], - ["set-cookie", "c=c; path=/; httponly"], - ]); + const expected = isNode() + ? [[ + "set-cookie", + "a=a; path=/; httponly, b=b; path=/; httponly, c=c; path=/; httponly", + ]] + : [ + ["set-cookie", "a=a; path=/; httponly"], + ["set-cookie", "b=b; path=/; httponly"], + ["set-cookie", "c=c; path=/; httponly"], + ]; + assertEquals([...response.headers], expected); }, }); @@ -300,9 +307,9 @@ test({ path: "/baz", sameSite: "strict", }); - assertEquals( - response.headers.get("set-cookie"), - "a=b; path=/a; expires=Wed, 01 Jan 2020 00:00:00 GMT; domain=*.example.com; samesite=strict, foo=baz; path=/baz; expires=Wed, 01 Jan 2020 00:00:00 GMT; domain=*.example.com; samesite=strict", - ); + const expected = isNode() + ? "foo=baz; path=/baz; expires=Wed, 01 Jan 2020 00:00:00 GMT; domain=*.example.com; samesite=strict" + : "a=b; path=/a; expires=Wed, 01 Jan 2020 00:00:00 GMT; domain=*.example.com; samesite=strict, foo=baz; path=/baz; expires=Wed, 01 Jan 2020 00:00:00 GMT; domain=*.example.com; samesite=strict"; + assertEquals(response.headers.get("set-cookie"), expected); }, }); diff --git a/deps.ts b/deps.ts index a12c63da..d14adc72 100644 --- a/deps.ts +++ b/deps.ts @@ -8,15 +8,18 @@ export { concat, copy as copyBytes, equals, -} from "https://deno.land/std@0.123.0/bytes/mod.ts"; -export * as base64 from "https://deno.land/std@0.123.0/encoding/base64.ts"; +} from "https://deno.land/std@0.126.0/bytes/mod.ts"; +export * as base64 from "https://deno.land/std@0.126.0/encoding/base64.ts"; export { Status, STATUS_TEXT, -} from "https://deno.land/std@0.123.0/http/http_status.ts"; -export { LimitedReader } from "https://deno.land/std@0.123.0/io/readers.ts"; -export { readerFromStreamReader } from "https://deno.land/std@0.123.0/streams/conversion.ts"; -export { readAll, writeAll } from "https://deno.land/std@0.123.0/io/util.ts"; +} from "https://deno.land/std@0.126.0/http/http_status.ts"; +export { LimitedReader } from "https://deno.land/std@0.126.0/io/readers.ts"; +export { + readAll, + readerFromStreamReader, + writeAll, +} from "https://deno.land/std@0.126.0/streams/conversion.ts"; export { basename, extname, @@ -25,7 +28,7 @@ export { normalize, parse, sep, -} from "https://deno.land/std@0.123.0/path/mod.ts"; +} from "https://deno.land/std@0.126.0/path/mod.ts"; // 3rd party dependencies diff --git a/docs/index.md b/docs/index.md index c6649865..b3e727ea 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,13 +1,15 @@ # oak -A middleware framework for Deno's native HTTP server and -[Deno Deploy](https://deno.com/deploy). It also includes a middleware router. +A middleware framework for Deno's native HTTP server, +[Deno Deploy](https://deno.com/deploy) and Node.js 16.5 and later. It also +includes a middleware router. This middleware framework is inspired by [Koa](https://github.com/koajs/koa) and middleware router inspired by [@koa/router](https://github.com/koajs/router/). -- [deno doc for oak](https://doc.deno.land/https/deno.land/x/oak/mod.ts) +- [API Documentation](https://doc.deno.land/https://deno.land/x/oak/mod.ts) - [oak and Deno Deploy](./deploy) +- [oak and Node.js](./node) - [Testing oak](./testing) - [Frequently Asked Questions](./FAQ) - [Awesome oak](https://oakserver.github.io/awesome-oak/) - Community resources diff --git a/docs/node.md b/docs/node.md new file mode 100644 index 00000000..5440a925 --- /dev/null +++ b/docs/node.md @@ -0,0 +1,69 @@ +# Node.js + +oak 10.3 introduces experimental support for Node.js 16.5 and later. + +The package is available on npm as +[`@oakserver/oak`](https://www.npmjs.com/package/@oakserver/oak) and can be +installed with your preferred package manager. + +The package shares the same API as if it were being used under Deno CLI or Deno +Deploy, and almost all functionality is the same. + +A few notes about the support: + +- The package is only available as an ESM distribution. This is primarily + because the package uses top level await, which can only be supported via ES + modules in Node.js. +- The package includes all the type definitions, which should make it easy to + use from within an intelligent IDE. +- The package uses `Headers` from [undici](https://github.com/nodejs/undici) + which operate slightly different than the Deno `Headers` class. Generally this + shouldn't cause issues for users and code, but it hasn't been extensively + tested yet and so there maybe some behavioral changes. +- Currently the package does not support upgrading a connection to web sockets. + There are plans for this in the future. +- Currently the package only supports HTTP/1.1 server. There are plans to + support HTTP/2 in the future. + +## Usage + +As mentioned above, installing the package `@oakserver/oak` should be +sufficient. + +Because oak is ESM only package, it is best to author your server code as an ES +module, which will give you the cleanest development experience: + +**index.mjs** + +```js +import { Application } from "@oakserver/oak"; + +const app = new Application(); + +app.use((ctx) => { + ctx.response.body = "Hello from oak on Node.js"; +}); + +app.listen({ port: 8000 }); +``` + +If for some reason you really want to use CommonJS modules, oak can be imported +via dynamic `import()`: + +**index.js** + +```js +async function start() { + const { Application } = await import("@oakserver/oak"); + + const app = new Application(); + + app.use((ctx) => { + ctx.response.body = "Hello from oak on Node.js"; + }); + + app.listen({ port: 8000 }); +} + +start(); +``` diff --git a/examples/closeServer.ts b/examples/closeServer.ts index fa7e235b..7797fab6 100644 --- a/examples/closeServer.ts +++ b/examples/closeServer.ts @@ -8,7 +8,7 @@ import { cyan, green, yellow, -} from "https://deno.land/std@0.123.0/fmt/colors.ts"; +} from "https://deno.land/std@0.126.0/fmt/colors.ts"; import { Application, Context, Router, Status } from "../mod.ts"; diff --git a/examples/cookieServer.ts b/examples/cookieServer.ts index b5719bf0..7529c7de 100644 --- a/examples/cookieServer.ts +++ b/examples/cookieServer.ts @@ -8,7 +8,7 @@ import { cyan, green, yellow, -} from "https://deno.land/std@0.123.0/fmt/colors.ts"; +} from "https://deno.land/std@0.126.0/fmt/colors.ts"; import { Application } from "../mod.ts"; diff --git a/examples/echoServer.ts b/examples/echoServer.ts index e1d0378e..67bc10e6 100644 --- a/examples/echoServer.ts +++ b/examples/echoServer.ts @@ -8,7 +8,7 @@ import { cyan, green, yellow, -} from "https://deno.land/std@0.123.0/fmt/colors.ts"; +} from "https://deno.land/std@0.126.0/fmt/colors.ts"; import { Application } from "../mod.ts"; diff --git a/examples/httpsServer.ts b/examples/httpsServer.ts index 82d8bfaf..5f11233c 100644 --- a/examples/httpsServer.ts +++ b/examples/httpsServer.ts @@ -13,7 +13,7 @@ import { cyan, green, yellow, -} from "https://deno.land/std@0.123.0/fmt/colors.ts"; +} from "https://deno.land/std@0.126.0/fmt/colors.ts"; import { Application } from "../mod.ts"; diff --git a/examples/proxyServer.ts b/examples/proxyServer.ts index d4161b49..fa9020ee 100644 --- a/examples/proxyServer.ts +++ b/examples/proxyServer.ts @@ -8,7 +8,7 @@ import { green, red, yellow, -} from "https://deno.land/std@0.123.0/fmt/colors.ts"; +} from "https://deno.land/std@0.126.0/fmt/colors.ts"; import { Application, HttpError, proxy, Status } from "../mod.ts"; diff --git a/examples/readerServer.ts b/examples/readerServer.ts index da3cdcc1..cc0105ca 100644 --- a/examples/readerServer.ts +++ b/examples/readerServer.ts @@ -4,8 +4,8 @@ */ // Importing some console colors -import { bold, yellow } from "https://deno.land/std@0.123.0/fmt/colors.ts"; -import { StringReader } from "https://deno.land/std@0.123.0/io/readers.ts"; +import { bold, yellow } from "https://deno.land/std@0.126.0/fmt/colors.ts"; +import { StringReader } from "https://deno.land/std@0.126.0/io/readers.ts"; import { Application } from "../mod.ts"; diff --git a/examples/routingServer.ts b/examples/routingServer.ts index 1e2cb436..d05a2218 100644 --- a/examples/routingServer.ts +++ b/examples/routingServer.ts @@ -8,7 +8,7 @@ import { cyan, green, yellow, -} from "https://deno.land/std@0.123.0/fmt/colors.ts"; +} from "https://deno.land/std@0.126.0/fmt/colors.ts"; import { Application, diff --git a/examples/server.ts b/examples/server.ts index 01e0a149..450f6597 100644 --- a/examples/server.ts +++ b/examples/server.ts @@ -9,7 +9,7 @@ import { cyan, green, yellow, -} from "https://deno.land/std@0.123.0/fmt/colors.ts"; +} from "https://deno.land/std@0.126.0/fmt/colors.ts"; import { Application } from "../mod.ts"; diff --git a/examples/sseServer.ts b/examples/sseServer.ts index 74c1d599..8bd68f73 100644 --- a/examples/sseServer.ts +++ b/examples/sseServer.ts @@ -8,7 +8,7 @@ import { cyan, green, yellow, -} from "https://deno.land/std@0.123.0/fmt/colors.ts"; +} from "https://deno.land/std@0.126.0/fmt/colors.ts"; import { Application, diff --git a/examples/staticServer.ts b/examples/staticServer.ts index 1ec6b352..e6a4efde 100644 --- a/examples/staticServer.ts +++ b/examples/staticServer.ts @@ -9,7 +9,7 @@ import { green, red, yellow, -} from "https://deno.land/std@0.123.0/fmt/colors.ts"; +} from "https://deno.land/std@0.126.0/fmt/colors.ts"; import { Application, HttpError, Status } from "../mod.ts"; diff --git a/http_server_native.ts b/http_server_native.ts index 362043ea..7e9de83e 100644 --- a/http_server_native.ts +++ b/http_server_native.ts @@ -1,11 +1,30 @@ // Copyright 2018-2022 the oak authors. All rights reserved. MIT license. import type { Application, State } from "./application.ts"; -import type { Server, UpgradeWebSocketOptions } from "./types.d.ts"; +import type { + Listener, + Server, + ServerRequest, + ServerRequestBody, + UpgradeWebSocketOptions, +} from "./types.d.ts"; import { assert, isListenTlsOptions } from "./util.ts"; +// this is included so when down-emitting to npm/Node.js, ReadableStream has +// async iterators +declare global { + // deno-lint-ignore no-explicit-any + interface ReadableStream { + [Symbol.asyncIterator](options?: { + preventCancel?: boolean; + }): AsyncIterableIterator; + } +} + export type Respond = (r: Response | Promise) => void; -export const DomResponse: typeof Response = Response; +// deno-lint-ignore no-explicit-any +export const DomResponse: typeof Response = (globalThis as any).Response ?? + class MockResponse {}; // This type is part of Deno, but not part of lib.dom.d.ts, therefore add it here // so that type checking can occur properly under `lib.dom.d.ts`. @@ -58,7 +77,7 @@ export interface NativeRequestOptions { /** An internal oak abstraction for handling a Deno native request. Most users * of oak do not need to worry about this abstraction. */ -export class NativeRequest { +export class NativeRequest implements ServerRequest { #conn?: Deno.Conn; // deno-lint-ignore no-explicit-any #reject!: (reason?: any) => void; @@ -87,7 +106,10 @@ export class NativeRequest { } get body(): ReadableStream | null { - return this.#request.body; + // when shimming with undici under Node.js, this is a + // `ControlledAsyncIterable` + // deno-lint-ignore no-explicit-any + return this.#request.body as any; } get donePromise(): Promise { @@ -133,6 +155,19 @@ export class NativeRequest { this.#resolved = true; } + getBody(): ServerRequestBody { + return { + // when emitting to Node.js, the body is not compatible, and thought it + // doesn't run at runtime, it still gets type checked. + // deno-lint-ignore no-explicit-any + body: this.#request.body as any, + readBody: async () => { + const ab = await this.#request.arrayBuffer(); + return new Uint8Array(ab); + }, + }; + } + respond(response: Response): Promise { if (this.#resolved) { throw new Error("Request already responded to."); @@ -168,7 +203,7 @@ export class HttpServerNative> #app: Application; #closed = false; #listener?: Deno.Listener; - #httpConnections: Set = new Set(); + #httpConnections: Set = new Set(); #options: Deno.ListenOptions | Deno.ListenTlsOptions; constructor( @@ -213,17 +248,17 @@ export class HttpServerNative> this.#httpConnections.clear(); } - listen(): Deno.Listener { - return this.#listener = isListenTlsOptions(this.#options) + listen(): Listener { + return (this.#listener = isListenTlsOptions(this.#options) ? Deno.listenTls(this.#options) - : Deno.listen(this.#options); + : Deno.listen(this.#options)) as Listener; } - #trackHttpConnection(httpConn: Deno.HttpConn): void { + #trackHttpConnection(httpConn: HttpConn): void { this.#httpConnections.add(httpConn); } - #untrackHttpConnection(httpConn: Deno.HttpConn): void { + #untrackHttpConnection(httpConn: HttpConn): void { this.#httpConnections.delete(httpConn); } diff --git a/http_server_native_test.ts b/http_server_native_test.ts index 831a3e21..d8d17480 100644 --- a/http_server_native_test.ts +++ b/http_server_native_test.ts @@ -5,6 +5,7 @@ import { assertEquals, assertStrictEquals, unreachable } from "./test_deps.ts"; import { HttpServerNative, NativeRequest } from "./http_server_native.ts"; import { Application } from "./application.ts"; +import { isNode } from "./util.ts"; const { test } = Deno; @@ -18,6 +19,7 @@ function createMockConn() { test({ name: "NativeRequest", + ignore: isNode(), async fn() { const respondWithStack: Array> = []; const request = new Request("http://localhost:8000/", { @@ -42,6 +44,7 @@ test({ test({ name: "HttpServerNative closes gracefully after serving requests", + ignore: isNode(), async fn() { const app = new Application(); const listenOptions = { port: 4505 }; diff --git a/http_server_node.ts b/http_server_node.ts new file mode 100644 index 00000000..29ebbd55 --- /dev/null +++ b/http_server_node.ts @@ -0,0 +1,222 @@ +// Copyright 2018-2022 the oak authors. All rights reserved. MIT license. + +import type { Listener, Server, ServerRequest } from "./types.d.ts"; +import * as http from "http"; + +// There are quite a few differences between Deno's `std/node/http` and the +// typings for Node.js for `"http"`. Since we develop everything in Deno, but +// type check in Deno and Node.js we have to provide the API surface we depend +// on here, instead of accepting what comes in via the import. +export type IncomingMessage = { + headers: Record; + method: string | null; + socket: { + address(): { + addr: null | { + address: string; + }; + }; + }; + url: string | null; + + on(method: "data", listener: (chunk: Uint8Array) => void): void; + on(method: "error", listener: (err: Error) => void): void; + on(method: "end", listener: () => void): void; +}; +type HttpServer = { + listen(options: { port: number; host: string; signal: AbortSignal }): void; +}; +export type ServerResponse = { + destroy(error?: Error): void; + end(callback?: () => void): void; + setHeader(key: string, value: string): void; + write(chunk: unknown, callback?: (err: Error | null) => void): void; + writeHead(status: number, statusText?: string): void; +}; + +interface ReadableStreamDefaultControllerCallback { + (controller: ReadableStreamDefaultController): void | PromiseLike; +} +// deno-lint-ignore no-explicit-any +interface ReadableStreamDefaultController { + readonly desiredSize: number | null; + close(): void; + enqueue(chunk: R): void; + // deno-lint-ignore no-explicit-any + error(error?: any): void; +} + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + // deno-lint-ignore no-explicit-any + let reject!: (reason?: any) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +export class NodeRequest implements ServerRequest { + #request: IncomingMessage; + #response: ServerResponse; + #responded = false; + + get remoteAddr(): string | undefined { + const addr = this.#request.socket.address(); + // deno-lint-ignore no-explicit-any + return addr && (addr as any)?.address; + } + + get headers(): Headers { + return new Headers(this.#request.headers as Record); + } + + get method(): string { + return this.#request.method ?? "GET"; + } + + get url(): string { + return this.#request.url ?? ""; + } + + constructor( + request: IncomingMessage, + response: ServerResponse, + ) { + this.#request = request; + this.#response = response; + } + + // deno-lint-ignore no-explicit-any + error(reason?: any) { + if (this.#responded) { + throw new Error("Request already responded to."); + } + let error; + if (reason) { + error = reason instanceof Error ? reason : new Error(String(reason)); + } + this.#response.destroy(error); + this.#responded = true; + } + + getBody() { + let body: ReadableStream | null; + if (this.method === "GET" || this.method === "HEAD") { + body = null; + } else { + body = new ReadableStream({ + start: (controller) => { + this.#request.on("data", (chunk: Uint8Array) => { + controller.enqueue(chunk); + }); + this.#request.on("error", (err: Error) => { + controller.error(err); + }); + this.#request.on("end", () => { + controller.close(); + }); + }, + }); + } + return { + body, + async readBody() { + if (!body) { + return new Uint8Array(); + } + const chunks: Uint8Array[] = []; + for await (const chunk of body) { + chunks.push(chunk); + } + const totalLength = chunks.reduce( + (acc, value) => acc + value.length, + 0, + ); + const result = new Uint8Array(totalLength); + let length = 0; + for (const chunk of chunks) { + result.set(chunk, length); + length += chunk.length; + } + return result; + }, + }; + } + + async respond(response: Response) { + if (this.#responded) { + throw new Error("Requested already responded to."); + } + for (const [key, value] of response.headers) { + this.#response.setHeader(key, value); + } + this.#response.writeHead(response.status, response.statusText); + if (response.body) { + for await (const chunk of response.body) { + const { promise, resolve, reject } = createDeferred(); + // deno-lint-ignore no-explicit-any + this.#response.write(chunk, (err: any) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + await promise; + } + } + const { promise, resolve } = createDeferred(); + this.#response.end(resolve); + await promise; + this.#responded = true; + } +} + +export class HttpServerNode implements Server { + #abortController = new AbortController(); + #host: string; + #port: number; + #requestStream: ReadableStream; + #server!: HttpServer; + + constructor( + _app: unknown, + options: Deno.ListenOptions | Deno.ListenTlsOptions, + ) { + this.#host = options.hostname ?? "127.0.0.1"; + this.#port = options.port; + const start: ReadableStreamDefaultControllerCallback = ( + controller, + ) => { + const handler = (req: IncomingMessage, res: ServerResponse) => + controller.enqueue(new NodeRequest(req, res)); + // deno-lint-ignore no-explicit-any + this.#server = http.createServer(handler as any); + }; + this.#requestStream = new ReadableStream({ start }); + } + + close(): void { + this.#abortController.abort(); + } + + listen(): Listener { + this.#server.listen({ + port: this.#port, + host: this.#host, + signal: this.#abortController.signal, + }); + return { + addr: { + port: this.#port, + hostname: this.#host, + }, + }; + } + + [Symbol.asyncIterator](): AsyncIterableIterator { + return this.#requestStream[Symbol.asyncIterator](); + } +} diff --git a/http_server_node_test.ts b/http_server_node_test.ts new file mode 100644 index 00000000..033d7e7c --- /dev/null +++ b/http_server_node_test.ts @@ -0,0 +1,112 @@ +// Copyright 2018-2022 the oak authors. All rights reserved. MIT license. + +// deno-lint-ignore-file no-explicit-any + +import { assertEquals, unreachable } from "./test_deps.ts"; + +import { + HttpServerNode, + type IncomingMessage, + NodeRequest, + type ServerResponse, +} from "./http_server_node.ts"; + +import { Application } from "./application.ts"; +import { isNode } from "./util.ts"; + +const destroyCalls: any[][] = []; +const setHeaderCalls: any[][] = []; +const writeCalls: any[][] = []; +const writeHeadCalls: any[][] = []; + +function createMockReqRes( + url = "/", + headers: Record = {}, + method = "GET", + address = "127.0.0.1", +): [req: IncomingMessage, res: ServerResponse] { + destroyCalls.length = 0; + setHeaderCalls.length = 0; + writeCalls.length = 0; + writeHeadCalls.length = 0; + const req = { + headers, + method, + socket: { + address() { + return { + addr: { + address, + }, + }; + }, + }, + url, + on(_method: string, _listener: (arg?: any) => void) {}, + }; + const res = { + destroy(...args: any[]) { + destroyCalls.push(args); + }, + end(callback?: () => void) { + if (callback) { + callback(); + } + }, + setHeader(...args: any[]) { + setHeaderCalls.push(args); + }, + write(chunk: unknown, callback?: (err: Error | null) => void) { + writeCalls.push([chunk, callback]); + if (callback) { + callback(null); + } + }, + writeHead(...args: any[]) { + writeHeadCalls.push(args); + }, + }; + return [req, res]; +} + +Deno.test({ + name: "NodeRequest", + async fn() { + const nodeRequest = new NodeRequest( + ...createMockReqRes("/", {}, "POST", "127.0.0.1"), + ); + assertEquals(nodeRequest.url, `/`); + const response = new Response("hello deno"); + await nodeRequest.respond(response); + assertEquals(writeHeadCalls, [[200, ""]]); + }, +}); + +Deno.test({ + name: "HttpServerNode closes gracefully after serving requests", + ignore: !isNode(), + async fn() { + const app = new Application(); + const listenOptions = { port: 4505 }; + + const server = new HttpServerNode(app, listenOptions); + server.listen(); + + const expectedBody = "test-body"; + + (async () => { + for await (const nodeRequest of server) { + nodeRequest.respond(new Response(expectedBody)); + } + })(); + + try { + const response = await fetch(`http://localhost:${listenOptions.port}`); + assertEquals(await response.text(), expectedBody); + } catch { + unreachable(); + } finally { + server.close(); + } + }, +}); diff --git a/import-map.json b/import-map.json new file mode 100644 index 00000000..d5c3ac57 --- /dev/null +++ b/import-map.json @@ -0,0 +1,30 @@ +{ + "imports": { + "buffer": "https://cdn.esm.sh/@types/node@16.11.9/buffer.d.ts", + "crypto": "https://deno.land/x/std@0.126.0/node/crypto.ts", + "dns": "https://cdn.esm.sh/@types/node@16.11.9/dns.d.ts", + "dns/promises": "https://cdn.esm.sh/@types/node@16.11.9/dns/promises.d.ts", + "http": "https://deno.land/x/std@0.126.0/node/http.ts", + "http2": "https://deno.land/x/std@0.126.0/node/http2.ts", + "net": "https://cdn.esm.sh/@types/node@16.11.9/net.d.ts", + "node:buffer": "https://cdn.esm.sh/@types/node@16.11.9/buffer.d.ts", + "node:crypto": "https://cdn.esm.sh/@types/node@16.11.9/crypto.d.ts", + "node:dns": "https://cdn.esm.sh/@types/node@16.11.9/dns.d.ts", + "node:dns/promises": "https://cdn.esm.sh/@types/node@16.11.9/dns/promises.d.ts", + "node:events": "https://cdn.esm.sh/@types/node@16.11.9/events.d.ts", + "node:http": "https://cdn.esm.sh/@types/node@16.11.9/http.d.ts", + "node:net": "https://cdn.esm.sh/@types/node@16.11.9/net.d.ts", + "node:querystring": "https://cdn.esm.sh/@types/node@16.11.9/querystring.d.ts", + "node:stream": "https://cdn.esm.sh/@types/node@16.11.9/stream.d.ts", + "node:stream/consumers": "https://cdn.esm.sh/@types/node@16.11.9/stream/consumers.d.ts", + "node:stream/promises": "https://cdn.esm.sh/@types/node@16.11.9/stream/promises.d.ts", + "node:tls": "https://cdn.esm.sh/@types/node@16.11.9/tls.d.ts", + "node:url": "https://cdn.esm.sh/@types/node@16.11.9/url.d.ts", + "querystring": "https://cdn.esm.sh/@types/node@16.11.9/querystring.d.ts", + "stream/consumers": "https://cdn.esm.sh/@types/node@16.11.9/stream/consumers.d.ts", + "stream/promises": "https://cdn.esm.sh/@types/node@16.11.9/stream/promises.d.ts", + "stream/web": "https://deno.land/x/std@0.126.0/node/stream/web.ts", + "tls": "https://cdn.esm.sh/@types/node@16.11.9/tls.d.ts", + "url": "https://cdn.esm.sh/@types/node@16.11.9/url.d.ts" + } +} diff --git a/keyStack.ts b/keyStack.ts index a6ffd275..6a5b4833 100644 --- a/keyStack.ts +++ b/keyStack.ts @@ -68,10 +68,26 @@ export class KeyStack { } [Symbol.for("Deno.customInspect")](inspect: (value: unknown) => string) { - return `${this.constructor.name} ${ - inspect({ - length: this.length, - }) + const { length } = this; + return `${this.constructor.name} ${inspect({ length })}`; + } + + [Symbol.for("nodejs.util.inspect.custom")]( + depth: number, + // deno-lint-ignore no-explicit-any + options: any, + inspect: (value: unknown, options?: unknown) => string, + ) { + if (depth < 0) { + return options.stylize(`[${this.constructor.name}]`, "special"); + } + + const newOptions = Object.assign({}, options, { + depth: options.depth === null ? null : options.depth - 1, + }); + const { length } = this; + return `${options.stylize(this.constructor.name, "special")} ${ + inspect({ length }, newOptions) }`; } } diff --git a/multipart.ts b/multipart.ts index b9743c26..1261e0f0 100644 --- a/multipart.ts +++ b/multipart.ts @@ -492,4 +492,22 @@ export class FormDataReader { [Symbol.for("Deno.customInspect")](inspect: (value: unknown) => string) { return `${this.constructor.name} ${inspect({})}`; } + + [Symbol.for("nodejs.util.inspect.custom")]( + depth: number, + // deno-lint-ignore no-explicit-any + options: any, + inspect: (value: unknown, options?: unknown) => string, + ) { + if (depth < 0) { + return options.stylize(`[${this.constructor.name}]`, "special"); + } + + const newOptions = Object.assign({}, options, { + depth: options.depth === null ? null : options.depth - 1, + }); + return `${options.stylize(this.constructor.name, "special")} ${ + inspect({}, newOptions) + }`; + } } diff --git a/multipart_test.ts b/multipart_test.ts index fdd217c8..011f1b5b 100644 --- a/multipart_test.ts +++ b/multipart_test.ts @@ -11,7 +11,7 @@ import { import { httpErrors } from "./httpError.ts"; import { FormDataFile, FormDataReader } from "./multipart.ts"; import { equals, lookup, parse } from "./deps.ts"; -import { stripEol } from "./util.ts"; +import { isNode, stripEol } from "./util.ts"; const { test } = Deno; @@ -97,6 +97,7 @@ Content-Type: ${mediaType} test({ name: "multipart - FormDataReader - .read() basic", + ignore: isNode(), async fn() { const body = createBody(fixture); const fdr = new FormDataReader(fixtureContentType, body); @@ -116,6 +117,7 @@ test({ test({ name: "multipart - FormDataReader - .stream() basic", + ignore: isNode(), async fn() { const body = createBody(fixture); const fdr = new FormDataReader(fixtureContentType, body); @@ -137,6 +139,7 @@ test({ test({ name: "multipart - FormDataReader - .stream() file default", + ignore: isNode(), async fn() { const [expected, body] = createBodyFile("fileA", "./fixtures/test.jpg"); const fdr = new FormDataReader(fixtureContentType, body); @@ -182,6 +185,7 @@ test({ test({ name: "multipart - FormDataReader - .stream() file maxSize overflow", + ignore: isNode(), async fn() { const [expected, body] = createBodyFile("fileA", "./fixtures/test.jpg"); const fdr = new FormDataReader(fixtureContentType, body); @@ -217,6 +221,7 @@ test({ test({ name: "multipart - FormDataReader - .read() custom content type", + ignore: isNode(), async fn() { const [, body] = createBodyFile( "fileA", @@ -246,6 +251,7 @@ test({ test({ name: "multipart - FormDataReader - body with mbc filename part", + ignore: isNode(), async fn() { const body = createBody(fixtureUtf8Filename); const fdr = new FormDataReader(fixtureContentType, body); @@ -262,6 +268,7 @@ test({ test({ name: "multipart - FormDataReader - .read() no extra CRLF at the end of result file if origin file doesn't end with newline", + ignore: isNode(), async fn() { const body = createBody(fixtureNoNewline); const fdr = new FormDataReader(fixtureContentType, body); diff --git a/node_shims.ts b/node_shims.ts new file mode 100644 index 00000000..9d86cb12 --- /dev/null +++ b/node_shims.ts @@ -0,0 +1,38 @@ +// Copyright 2018-2022 the oak authors. All rights reserved. MIT license. + +export class ErrorEvent extends Event { + #message: string; + #filename: string; + #lineno: number; + #colno: number; + // deno-lint-ignore no-explicit-any + #error: any; + + get message(): string { + return this.#message; + } + get filename(): string { + return this.#filename; + } + get lineno(): number { + return this.#lineno; + } + get colno(): number { + return this.#colno; + } + // deno-lint-ignore no-explicit-any + get error(): any { + return this.#error; + } + + constructor(type: string, eventInitDict: ErrorEventInit = {}) { + super(type, eventInitDict); + const { message = "error", filename = "", lineno = 0, colno = 0, error } = + eventInitDict; + this.#message = message; + this.#filename = filename; + this.#lineno = lineno; + this.#colno = colno; + this.#error = error; + } +} diff --git a/request.ts b/request.ts index 8ffcb4d0..c8992e35 100644 --- a/request.ts +++ b/request.ts @@ -12,8 +12,7 @@ import type { BodyText, } from "./body.ts"; import { RequestBody } from "./body.ts"; -import type { NativeRequest } from "./http_server_native.ts"; -import type { HTTPMethods } from "./types.d.ts"; +import type { HTTPMethods, ServerRequest } from "./types.d.ts"; import { preferredCharsets } from "./negotiation/charset.ts"; import { preferredEncodings } from "./negotiation/encoding.ts"; import { preferredLanguages } from "./negotiation/language.ts"; @@ -31,7 +30,7 @@ export class Request { #body: RequestBody; #proxy: boolean; #secure: boolean; - #serverRequest: NativeRequest; + #serverRequest: ServerRequest; #url?: URL; #getRemoteAddr(): string { @@ -83,7 +82,7 @@ export class Request { } /** Set to the value of the _original_ Deno server request. */ - get originalRequest(): NativeRequest { + get originalRequest(): ServerRequest { return this.#serverRequest; } @@ -100,8 +99,10 @@ export class Request { // so we will try to use that URL here, but default back to old logic // if the URL isn't valid. try { - this.#url = new URL(serverRequest.rawUrl); - return this.#url; + if (serverRequest.rawUrl) { + this.#url = new URL(serverRequest.rawUrl); + return this.#url; + } } catch { // we don't care about errors here } @@ -130,14 +131,17 @@ export class Request { } constructor( - serverRequest: NativeRequest, + serverRequest: ServerRequest, proxy = false, secure = false, ) { this.#proxy = proxy; this.#secure = secure; this.#serverRequest = serverRequest; - this.#body = new RequestBody(serverRequest.request); + this.#body = new RequestBody( + serverRequest.getBody(), + serverRequest.headers, + ); } /** Returns an array of media types, accepted by the requestor, in order of @@ -249,7 +253,7 @@ export class Request { [Symbol.for("Deno.customInspect")](inspect: (value: unknown) => string) { const { hasBody, headers, ip, ips, method, secure, url } = this; - return `Request ${ + return `${this.constructor.name} ${ inspect({ hasBody, headers, @@ -261,4 +265,26 @@ export class Request { }) }`; } + + [Symbol.for("nodejs.util.inspect.custom")]( + depth: number, + // deno-lint-ignore no-explicit-any + options: any, + inspect: (value: unknown, options?: unknown) => string, + ) { + if (depth < 0) { + return options.stylize(`[${this.constructor.name}]`, "special"); + } + + const newOptions = Object.assign({}, options, { + depth: options.depth === null ? null : options.depth - 1, + }); + const { hasBody, headers, ip, ips, method, secure, url } = this; + return `${options.stylize(this.constructor.name, "special")} ${ + inspect( + { hasBody, headers, ip, ips, method, secure, url }, + newOptions, + ) + }`; + } } diff --git a/request_test.ts b/request_test.ts index 4081b16c..65594251 100644 --- a/request_test.ts +++ b/request_test.ts @@ -11,6 +11,7 @@ import { import { NativeRequest } from "./http_server_native.ts"; import type { NativeRequestOptions } from "./http_server_native.ts"; import { Request } from "./request.ts"; +import { isNode } from "./util.ts"; const { test } = Deno; @@ -291,7 +292,9 @@ test({ }), ), ), - `Request {\n hasBody: false,\n headers: Headers { host: "localhost" },\n ip: "",\n ips: [],\n method: "GET",\n secure: false,\n url: "http://localhost/foo?bar=baz&qat=qux"\n}`, + isNode() + ? `Request {\n hasBody: false,\n headers: HeadersList(2) [ 'host', 'localhost' ],\n ip: '',\n ips: [],\n method: 'GET',\n secure: false,\n url: URL {\n href: 'http://localhost/foo?bar=baz&qat=qux',\n origin: 'http://localhost',\n protocol: 'http:',\n username: '',\n password: '',\n host: 'localhost',\n hostname: 'localhost',\n port: '',\n pathname: '/foo',\n search: '?bar=baz&qat=qux',\n searchParams: URLSearchParams { 'bar' => 'baz', 'qat' => 'qux' },\n hash: ''\n }\n}` + : `Request {\n hasBody: false,\n headers: Headers { host: "localhost" },\n ip: "",\n ips: [],\n method: "GET",\n secure: false,\n url: "http://localhost/foo?bar=baz&qat=qux"\n}`, ); }, }); diff --git a/response.ts b/response.ts index e7c21eb7..a9f33066 100644 --- a/response.ts +++ b/response.ts @@ -60,7 +60,8 @@ export async function convertBodyToBodyInit( ArrayBuffer.isView(body) || body instanceof ArrayBuffer || body instanceof Blob || body instanceof URLSearchParams ) { - result = body; + // deno-lint-ignore no-explicit-any + result = body as any; } else if (body instanceof ReadableStream) { result = body.pipeThrough(new Uint8ArrayTransformStream()); } else if (body instanceof FormData) { @@ -316,6 +317,30 @@ export class Response { [Symbol.for("Deno.customInspect")](inspect: (value: unknown) => string) { const { body, headers, status, type, writable } = this; - return `Response ${inspect({ body, headers, status, type, writable })}`; + return `${this.constructor.name} ${ + inspect({ body, headers, status, type, writable }) + }`; + } + + [Symbol.for("nodejs.util.inspect.custom")]( + depth: number, + // deno-lint-ignore no-explicit-any + options: any, + inspect: (value: unknown, options?: unknown) => string, + ) { + if (depth < 0) { + return options.stylize(`[${this.constructor.name}]`, "special"); + } + + const newOptions = Object.assign({}, options, { + depth: options.depth === null ? null : options.depth - 1, + }); + const { body, headers, status, type, writable } = this; + return `${options.stylize(this.constructor.name, "special")} ${ + inspect( + { body, headers, status, type, writable }, + newOptions, + ) + }`; } } diff --git a/response_test.ts b/response_test.ts index bdb353c0..24dead80 100644 --- a/response_test.ts +++ b/response_test.ts @@ -4,6 +4,7 @@ import { Status } from "./deps.ts"; import { assert, assertEquals, assertThrows } from "./test_deps.ts"; import type { Request } from "./request.ts"; import { REDIRECT_BACK, Response } from "./response.ts"; +import { isNode } from "./util.ts"; const { test } = Deno; @@ -441,7 +442,9 @@ test({ fn() { assertEquals( Deno.inspect(new Response(createMockRequest())), - `Response { body: undefined, headers: Headers {}, status: 404, type: undefined, writable: true }`, + isNode() + ? `Response {\n body: undefined,\n headers: HeadersList(0) [],\n status: 404,\n type: undefined,\n writable: true\n}` + : `Response { body: undefined, headers: Headers {}, status: 404, type: undefined, writable: true }`, ); }, }); diff --git a/router.ts b/router.ts index c8ab6b4d..48518209 100644 --- a/router.ts +++ b/router.ts @@ -393,6 +393,34 @@ class Layer< }) }`; } + + [Symbol.for("nodejs.util.inspect.custom")]( + depth: number, + // deno-lint-ignore no-explicit-any + options: any, + inspect: (value: unknown, options?: unknown) => string, + ) { + if (depth < 0) { + return options.stylize(`[${this.constructor.name}]`, "special"); + } + + const newOptions = Object.assign({}, options, { + depth: options.depth === null ? null : options.depth - 1, + }); + return `${options.stylize(this.constructor.name, "special")} ${ + inspect( + { + methods: this.methods, + middleware: this.stack, + options: this.#opts, + paramNames: this.#paramNames.map((key) => key.name), + path: this.path, + regexp: this.#regexp, + }, + newOptions, + ) + }`; + } } /** An interface for registering middleware that will run when certain HTTP @@ -1228,4 +1256,25 @@ export class Router< inspect({ "#params": this.#params, "#stack": this.#stack }) }`; } + + [Symbol.for("nodejs.util.inspect.custom")]( + depth: number, + // deno-lint-ignore no-explicit-any + options: any, + inspect: (value: unknown, options?: unknown) => string, + ) { + if (depth < 0) { + return options.stylize(`[${this.constructor.name}]`, "special"); + } + + const newOptions = Object.assign({}, options, { + depth: options.depth === null ? null : options.depth - 1, + }); + return `${options.stylize(this.constructor.name, "special")} ${ + inspect( + { "#params": this.#params, "#stack": this.#stack }, + newOptions, + ) + }`; + } } diff --git a/send.ts b/send.ts index 731ed24f..9fb25436 100644 --- a/send.ts +++ b/send.ts @@ -272,6 +272,10 @@ export async function send( if (err instanceof Deno.errors.NotFound) { throw createHttpError(404, err.message); } + // TODO(@kitsonk) remove when https://github.com/denoland/node_deno_shims/issues/87 resolved + if (err instanceof Error && err.message.startsWith("ENOENT:")) { + throw createHttpError(404, err.message); + } throw createHttpError( 500, err instanceof Error ? err.message : "[non-error thrown]", diff --git a/send_test.ts b/send_test.ts index ad829ace..c0140577 100644 --- a/send_test.ts +++ b/send_test.ts @@ -13,6 +13,7 @@ import * as etag from "./etag.ts"; import { httpErrors } from "./httpError.ts"; import type { RouteParams } from "./router.ts"; import { send } from "./send.ts"; +import { isNode } from "./util.ts"; const { test } = Deno; @@ -136,6 +137,7 @@ test({ root: "./fixtures", }); } catch (e) { + console.log(e); assert(e instanceof httpErrors.NotFound); didThrow = true; } @@ -288,7 +290,7 @@ test({ const { context } = setup("/test.json"); const fixture = await Deno.readFile("./fixtures/test.json"); await send(context, context.request.url.pathname, { - root: "../oak/fixtures", + root: isNode() ? "../esm/fixtures" : "../oak/fixtures", maxbuffer: 0, }); const nativeResponse = await context.response.toDomResponse(); diff --git a/server_sent_event.ts b/server_sent_event.ts index 8c3cda4e..c8468d0e 100644 --- a/server_sent_event.ts +++ b/server_sent_event.ts @@ -123,14 +123,12 @@ export class ServerSentEvent extends Event { } } -const responseHeaders = new Headers( - [ - ["Connection", "Keep-Alive"], - ["Content-Type", "text/event-stream"], - ["Cache-Control", "no-cache"], - ["Keep-Alive", `timeout=${Number.MAX_SAFE_INTEGER}`], - ], -); +const RESPONSE_HEADERS = [ + ["Connection", "Keep-Alive"], + ["Content-Type", "text/event-stream"], + ["Cache-Control", "no-cache"], + ["Keep-Alive", `timeout=${Number.MAX_SAFE_INTEGER}`], +] as const; export interface ServerSentEventTarget extends EventTarget { /** Is set to `true` if events cannot be sent to the remote connection. @@ -219,7 +217,10 @@ export class SSEStreamTarget extends EventTarget #closed = false; #context: Context; #controller?: ReadableStreamDefaultController; - #keepAliveId?: number; + // we are ignoring any here, because when exporting to npn/Node.js, the timer + // handle isn't a number. + // deno-lint-ignore no-explicit-any + #keepAliveId?: any; // deno-lint-ignore no-explicit-any #error(error: any) { @@ -275,7 +276,7 @@ export class SSEStreamTarget extends EventTarget context.response.headers.set(key, value); } } - for (const [key, value] of responseHeaders) { + for (const [key, value] of RESPONSE_HEADERS) { context.response.headers.set(key, value); } @@ -336,4 +337,25 @@ export class SSEStreamTarget extends EventTarget inspect({ "#closed": this.#closed, "#context": this.#context }) }`; } + + [Symbol.for("nodejs.util.inspect.custom")]( + depth: number, + // deno-lint-ignore no-explicit-any + options: any, + inspect: (value: unknown, options?: unknown) => string, + ) { + if (depth < 0) { + return options.stylize(`[${this.constructor.name}]`, "special"); + } + + const newOptions = Object.assign({}, options, { + depth: options.depth === null ? null : options.depth - 1, + }); + return `${options.stylize(this.constructor.name, "special")} ${ + inspect( + { "#closed": this.#closed, "#context": this.#context }, + newOptions, + ) + }`; + } } diff --git a/server_sent_event_test.ts b/server_sent_event_test.ts index bf1974ad..4c04c867 100644 --- a/server_sent_event_test.ts +++ b/server_sent_event_test.ts @@ -8,6 +8,7 @@ import type { Application } from "./application.ts"; import { Context } from "./context.ts"; import { NativeRequest } from "./http_server_native.ts"; import { ServerSentEvent, SSEStreamTarget } from "./server_sent_event.ts"; +import { isNode } from "./util.ts"; const { test } = Deno; @@ -119,7 +120,7 @@ test({ await sse.close(); assert(env.response); assert(env.response.body); - const reader = env.response.body.getReader(); + const reader = (env.response.body as any).getReader(); await reader.closed; assertEquals(env.response.status, 200); assertEquals(env.response.headers.get("content-type"), "text/event-stream"); @@ -252,7 +253,9 @@ test({ const context = new Context(createMockApp(), request, {}); assertEquals( Deno.inspect(new SSEStreamTarget(context)), - `SSEStreamTarget {\n "#closed": false,\n "#context": Context {\n app: EventTarget { + isNode() + ? `SSEStreamTarget {\n '#closed': false,\n '#context': Context {\n app: EventTarget,\n cookies: [Cookies],\n isUpgradable: false,\n respond: true,\n request: [Request],\n response: [Response],\n socket: undefined,\n state: {}\n }\n}` + : `SSEStreamTarget {\n "#closed": false,\n "#context": Context {\n app: EventTarget { [Symbol()]: { assignedSlot: false, hasActivationBehavior: false, diff --git a/test_deps.ts b/test_deps.ts index aa10a9df..36781bca 100644 --- a/test_deps.ts +++ b/test_deps.ts @@ -1,10 +1,10 @@ // Copyright 2018-2022 the oak authors. All rights reserved. MIT license. -export { Buffer } from "https://deno.land/std@0.123.0/io/buffer.ts"; -export { BufWriter } from "https://deno.land/std@0.123.0/io/bufio.ts"; -export { StringReader } from "https://deno.land/std@0.123.0/io/readers.ts"; -export { StringWriter } from "https://deno.land/std@0.123.0/io/writers.ts"; -export { writeAllSync } from "https://deno.land/std@0.123.0/io/util.ts"; +export { Buffer } from "https://deno.land/std@0.126.0/io/buffer.ts"; +export { BufWriter } from "https://deno.land/std@0.126.0/io/bufio.ts"; +export { StringReader } from "https://deno.land/std@0.126.0/io/readers.ts"; +export { StringWriter } from "https://deno.land/std@0.126.0/io/writers.ts"; +export { writeAllSync } from "https://deno.land/std@0.126.0/streams/conversion.ts"; export { assert, assertEquals, @@ -12,4 +12,4 @@ export { assertStrictEquals, assertThrows, unreachable, -} from "https://deno.land/std@0.123.0/testing/asserts.ts"; +} from "https://deno.land/std@0.126.0/testing/asserts.ts"; diff --git a/testing.ts b/testing.ts index 4bd86e9c..ac738e95 100644 --- a/testing.ts +++ b/testing.ts @@ -32,6 +32,22 @@ export function createMockApp< [Symbol.for("Deno.customInspect")]() { return "MockApplication {}"; }, + [Symbol.for("nodejs.util.inspect.custom")]( + depth: number, + options: any, + inspect: (value: unknown, options?: unknown) => string, + ) { + if (depth < 0) { + return options.stylize(`[MockApplication]`, "special"); + } + + const newOptions = Object.assign({}, options, { + depth: options.depth === null ? null : options.depth - 1, + }); + return `${options.stylize("MockApplication", "special")} ${ + inspect({}, newOptions) + }`; + }, } as any; return app; } @@ -140,6 +156,22 @@ export function createMockContext< [Symbol.for("Deno.customInspect")]() { return `MockContext {}`; }, + [Symbol.for("nodejs.util.inspect.custom")]( + depth: number, + options: any, + inspect: (value: unknown, options?: unknown) => string, + ) { + if (depth < 0) { + return options.stylize(`[MockContext]`, "special"); + } + + const newOptions = Object.assign({}, options, { + depth: options.depth === null ? null : options.depth - 1, + }); + return `${options.stylize("MockContext", "special")} ${ + inspect({}, newOptions) + }`; + }, } as unknown) as RouterContext; } diff --git a/types.d.ts b/types.d.ts index 438316a0..88592384 100644 --- a/types.d.ts +++ b/types.d.ts @@ -64,13 +64,35 @@ export type HTTPMethods = | "POST" | "DELETE"; +export interface Listener { + addr: { hostname: string; port: number }; +} + export interface Server extends AsyncIterable { close(): void; - listen(): Deno.Listener; + listen(): Listener; [Symbol.asyncIterator](): AsyncIterableIterator; } -export interface ServerConstructor { +export interface ServerRequestBody { + body: ReadableStream | null; + readBody: () => Promise; +} + +export interface ServerRequest { + readonly remoteAddr: string | undefined; + readonly headers: Headers; + readonly method: string; + readonly rawUrl?: string; + readonly url: string; + // deno-lint-ignore no-explicit-any + error(reason?: any): void; + getBody(): ServerRequestBody; + respond(response: Response): Promise; + upgrade?(options?: UpgradeWebSocketOptions): WebSocket; +} + +export interface ServerConstructor { // deno-lint-ignore no-explicit-any new >( app: Application, diff --git a/util.ts b/util.ts index be066168..909e4661 100644 --- a/util.ts +++ b/util.ts @@ -432,13 +432,17 @@ export function encodeBase64Safe(data: string | ArrayBuffer): string { return base64.encode(data).replace(/\/|\+|=/g, (c) => replacements[c]); } +export function isNode(): boolean { + return "process" in globalThis && "global" in globalThis; +} + export function importKey(key: Key): Promise { if (typeof key === "string") { key = encoder.encode(key); } else if (Array.isArray(key)) { key = new Uint8Array(key); } - return globalThis.crypto.subtle.importKey( + return crypto.subtle.importKey( "raw", key, {