diff --git a/Makefile b/Makefile index b64a97d..8ba48b7 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ types: deno run -A tools/gen_types.ts deno fmt types/**/*.ts test: - deno test -A *_test.ts + deno test -A --unstable build: docker build -t servest/site . bench: diff --git a/_adapter.ts b/_adapter.ts new file mode 100644 index 0000000..e5e1759 --- /dev/null +++ b/_adapter.ts @@ -0,0 +1,122 @@ +// Copyright 2019-2020 Yusuke Sakurai. All rights reserved. MIT license. +import { createBodyParser } from "./body_parser.ts"; +import { readRequest, setupBodyInit, writeResponse } from "./serveio.ts"; +import { + BodyReader, + IncomingRequest, + ServeOptions, + ServerResponse, +} from "./server.ts"; +import { BufReader, BufWriter } from "./vendor/https/deno.land/std/io/bufio.ts"; +import { closableBodyReader, noopReader, streamReader } from "./_readers.ts"; + +export interface HttpApiAdapter { + next(opts: ServeOptions): Promise; + respond(resp: ServerResponse): Promise; + close(): void; +} + +export function classicAdapter({ conn, bufReader, bufWriter }: { + conn: Deno.Conn; + bufReader: BufReader; + bufWriter: BufWriter; +}): HttpApiAdapter { + return { + async next(opts) { + return readRequest(bufReader, opts); + }, + async respond(resp) { + await writeResponse(bufWriter, resp); + }, + close() { + conn.close(); + }, + }; +} + +export interface RequestEvent { + readonly request: Request; + respondWith(r: Response | Promise): void; +} + +export interface HttpConn extends AsyncIterable { + readonly rid: number; + + nextRequest(): Promise; + close(): void; +} + +export function nativeAdapter(conn: Deno.Conn): HttpApiAdapter { + // @ts-ignore + const http: HttpConn = Deno.serveHttp(conn); + let ev: RequestEvent | null; + let closed = false; + return { + async next() { + ev = await http.nextRequest(); + if (!ev) { + closed = true; + return; + } + return requestFromEvent(ev); + }, + async respond(resp) { + if (!ev) throw new Error("Unexpected respond"); + const headers = resp.headers ?? new Headers(); + let body: BodyInit | undefined; + if (resp.body) { + const [_body, contentType] = setupBodyInit(resp.body); + body = _body; + if (!headers.has("content-type")) { + headers.set("content-type", contentType); + } + } + // TODO: trailer + try { + await ev.respondWith( + new Response(body, { + status: resp.status, + headers, + }), + ); + } finally { + ev = null; + } + }, + close() { + if (!closed) { + http.close(); + } + }, + }; +} + +function requestFromEvent(ev: RequestEvent): IncomingRequest { + const { pathname, search, searchParams } = new URL( + ev.request.url, + "http://dummy", + ); + const { method, headers } = ev.request; + const contentType = headers.get("content-type") ?? ""; + let body: BodyReader; + if (ev.request.body) { + body = closableBodyReader(streamReader(ev.request.body)); + } else { + body = closableBodyReader(noopReader()); + } + const bodyParser = createBodyParser({ + reader: body, + contentType, + }); + return { + url: pathname + search, + path: pathname, + query: searchParams, + method, + proto: "HTTP/1.1", + headers, + cookies: new Map(), + body, + ...bodyParser, + }; +} diff --git a/_adapter_test.ts b/_adapter_test.ts new file mode 100644 index 0000000..1f38259 --- /dev/null +++ b/_adapter_test.ts @@ -0,0 +1,57 @@ +// Copyright 2019-2020 Yusuke Sakurai. All rights reserved. MIT license. +import { BufReader, BufWriter } from "./vendor/https/deno.land/std/io/bufio.ts"; +import { assertEquals } from "./vendor/https/deno.land/std/testing/asserts.ts"; +import { classicAdapter, nativeAdapter } from "./_adapter.ts"; +import { group } from "./_test_util.ts"; + +group("adapter", ({ test }) => { + async function doTest() { + const resp = await fetch("http://localhost:8899", { + method: "POST", + body: "hello", + }); + assertEquals(resp.status, 200); + assertEquals(resp.headers.get("content-type"), "text/html"); + assertEquals(await resp.text(), "hello"); + } + test("classic", async () => { + async function serve() { + const l = Deno.listen({ port: 8899 }); + const conn = await l.accept(); + const bufReader = new BufReader(conn); + const bufWriter = new BufWriter(conn); + const adapter = classicAdapter({ conn, bufReader, bufWriter }); + const req = await adapter.next({}); + await adapter.respond({ + status: 200, + headers: new Headers({ + "content-type": "text/html", + }), + body: req!.body, + }); + adapter.close(); + l.close(); + } + serve(); + await doTest(); + }); + test("native", async () => { + async function serve() { + const l = Deno.listen({ port: 8899 }); + const conn = await l.accept(); + const adapter = nativeAdapter(conn); + const req = await adapter.next({}); + await adapter.respond({ + status: 200, + headers: new Headers({ + "content-type": "text/html", + }), + body: req!.body, + }); + adapter.close(); + l.close(); + } + serve(); + await doTest(); + }); +}); diff --git a/_readers.ts b/_readers.ts index f5f3f2e..06ba07a 100644 --- a/_readers.ts +++ b/_readers.ts @@ -79,3 +79,11 @@ export function streamReader(stream: ReadableStream): Deno.Reader { }; return { read }; } + +export function noopReader(): Deno.Reader { + return { + async read() { + return null; + }, + }; +} diff --git a/_version.ts b/_version.ts index d401166..bd8e9b5 100644 --- a/_version.ts +++ b/_version.ts @@ -1,2 +1,2 @@ // Copyright 2019-2020 Yusuke Sakurai. All rights reserved. MIT license. -export const Version = "v1.1.7"; +export const Version = "v1.3.0"; diff --git a/responder.ts b/responder.ts index 3507a7a..d38ad57 100644 --- a/responder.ts +++ b/responder.ts @@ -1,8 +1,6 @@ // Copyright 2019-2020 Yusuke Sakurai. All rights reserved. MIT license. -import Writer = Deno.Writer; -import { HttpBody, ServerResponse } from "./server.ts"; +import { ServerResponse } from "./server.ts"; import { CookieSetter, cookieSetter } from "./cookie.ts"; -import { writeResponse } from "./serveio.ts"; import { basename, extname } from "./vendor/https/deno.land/std/path/mod.ts"; import { contentTypeByExt } from "./media_types.ts"; /** Basic responder for http response */ @@ -45,9 +43,7 @@ export interface Responder extends CookieSetter { /** create ServerResponder object */ export function createResponder( - w: Writer, - onResponse: (r: ServerResponse) => Promise = (resp) => - writeResponse(w, resp), + onResponse: (resp: ServerResponse) => Promise, ): Responder { const responseHeaders = new Headers(); const cookie = cookieSetter(responseHeaders); diff --git a/responder_test.ts b/responder_test.ts index eae1039..8d07680 100644 --- a/responder_test.ts +++ b/responder_test.ts @@ -6,13 +6,16 @@ import { assertThrowsAsync, } from "./vendor/https/deno.land/std/testing/asserts.ts"; import { StringReader } from "./vendor/https/deno.land/std/io/readers.ts"; -import { readResponse } from "./serveio.ts"; +import { readResponse, writeResponse } from "./serveio.ts"; import { group } from "./_test_util.ts"; group("responder", (t) => { + function _createResponder(w: Deno.Writer) { + return createResponder((resp) => writeResponse(w, resp)); + } t.test("basic", async function () { const w = new Deno.Buffer(); - const res = createResponder(w); + const res = _createResponder(w); assert(!res.isResponded()); await res.respond({ status: 200, @@ -30,7 +33,7 @@ group("responder", (t) => { t.test("respond() should throw if already responded", async function () { const w = new Deno.Buffer(); - const res = createResponder(w); + const res = _createResponder(w); await res.respond({ status: 200, headers: new Headers(), @@ -48,7 +51,7 @@ group("responder", (t) => { t.test("sendFile() basic", async function () { const w = new Deno.Buffer(); - const res = createResponder(w); + const res = _createResponder(w); await res.sendFile("./fixtures/sample.txt"); const resp = await readResponse(w); assertEquals(resp.status, 200); @@ -58,7 +61,7 @@ group("responder", (t) => { t.test("sendFile() should throw if file not found", async () => { const w = new Deno.Buffer(); - const res = createResponder(w); + const res = _createResponder(w); await assertThrowsAsync( () => res.sendFile("./fixtures/not-found"), Deno.errors.NotFound, @@ -67,7 +70,7 @@ group("responder", (t) => { t.test("sendFile() with attachment", async () => { const w = new Deno.Buffer(); - const res = createResponder(w); + const res = _createResponder(w); await res.sendFile("./fixtures/sample.txt", { contentDisposition: "inline", }); @@ -79,7 +82,7 @@ group("responder", (t) => { t.test("sendFile() with attachment", async () => { const w = new Deno.Buffer(); - const res = createResponder(w); + const res = _createResponder(w); await res.sendFile("./fixtures/sample.txt", { contentDisposition: "attachment", }); @@ -94,7 +97,7 @@ group("responder", (t) => { t.test("redirect() should set Location header", async () => { const w = new Deno.Buffer(); - const res = createResponder(w); + const res = _createResponder(w); await res.redirect("/index.html"); const { status, headers } = await readResponse(w); assertEquals(status, 302); @@ -103,7 +106,7 @@ group("responder", (t) => { t.test("redirect() should use partial body for response", async () => { const w = new Deno.Buffer(); - const res = createResponder(w); + const res = _createResponder(w); await res.redirect("/", { status: 303, headers: new Headers({ "content-type": "text/plain" }), @@ -117,7 +120,7 @@ group("responder", (t) => { t.test("resirect() should throw error if status code is not in 300~399", async () => { const w = new Deno.Buffer(); - const res = createResponder(w); + const res = _createResponder(w); await assertThrowsAsync( async () => { await res.redirect("/", { status: 200 }); @@ -129,7 +132,7 @@ group("responder", (t) => { t.test("markResponded()", async () => { const w = new Deno.Buffer(); - const res = createResponder(w); + const res = _createResponder(w); res.markAsResponded(200); assertEquals(res.isResponded(), true); assertEquals(res.respondedStatus(), 200); diff --git a/serveio.ts b/serveio.ts index e0b0916..8ddd858 100644 --- a/serveio.ts +++ b/serveio.ts @@ -38,6 +38,7 @@ export function initServeOptions(opts: ServeOptions = {}): ServeOptions { let cancel = opts.cancel; let keepAliveTimeout = kDefaultKeepAliveTimeout; let readTimeout = kDefaultKeepAliveTimeout; + let useNative = false; if (opts.keepAliveTimeout !== void 0) { keepAliveTimeout = opts.keepAliveTimeout; } @@ -46,7 +47,7 @@ export function initServeOptions(opts: ServeOptions = {}): ServeOptions { } assert(keepAliveTimeout >= 0, "keepAliveTimeout must be >= 0"); assert(readTimeout >= 0, "readTimeout must be >= 0"); - return { cancel, keepAliveTimeout, readTimeout }; + return { cancel, keepAliveTimeout, readTimeout, useNative }; } /** @@ -264,6 +265,32 @@ export function setupBody( } return [r, chunked ? undefined : len]; } + +export function setupBodyInit(body: HttpBody): [BodyInit, string] { + if (typeof body === "string") { + return [body, "text/plain; charset=UTF-8"]; + } else if (body instanceof Uint8Array) { + return [body, "application/octet-stream"]; + } else if (body instanceof ReadableStream) { + return [body, "application/octet-stream"]; + } else { + const buf = new Uint8Array(2048); + return [ + new ReadableStream({ + async pull(ctrl) { + const len = await body.read(buf); + if (len != null) { + ctrl.enqueue(buf.subarray(0, len)); + } else { + ctrl.close(); + } + }, + }), + "application/octet-stream", + ]; + } +} + /** write http response to writer. Content-Length, Transfer-Encoding headers are set if needed */ export async function writeResponse( w: Writer, diff --git a/serveio_test.ts b/serveio_test.ts index 81835c8..cabc0c4 100644 --- a/serveio_test.ts +++ b/serveio_test.ts @@ -5,6 +5,7 @@ import { readRequest, readResponse, setupBody, + setupBodyInit, writeRequest, writeResponse, } from "./serveio.ts"; @@ -12,9 +13,9 @@ import { assertEquals } from "./vendor/https/deno.land/std/testing/asserts.ts"; import { StringReader } from "./vendor/https/deno.land/std/io/readers.ts"; import { encode } from "./_util.ts"; import Buffer = Deno.Buffer; -import copy = Deno.copy; import { ServerResponse } from "./server.ts"; import { group } from "./_test_util.ts"; +import { noopReader } from "./_readers.ts"; group("serveio", (t) => { t.test("serveioReadRequestGet", async function serveioReadRequestGet() { @@ -421,6 +422,32 @@ group("serveio/setupBody", (t) => { }); }); +group("serveio/setupBodyInit", ({ test }) => { + test("string", () => { + const [body, ct] = setupBodyInit(""); + assertEquals(body, ""); + assertEquals(ct, "text/plain; charset=UTF-8"); + }); + test("Uint8Array", () => { + const arr = new Uint8Array(); + const [body, ct] = setupBodyInit(arr); + assertEquals(body, arr); + assertEquals(ct, "application/octet-stream"); + }); + test("Uint8Array", () => { + const stream = new ReadableStream(); + const [body, ct] = setupBodyInit(stream); + assertEquals(body, stream); + assertEquals(ct, "application/octet-stream"); + }); + test("Reader", () => { + const reader = noopReader(); + const [body, ct] = setupBodyInit(reader); + assertEquals((body instanceof ReadableStream), true); + assertEquals(ct, "application/octet-stream"); + }); +}); + group("serveio/keep-alive", (t) => { t.test("serveioParseKeepAlive", function () { const ka = parseKeepAlive( diff --git a/server.ts b/server.ts index 0d35217..ab8fb1b 100644 --- a/server.ts +++ b/server.ts @@ -1,11 +1,12 @@ // Copyright 2019-2020 Yusuke Sakurai. All rights reserved. MIT license. import { BufReader, BufWriter } from "./vendor/https/deno.land/std/io/bufio.ts"; import { deferred } from "./vendor/https/deno.land/std/async/mod.ts"; -import { initServeOptions, readRequest, writeResponse } from "./serveio.ts"; +import { initServeOptions } from "./serveio.ts"; import { createResponder, Responder } from "./responder.ts"; import { promiseInterrupter, promiseWaitQueue } from "./_util.ts"; import { createDataHolder, DataHolder } from "./data_holder.ts"; import { BodyParser } from "./body_parser.ts"; +import { classicAdapter, HttpApiAdapter, nativeAdapter } from "./_adapter.ts"; export type HttpBody = | string @@ -107,6 +108,8 @@ export interface ServeOptions { keepAliveTimeout?: number; /** read timeout for all read request. ms. default=75000(ms) */ readTimeout?: number; + /** use native http binding api (needs --unstable) */ + useNative?: boolean; } export type ServeListener = Deno.Closer; @@ -185,10 +188,10 @@ export function handleKeepAliveConn( const bufReader = new BufReader(conn); const bufWriter = new BufWriter(conn); const originalOpts = opts; - const q = promiseWaitQueue((resp) => - writeResponse(bufWriter, resp) - ); - + const adapter = opts.useNative + ? nativeAdapter(conn) + : classicAdapter({ conn, bufReader, bufWriter }); + const q = promiseWaitQueue(adapter.respond); // ignore keepAliveTimeout and use readTimeout for the first time scheduleReadRequest({ keepAliveTimeout: opts.readTimeout, @@ -196,36 +199,38 @@ export function handleKeepAliveConn( cancel: opts.cancel, }); - function scheduleReadRequest(opts: ServeOptions) { - processRequest(opts) + async function scheduleReadRequest(opts: ServeOptions) { + processRequest(adapter, opts) .then((v) => { if (v) scheduleReadRequest(v); }) .catch(() => { - conn.close(); + adapter.close(); }); } async function processRequest( + adapter: HttpApiAdapter, opts: ServeOptions, ): Promise { - const baseReq = await readRequest(bufReader, opts); + const baseReq = await adapter.next(opts); + if (!baseReq) { + throw new Error("connection closed"); + } let responded: Promise = Promise.resolve(); - const onResponse = (resp: ServerResponse) => { - responded = q.enqueue(resp); - return responded; - }; - const responder = createResponder(bufWriter, onResponse); + const responder = createResponder(async (resp) => { + return responded = q.enqueue(resp); + }); const match = baseReq.url.match(/^\//); if (!match) { throw new Error("malformed url"); } const dataHolder = createDataHolder(); const req: ServerRequest = { - ...baseReq, bufWriter, bufReader, conn, + ...baseReq, ...responder, ...dataHolder, match, diff --git a/testing.ts b/testing.ts index e282257..23d1427 100644 --- a/testing.ts +++ b/testing.ts @@ -6,7 +6,7 @@ import { IncomingResponse, ServerRequest, } from "./server.ts"; -import { readResponse, setupBody } from "./serveio.ts"; +import { readResponse, setupBody, writeResponse } from "./serveio.ts"; import { createResponder } from "./responder.ts"; import { closableBodyReader } from "./_readers.ts"; import { parseCookie } from "./cookie.ts"; @@ -74,7 +74,9 @@ export function createRecorder(opts?: { }); return { ...resp, ...bodyParser }; } - const responder = createResponder(bufWriter); + const responder = createResponder(async (resp) => { + return writeResponse(bufWriter, resp); + }); const bodyParser = createBodyParser({ reader: br, contentType: headers.get("content-type") ?? "",