Skip to content

Commit

Permalink
feat: Native http binding (#158)
Browse files Browse the repository at this point in the history
  • Loading branch information
keroxp authored Apr 14, 2021
1 parent 4838953 commit f105800
Show file tree
Hide file tree
Showing 11 changed files with 285 additions and 38 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
122 changes: 122 additions & 0 deletions _adapter.ts
Original file line number Diff line number Diff line change
@@ -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<IncomingRequest | undefined>;
respond(resp: ServerResponse): Promise<void>;
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<Response>): void;
}

export interface HttpConn extends AsyncIterable<RequestEvent> {
readonly rid: number;

nextRequest(): Promise<RequestEvent | null>;
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,
};
}
57 changes: 57 additions & 0 deletions _adapter_test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
8 changes: 8 additions & 0 deletions _readers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,11 @@ export function streamReader(stream: ReadableStream<Uint8Array>): Deno.Reader {
};
return { read };
}

export function noopReader(): Deno.Reader {
return {
async read() {
return null;
},
};
}
2 changes: 1 addition & 1 deletion _version.ts
Original file line number Diff line number Diff line change
@@ -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";
8 changes: 2 additions & 6 deletions responder.ts
Original file line number Diff line number Diff line change
@@ -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 */
Expand Down Expand Up @@ -45,9 +43,7 @@ export interface Responder extends CookieSetter {

/** create ServerResponder object */
export function createResponder(
w: Writer,
onResponse: (r: ServerResponse) => Promise<void> = (resp) =>
writeResponse(w, resp),
onResponse: (resp: ServerResponse) => Promise<void>,
): Responder {
const responseHeaders = new Headers();
const cookie = cookieSetter(responseHeaders);
Expand Down
25 changes: 14 additions & 11 deletions responder_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(),
Expand All @@ -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);
Expand All @@ -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,
Expand All @@ -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",
});
Expand All @@ -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",
});
Expand All @@ -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);
Expand All @@ -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" }),
Expand All @@ -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 });
Expand All @@ -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);
Expand Down
29 changes: 28 additions & 1 deletion serveio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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 };
}

/**
Expand Down Expand Up @@ -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<Uint8Array>({
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,
Expand Down
Loading

0 comments on commit f105800

Please sign in to comment.