summaryrefslogtreecommitdiff
path: root/std/http
diff options
context:
space:
mode:
Diffstat (limited to 'std/http')
-rw-r--r--std/http/README.md77
-rw-r--r--std/http/cookie.ts133
-rw-r--r--std/http/cookie_test.ts210
-rwxr-xr-xstd/http/file_server.ts262
-rw-r--r--std/http/file_server_test.ts88
-rw-r--r--std/http/http_bench.ts15
-rw-r--r--std/http/http_status.ts135
-rw-r--r--std/http/racing_server.ts50
-rw-r--r--std/http/racing_server_test.ts65
-rw-r--r--std/http/server.ts408
-rw-r--r--std/http/server_test.ts530
-rw-r--r--std/http/testdata/simple_server.ts11
12 files changed, 1984 insertions, 0 deletions
diff --git a/std/http/README.md b/std/http/README.md
new file mode 100644
index 000000000..ab35f9564
--- /dev/null
+++ b/std/http/README.md
@@ -0,0 +1,77 @@
+# http
+
+A framework for creating HTTP/HTTPS server.
+
+## Cookie
+
+Helper to manipulate `Cookie` through `ServerRequest` and `Response`.
+
+```ts
+import { ServerRequest } from "https://deno.land/std/http/server.ts";
+import { getCookies } from "https://deno.land/std/http/cookie.ts";
+
+let request = new ServerRequest();
+request.headers = new Headers();
+request.headers.set("Cookie", "full=of; tasty=chocolate");
+
+const cookies = getCookies(request);
+console.log("cookies:", cookies);
+// cookies: { full: "of", tasty: "chocolate" }
+```
+
+To set a `Cookie` you can add `CookieOptions` to properly set your `Cookie`
+
+```ts
+import { Response } from "https://deno.land/std/http/server.ts";
+import { Cookie, setCookie } from "https://deno.land/std/http/cookie.ts";
+
+let response: Response = {};
+const cookie: Cookie = { name: "Space", value: "Cat" };
+setCookie(response, cookie);
+
+const cookieHeader = response.headers.get("set-cookie");
+console.log("Set-Cookie:", cookieHeader);
+// Set-Cookie: Space=Cat
+```
+
+Deleting a `Cookie` will set its expiration date before now.
+Forcing the browser to delete it.
+
+```ts
+import { Response } from "https://deno.land/std/http/server.ts";
+import { delCookie } from "https://deno.land/std/http/cookie.ts";
+
+let response: Response = {};
+delCookie(response, "deno");
+
+const cookieHeader = response.headers.get("set-cookie");
+console.log("Set-Cookie:", cookieHeader);
+// Set-Cookie: deno=; Expires=Thus, 01 Jan 1970 00:00:00 GMT
+```
+
+**Note**: At the moment multiple `Set-Cookie` in a `Response` is not handled.
+
+## Example
+
+```typescript
+import { serve } from "https://deno.land/std/http/server.ts";
+const s = serve("0.0.0.0:8000");
+
+async function main() {
+ for await (const req of s) {
+ req.respond({ body: new TextEncoder().encode("Hello World\n") });
+ }
+}
+
+main();
+```
+
+### File Server
+
+A small program for serving local files over HTTP.
+
+Install it by using `deno install`
+
+```sh
+deno install file_server https://deno.land/std/http/file_server.ts --allow-net --allow-read
+```
diff --git a/std/http/cookie.ts b/std/http/cookie.ts
new file mode 100644
index 000000000..4d2704da1
--- /dev/null
+++ b/std/http/cookie.ts
@@ -0,0 +1,133 @@
+// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license.
+// Structured similarly to Go's cookie.go
+// https://github.com/golang/go/blob/master/src/net/http/cookie.go
+import { ServerRequest, Response } from "./server.ts";
+import { assert } from "../testing/asserts.ts";
+import { toIMF } from "../datetime/mod.ts";
+
+export interface Cookies {
+ [key: string]: string;
+}
+
+export interface Cookie {
+ name: string;
+ value: string;
+ expires?: Date;
+ maxAge?: number;
+ domain?: string;
+ path?: string;
+ secure?: boolean;
+ httpOnly?: boolean;
+ sameSite?: SameSite;
+ unparsed?: string[];
+}
+
+export type SameSite = "Strict" | "Lax";
+
+function toString(cookie: Cookie): string {
+ const out: string[] = [];
+ out.push(`${cookie.name}=${cookie.value}`);
+
+ // Fallback for invalid Set-Cookie
+ // ref: https://tools.ietf.org/html/draft-ietf-httpbis-cookie-prefixes-00#section-3.1
+ if (cookie.name.startsWith("__Secure")) {
+ cookie.secure = true;
+ }
+ if (cookie.name.startsWith("__Host")) {
+ cookie.path = "/";
+ cookie.secure = true;
+ delete cookie.domain;
+ }
+
+ if (cookie.secure) {
+ out.push("Secure");
+ }
+ if (cookie.httpOnly) {
+ out.push("HttpOnly");
+ }
+ if (Number.isInteger(cookie.maxAge!)) {
+ assert(cookie.maxAge! > 0, "Max-Age must be an integer superior to 0");
+ out.push(`Max-Age=${cookie.maxAge}`);
+ }
+ if (cookie.domain) {
+ out.push(`Domain=${cookie.domain}`);
+ }
+ if (cookie.sameSite) {
+ out.push(`SameSite=${cookie.sameSite}`);
+ }
+ if (cookie.path) {
+ out.push(`Path=${cookie.path}`);
+ }
+ if (cookie.expires) {
+ const dateString = toIMF(cookie.expires);
+ out.push(`Expires=${dateString}`);
+ }
+ if (cookie.unparsed) {
+ out.push(cookie.unparsed.join("; "));
+ }
+ return out.join("; ");
+}
+
+/**
+ * Parse the cookies of the Server Request
+ * @param req Server Request
+ */
+export function getCookies(req: ServerRequest): Cookies {
+ if (req.headers.has("Cookie")) {
+ const out: Cookies = {};
+ const c = req.headers.get("Cookie")!.split(";");
+ for (const kv of c) {
+ const cookieVal = kv.split("=");
+ const key = cookieVal.shift()!.trim();
+ out[key] = cookieVal.join("=");
+ }
+ return out;
+ }
+ return {};
+}
+
+/**
+ * Set the cookie header properly in the Response
+ * @param res Server Response
+ * @param cookie Cookie to set
+ * @param [cookie.name] Name of the cookie
+ * @param [cookie.value] Value of the cookie
+ * @param [cookie.expires] Expiration Date of the cookie
+ * @param [cookie.maxAge] Max-Age of the Cookie. Must be integer superior to 0
+ * @param [cookie.domain] Specifies those hosts to which the cookie will be sent
+ * @param [cookie.path] Indicates a URL path that must exist in the request.
+ * @param [cookie.secure] Indicates if the cookie is made using SSL & HTTPS.
+ * @param [cookie.httpOnly] Indicates that cookie is not accessible via
+ * Javascript
+ * @param [cookie.sameSite] Allows servers to assert that a cookie ought not to
+ * be sent along with cross-site requests
+ * Example:
+ *
+ * setCookie(response, { name: 'deno', value: 'runtime',
+ * httpOnly: true, secure: true, maxAge: 2, domain: "deno.land" });
+ */
+export function setCookie(res: Response, cookie: Cookie): void {
+ if (!res.headers) {
+ res.headers = new Headers();
+ }
+ // TODO (zekth) : Add proper parsing of Set-Cookie headers
+ // Parsing cookie headers to make consistent set-cookie header
+ // ref: https://tools.ietf.org/html/rfc6265#section-4.1.1
+ res.headers.set("Set-Cookie", toString(cookie));
+}
+
+/**
+ * Set the cookie header properly in the Response to delete it
+ * @param res Server Response
+ * @param name Name of the cookie to Delete
+ * Example:
+ *
+ * delCookie(res,'foo');
+ */
+export function delCookie(res: Response, name: string): void {
+ setCookie(res, {
+ name: name,
+ value: "",
+ expires: new Date(0)
+ });
+}
diff --git a/std/http/cookie_test.ts b/std/http/cookie_test.ts
new file mode 100644
index 000000000..da9110291
--- /dev/null
+++ b/std/http/cookie_test.ts
@@ -0,0 +1,210 @@
+// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license.
+import { ServerRequest, Response } from "./server.ts";
+import { getCookies, delCookie, setCookie } from "./cookie.ts";
+import { assert, assertEquals } from "../testing/asserts.ts";
+import { test } from "../testing/mod.ts";
+
+test({
+ name: "[HTTP] Cookie parser",
+ fn(): void {
+ const req = new ServerRequest();
+ req.headers = new Headers();
+ assertEquals(getCookies(req), {});
+ req.headers = new Headers();
+ req.headers.set("Cookie", "foo=bar");
+ assertEquals(getCookies(req), { foo: "bar" });
+
+ req.headers = new Headers();
+ req.headers.set("Cookie", "full=of ; tasty=chocolate");
+ assertEquals(getCookies(req), { full: "of ", tasty: "chocolate" });
+
+ req.headers = new Headers();
+ req.headers.set("Cookie", "igot=99; problems=but...");
+ assertEquals(getCookies(req), { igot: "99", problems: "but..." });
+
+ req.headers = new Headers();
+ req.headers.set("Cookie", "PREF=al=en-GB&f1=123; wide=1; SID=123");
+ assertEquals(getCookies(req), {
+ PREF: "al=en-GB&f1=123",
+ wide: "1",
+ SID: "123"
+ });
+ }
+});
+
+test({
+ name: "[HTTP] Cookie Delete",
+ fn(): void {
+ const res: Response = {};
+ delCookie(res, "deno");
+ assertEquals(
+ res.headers!.get("Set-Cookie"),
+ "deno=; Expires=Thus, 01 Jan 1970 00:00:00 GMT"
+ );
+ }
+});
+
+test({
+ name: "[HTTP] Cookie Set",
+ fn(): void {
+ const res: Response = {};
+
+ res.headers = new Headers();
+ setCookie(res, { name: "Space", value: "Cat" });
+ assertEquals(res.headers.get("Set-Cookie"), "Space=Cat");
+
+ res.headers = new Headers();
+ setCookie(res, { name: "Space", value: "Cat", secure: true });
+ assertEquals(res.headers.get("Set-Cookie"), "Space=Cat; Secure");
+
+ res.headers = new Headers();
+ setCookie(res, { name: "Space", value: "Cat", httpOnly: true });
+ assertEquals(res.headers.get("Set-Cookie"), "Space=Cat; HttpOnly");
+
+ res.headers = new Headers();
+ setCookie(res, {
+ name: "Space",
+ value: "Cat",
+ httpOnly: true,
+ secure: true
+ });
+ assertEquals(res.headers.get("Set-Cookie"), "Space=Cat; Secure; HttpOnly");
+
+ res.headers = new Headers();
+ setCookie(res, {
+ name: "Space",
+ value: "Cat",
+ httpOnly: true,
+ secure: true,
+ maxAge: 2
+ });
+ assertEquals(
+ res.headers.get("Set-Cookie"),
+ "Space=Cat; Secure; HttpOnly; Max-Age=2"
+ );
+
+ let error = false;
+ res.headers = new Headers();
+ try {
+ setCookie(res, {
+ name: "Space",
+ value: "Cat",
+ httpOnly: true,
+ secure: true,
+ maxAge: 0
+ });
+ } catch (e) {
+ error = true;
+ }
+ assert(error);
+
+ res.headers = new Headers();
+ setCookie(res, {
+ name: "Space",
+ value: "Cat",
+ httpOnly: true,
+ secure: true,
+ maxAge: 2,
+ domain: "deno.land"
+ });
+ assertEquals(
+ res.headers.get("Set-Cookie"),
+ "Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land"
+ );
+
+ res.headers = new Headers();
+ setCookie(res, {
+ name: "Space",
+ value: "Cat",
+ httpOnly: true,
+ secure: true,
+ maxAge: 2,
+ domain: "deno.land",
+ sameSite: "Strict"
+ });
+ assertEquals(
+ res.headers.get("Set-Cookie"),
+ "Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; " +
+ "SameSite=Strict"
+ );
+
+ res.headers = new Headers();
+ setCookie(res, {
+ name: "Space",
+ value: "Cat",
+ httpOnly: true,
+ secure: true,
+ maxAge: 2,
+ domain: "deno.land",
+ sameSite: "Lax"
+ });
+ assertEquals(
+ res.headers.get("Set-Cookie"),
+ "Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; SameSite=Lax"
+ );
+
+ res.headers = new Headers();
+ setCookie(res, {
+ name: "Space",
+ value: "Cat",
+ httpOnly: true,
+ secure: true,
+ maxAge: 2,
+ domain: "deno.land",
+ path: "/"
+ });
+ assertEquals(
+ res.headers.get("Set-Cookie"),
+ "Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; Path=/"
+ );
+
+ res.headers = new Headers();
+ setCookie(res, {
+ name: "Space",
+ value: "Cat",
+ httpOnly: true,
+ secure: true,
+ maxAge: 2,
+ domain: "deno.land",
+ path: "/",
+ unparsed: ["unparsed=keyvalue", "batman=Bruce"]
+ });
+ assertEquals(
+ res.headers.get("Set-Cookie"),
+ "Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; Path=/; " +
+ "unparsed=keyvalue; batman=Bruce"
+ );
+
+ res.headers = new Headers();
+ setCookie(res, {
+ name: "Space",
+ value: "Cat",
+ httpOnly: true,
+ secure: true,
+ maxAge: 2,
+ domain: "deno.land",
+ path: "/",
+ expires: new Date(Date.UTC(1983, 0, 7, 15, 32))
+ });
+ assertEquals(
+ res.headers.get("Set-Cookie"),
+ "Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; Path=/; " +
+ "Expires=Fri, 07 Jan 1983 15:32:00 GMT"
+ );
+
+ res.headers = new Headers();
+ setCookie(res, { name: "__Secure-Kitty", value: "Meow" });
+ assertEquals(res.headers.get("Set-Cookie"), "__Secure-Kitty=Meow; Secure");
+
+ res.headers = new Headers();
+ setCookie(res, {
+ name: "__Host-Kitty",
+ value: "Meow",
+ domain: "deno.land"
+ });
+ assertEquals(
+ res.headers.get("Set-Cookie"),
+ "__Host-Kitty=Meow; Secure; Path=/"
+ );
+ }
+});
diff --git a/std/http/file_server.ts b/std/http/file_server.ts
new file mode 100755
index 000000000..77d0cf748
--- /dev/null
+++ b/std/http/file_server.ts
@@ -0,0 +1,262 @@
+#!/usr/bin/env -S deno --allow-net
+// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license.
+
+// This program serves files in the current directory over HTTP.
+// TODO Stream responses instead of reading them into memory.
+// TODO Add tests like these:
+// https://github.com/indexzero/http-server/blob/master/test/http-server-test.js
+
+const { ErrorKind, cwd, args, stat, readDir, open } = Deno;
+import {
+ listenAndServe,
+ ServerRequest,
+ setContentLength,
+ Response
+} from "./server.ts";
+import { extname, posix } from "../fs/path.ts";
+import { contentType } from "../media_types/mod.ts";
+
+const dirViewerTemplate = `
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <meta http-equiv="X-UA-Compatible" content="ie=edge">
+ <title>Deno File Server</title>
+ <style>
+ td {
+ padding: 0 1rem;
+ }
+ td.mode {
+ font-family: Courier;
+ }
+ </style>
+</head>
+<body>
+ <h1>Index of <%DIRNAME%></h1>
+ <table>
+ <tr><th>Mode</th><th>Size</th><th>Name</th></tr>
+ <%CONTENTS%>
+ </table>
+</body>
+</html>
+`;
+
+const serverArgs = args.slice();
+let CORSEnabled = false;
+// TODO: switch to flags if we later want to add more options
+for (let i = 0; i < serverArgs.length; i++) {
+ if (serverArgs[i] === "--cors") {
+ CORSEnabled = true;
+ serverArgs.splice(i, 1);
+ break;
+ }
+}
+const targetArg = serverArgs[1] || "";
+const target = posix.isAbsolute(targetArg)
+ ? posix.normalize(targetArg)
+ : posix.join(cwd(), targetArg);
+const addr = `0.0.0.0:${serverArgs[2] || 4500}`;
+const encoder = new TextEncoder();
+
+function modeToString(isDir: boolean, maybeMode: number | null): string {
+ const modeMap = ["---", "--x", "-w-", "-wx", "r--", "r-x", "rw-", "rwx"];
+
+ if (maybeMode === null) {
+ return "(unknown mode)";
+ }
+ const mode = maybeMode!.toString(8);
+ if (mode.length < 3) {
+ return "(unknown mode)";
+ }
+ let output = "";
+ mode
+ .split("")
+ .reverse()
+ .slice(0, 3)
+ .forEach((v): void => {
+ output = modeMap[+v] + output;
+ });
+ output = `(${isDir ? "d" : "-"}${output})`;
+ return output;
+}
+
+function fileLenToString(len: number): string {
+ const multiplier = 1024;
+ let base = 1;
+ const suffix = ["B", "K", "M", "G", "T"];
+ let suffixIndex = 0;
+
+ while (base * multiplier < len) {
+ if (suffixIndex >= suffix.length - 1) {
+ break;
+ }
+ base *= multiplier;
+ suffixIndex++;
+ }
+
+ return `${(len / base).toFixed(2)}${suffix[suffixIndex]}`;
+}
+
+function createDirEntryDisplay(
+ name: string,
+ url: string,
+ size: number | null,
+ mode: number | null,
+ isDir: boolean
+): string {
+ const sizeStr = size === null ? "" : "" + fileLenToString(size!);
+ return `
+ <tr><td class="mode">${modeToString(
+ isDir,
+ mode
+ )}</td><td>${sizeStr}</td><td><a href="${url}">${name}${
+ isDir ? "/" : ""
+ }</a></td>
+ </tr>
+ `;
+}
+
+async function serveFile(
+ req: ServerRequest,
+ filePath: string
+): Promise<Response> {
+ const file = await open(filePath);
+ const fileInfo = await stat(filePath);
+ const headers = new Headers();
+ headers.set("content-length", fileInfo.len.toString());
+ headers.set("content-type", contentType(extname(filePath)) || "text/plain");
+
+ const res = {
+ status: 200,
+ body: file,
+ headers
+ };
+ return res;
+}
+
+// TODO: simplify this after deno.stat and deno.readDir are fixed
+async function serveDir(
+ req: ServerRequest,
+ dirPath: string
+): Promise<Response> {
+ interface ListItem {
+ name: string;
+ template: string;
+ }
+ const dirUrl = `/${posix.relative(target, dirPath)}`;
+ const listEntry: ListItem[] = [];
+ const fileInfos = await readDir(dirPath);
+ for (const fileInfo of fileInfos) {
+ const filePath = posix.join(dirPath, fileInfo.name);
+ const fileUrl = posix.join(dirUrl, fileInfo.name);
+ if (fileInfo.name === "index.html" && fileInfo.isFile()) {
+ // in case index.html as dir...
+ return await serveFile(req, filePath);
+ }
+ // Yuck!
+ let mode = null;
+ try {
+ mode = (await stat(filePath)).mode;
+ } catch (e) {}
+ listEntry.push({
+ name: fileInfo.name,
+ template: createDirEntryDisplay(
+ fileInfo.name,
+ fileUrl,
+ fileInfo.isFile() ? fileInfo.len : null,
+ mode,
+ fileInfo.isDirectory()
+ )
+ });
+ }
+
+ const formattedDirUrl = `${dirUrl.replace(/\/$/, "")}/`;
+ const page = new TextEncoder().encode(
+ dirViewerTemplate.replace("<%DIRNAME%>", formattedDirUrl).replace(
+ "<%CONTENTS%>",
+ listEntry
+ .sort((a, b): number =>
+ a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1
+ )
+ .map((v): string => v.template)
+ .join("")
+ )
+ );
+
+ const headers = new Headers();
+ headers.set("content-type", "text/html");
+
+ const res = {
+ status: 200,
+ body: page,
+ headers
+ };
+ setContentLength(res);
+ return res;
+}
+
+async function serveFallback(req: ServerRequest, e: Error): Promise<Response> {
+ if (
+ e instanceof Deno.DenoError &&
+ (e as Deno.DenoError<Deno.ErrorKind.NotFound>).kind === ErrorKind.NotFound
+ ) {
+ return {
+ status: 404,
+ body: encoder.encode("Not found")
+ };
+ } else {
+ return {
+ status: 500,
+ body: encoder.encode("Internal server error")
+ };
+ }
+}
+
+function serverLog(req: ServerRequest, res: Response): void {
+ const d = new Date().toISOString();
+ const dateFmt = `[${d.slice(0, 10)} ${d.slice(11, 19)}]`;
+ const s = `${dateFmt} "${req.method} ${req.url} ${req.proto}" ${res.status}`;
+ console.log(s);
+}
+
+function setCORS(res: Response): void {
+ if (!res.headers) {
+ res.headers = new Headers();
+ }
+ res.headers!.append("access-control-allow-origin", "*");
+ res.headers!.append(
+ "access-control-allow-headers",
+ "Origin, X-Requested-With, Content-Type, Accept, Range"
+ );
+}
+
+listenAndServe(
+ addr,
+ async (req): Promise<void> => {
+ const normalizedUrl = posix.normalize(req.url);
+ const fsPath = posix.join(target, normalizedUrl);
+
+ let response: Response;
+
+ try {
+ const info = await stat(fsPath);
+ if (info.isDirectory()) {
+ response = await serveDir(req, fsPath);
+ } else {
+ response = await serveFile(req, fsPath);
+ }
+ } catch (e) {
+ response = await serveFallback(req, e);
+ } finally {
+ if (CORSEnabled) {
+ setCORS(response);
+ }
+ serverLog(req, response);
+ req.respond(response);
+ }
+ }
+);
+
+console.log(`HTTP server listening on http://${addr}/`);
diff --git a/std/http/file_server_test.ts b/std/http/file_server_test.ts
new file mode 100644
index 000000000..b7148905b
--- /dev/null
+++ b/std/http/file_server_test.ts
@@ -0,0 +1,88 @@
+// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license.
+const { readFile, run } = Deno;
+
+import { test } from "../testing/mod.ts";
+import { assert, assertEquals } from "../testing/asserts.ts";
+import { BufReader } from "../io/bufio.ts";
+import { TextProtoReader } from "../textproto/mod.ts";
+
+let fileServer: Deno.Process;
+
+async function startFileServer(): Promise<void> {
+ fileServer = run({
+ args: [
+ Deno.execPath(),
+ "run",
+ "--allow-read",
+ "--allow-net",
+ "http/file_server.ts",
+ ".",
+ "--cors"
+ ],
+ stdout: "piped"
+ });
+ // Once fileServer is ready it will write to its stdout.
+ const r = new TextProtoReader(new BufReader(fileServer.stdout!));
+ const s = await r.readLine();
+ assert(s !== Deno.EOF && s.includes("server listening"));
+}
+
+function killFileServer(): void {
+ fileServer.close();
+ fileServer.stdout!.close();
+}
+
+test(async function serveFile(): Promise<void> {
+ await startFileServer();
+ try {
+ const res = await fetch("http://localhost:4500/azure-pipelines.yml");
+ assert(res.headers.has("access-control-allow-origin"));
+ assert(res.headers.has("access-control-allow-headers"));
+ assertEquals(res.headers.get("content-type"), "text/yaml; charset=utf-8");
+ const downloadedFile = await res.text();
+ const localFile = new TextDecoder().decode(
+ await readFile("./azure-pipelines.yml")
+ );
+ assertEquals(downloadedFile, localFile);
+ } finally {
+ killFileServer();
+ }
+});
+
+test(async function serveDirectory(): Promise<void> {
+ await startFileServer();
+ try {
+ const res = await fetch("http://localhost:4500/");
+ assert(res.headers.has("access-control-allow-origin"));
+ assert(res.headers.has("access-control-allow-headers"));
+ const page = await res.text();
+ assert(page.includes("azure-pipelines.yml"));
+
+ // `Deno.FileInfo` is not completely compatible with Windows yet
+ // TODO: `mode` should work correctly in the future.
+ // Correct this test case accordingly.
+ Deno.build.os !== "win" &&
+ assert(/<td class="mode">\([a-zA-Z-]{10}\)<\/td>/.test(page));
+ Deno.build.os === "win" &&
+ assert(/<td class="mode">\(unknown mode\)<\/td>/.test(page));
+ assert(
+ page.includes(
+ `<td><a href="/azure-pipelines.yml">azure-pipelines.yml</a></td>`
+ )
+ );
+ } finally {
+ killFileServer();
+ }
+});
+
+test(async function serveFallback(): Promise<void> {
+ await startFileServer();
+ try {
+ const res = await fetch("http://localhost:4500/badfile.txt");
+ assert(res.headers.has("access-control-allow-origin"));
+ assert(res.headers.has("access-control-allow-headers"));
+ assertEquals(res.status, 404);
+ } finally {
+ killFileServer();
+ }
+});
diff --git a/std/http/http_bench.ts b/std/http/http_bench.ts
new file mode 100644
index 000000000..06043f9e4
--- /dev/null
+++ b/std/http/http_bench.ts
@@ -0,0 +1,15 @@
+// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license.
+import { serve } from "./server.ts";
+
+const addr = Deno.args[1] || "127.0.0.1:4500";
+const server = serve(addr);
+const body = new TextEncoder().encode("Hello World");
+
+async function main(): Promise<void> {
+ console.log(`http://${addr}/`);
+ for await (const req of server) {
+ req.respond({ body });
+ }
+}
+
+main();
diff --git a/std/http/http_status.ts b/std/http/http_status.ts
new file mode 100644
index 000000000..9ff212f29
--- /dev/null
+++ b/std/http/http_status.ts
@@ -0,0 +1,135 @@
+// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license.
+export enum Status {
+ Continue = 100, // RFC 7231, 6.2.1
+ SwitchingProtocols = 101, // RFC 7231, 6.2.2
+ Processing = 102, // RFC 2518, 10.1
+
+ OK = 200, // RFC 7231, 6.3.1
+ Created = 201, // RFC 7231, 6.3.2
+ Accepted = 202, // RFC 7231, 6.3.3
+ NonAuthoritativeInfo = 203, // RFC 7231, 6.3.4
+ NoContent = 204, // RFC 7231, 6.3.5
+ ResetContent = 205, // RFC 7231, 6.3.6
+ PartialContent = 206, // RFC 7233, 4.1
+ MultiStatus = 207, // RFC 4918, 11.1
+ AlreadyReported = 208, // RFC 5842, 7.1
+ IMUsed = 226, // RFC 3229, 10.4.1
+
+ MultipleChoices = 300, // RFC 7231, 6.4.1
+ MovedPermanently = 301, // RFC 7231, 6.4.2
+ Found = 302, // RFC 7231, 6.4.3
+ SeeOther = 303, // RFC 7231, 6.4.4
+ NotModified = 304, // RFC 7232, 4.1
+ UseProxy = 305, // RFC 7231, 6.4.5
+ // _ = 306, // RFC 7231, 6.4.6 (Unused)
+ TemporaryRedirect = 307, // RFC 7231, 6.4.7
+ PermanentRedirect = 308, // RFC 7538, 3
+
+ BadRequest = 400, // RFC 7231, 6.5.1
+ Unauthorized = 401, // RFC 7235, 3.1
+ PaymentRequired = 402, // RFC 7231, 6.5.2
+ Forbidden = 403, // RFC 7231, 6.5.3
+ NotFound = 404, // RFC 7231, 6.5.4
+ MethodNotAllowed = 405, // RFC 7231, 6.5.5
+ NotAcceptable = 406, // RFC 7231, 6.5.6
+ ProxyAuthRequired = 407, // RFC 7235, 3.2
+ RequestTimeout = 408, // RFC 7231, 6.5.7
+ Conflict = 409, // RFC 7231, 6.5.8
+ Gone = 410, // RFC 7231, 6.5.9
+ LengthRequired = 411, // RFC 7231, 6.5.10
+ PreconditionFailed = 412, // RFC 7232, 4.2
+ RequestEntityTooLarge = 413, // RFC 7231, 6.5.11
+ RequestURITooLong = 414, // RFC 7231, 6.5.12
+ UnsupportedMediaType = 415, // RFC 7231, 6.5.13
+ RequestedRangeNotSatisfiable = 416, // RFC 7233, 4.4
+ ExpectationFailed = 417, // RFC 7231, 6.5.14
+ Teapot = 418, // RFC 7168, 2.3.3
+ MisdirectedRequest = 421, // RFC 7540, 9.1.2
+ UnprocessableEntity = 422, // RFC 4918, 11.2
+ Locked = 423, // RFC 4918, 11.3
+ FailedDependency = 424, // RFC 4918, 11.4
+ UpgradeRequired = 426, // RFC 7231, 6.5.15
+ PreconditionRequired = 428, // RFC 6585, 3
+ TooManyRequests = 429, // RFC 6585, 4
+ RequestHeaderFieldsTooLarge = 431, // RFC 6585, 5
+ UnavailableForLegalReasons = 451, // RFC 7725, 3
+
+ InternalServerError = 500, // RFC 7231, 6.6.1
+ NotImplemented = 501, // RFC 7231, 6.6.2
+ BadGateway = 502, // RFC 7231, 6.6.3
+ ServiceUnavailable = 503, // RFC 7231, 6.6.4
+ GatewayTimeout = 504, // RFC 7231, 6.6.5
+ HTTPVersionNotSupported = 505, // RFC 7231, 6.6.6
+ VariantAlsoNegotiates = 506, // RFC 2295, 8.1
+ InsufficientStorage = 507, // RFC 4918, 11.5
+ LoopDetected = 508, // RFC 5842, 7.2
+ NotExtended = 510, // RFC 2774, 7
+ NetworkAuthenticationRequired = 511 // RFC 6585, 6
+}
+
+export const STATUS_TEXT = new Map<Status, string>([
+ [Status.Continue, "Continue"],
+ [Status.SwitchingProtocols, "Switching Protocols"],
+ [Status.Processing, "Processing"],
+
+ [Status.OK, "OK"],
+ [Status.Created, "Created"],
+ [Status.Accepted, "Accepted"],
+ [Status.NonAuthoritativeInfo, "Non-Authoritative Information"],
+ [Status.NoContent, "No Content"],
+ [Status.ResetContent, "Reset Content"],
+ [Status.PartialContent, "Partial Content"],
+ [Status.MultiStatus, "Multi-Status"],
+ [Status.AlreadyReported, "Already Reported"],
+ [Status.IMUsed, "IM Used"],
+
+ [Status.MultipleChoices, "Multiple Choices"],
+ [Status.MovedPermanently, "Moved Permanently"],
+ [Status.Found, "Found"],
+ [Status.SeeOther, "See Other"],
+ [Status.NotModified, "Not Modified"],
+ [Status.UseProxy, "Use Proxy"],
+ [Status.TemporaryRedirect, "Temporary Redirect"],
+ [Status.PermanentRedirect, "Permanent Redirect"],
+
+ [Status.BadRequest, "Bad Request"],
+ [Status.Unauthorized, "Unauthorized"],
+ [Status.PaymentRequired, "Payment Required"],
+ [Status.Forbidden, "Forbidden"],
+ [Status.NotFound, "Not Found"],
+ [Status.MethodNotAllowed, "Method Not Allowed"],
+ [Status.NotAcceptable, "Not Acceptable"],
+ [Status.ProxyAuthRequired, "Proxy Authentication Required"],
+ [Status.RequestTimeout, "Request Timeout"],
+ [Status.Conflict, "Conflict"],
+ [Status.Gone, "Gone"],
+ [Status.LengthRequired, "Length Required"],
+ [Status.PreconditionFailed, "Precondition Failed"],
+ [Status.RequestEntityTooLarge, "Request Entity Too Large"],
+ [Status.RequestURITooLong, "Request URI Too Long"],
+ [Status.UnsupportedMediaType, "Unsupported Media Type"],
+ [Status.RequestedRangeNotSatisfiable, "Requested Range Not Satisfiable"],
+ [Status.ExpectationFailed, "Expectation Failed"],
+ [Status.Teapot, "I'm a teapot"],
+ [Status.MisdirectedRequest, "Misdirected Request"],
+ [Status.UnprocessableEntity, "Unprocessable Entity"],
+ [Status.Locked, "Locked"],
+ [Status.FailedDependency, "Failed Dependency"],
+ [Status.UpgradeRequired, "Upgrade Required"],
+ [Status.PreconditionRequired, "Precondition Required"],
+ [Status.TooManyRequests, "Too Many Requests"],
+ [Status.RequestHeaderFieldsTooLarge, "Request Header Fields Too Large"],
+ [Status.UnavailableForLegalReasons, "Unavailable For Legal Reasons"],
+
+ [Status.InternalServerError, "Internal Server Error"],
+ [Status.NotImplemented, "Not Implemented"],
+ [Status.BadGateway, "Bad Gateway"],
+ [Status.ServiceUnavailable, "Service Unavailable"],
+ [Status.GatewayTimeout, "Gateway Timeout"],
+ [Status.HTTPVersionNotSupported, "HTTP Version Not Supported"],
+ [Status.VariantAlsoNegotiates, "Variant Also Negotiates"],
+ [Status.InsufficientStorage, "Insufficient Storage"],
+ [Status.LoopDetected, "Loop Detected"],
+ [Status.NotExtended, "Not Extended"],
+ [Status.NetworkAuthenticationRequired, "Network Authentication Required"]
+]);
diff --git a/std/http/racing_server.ts b/std/http/racing_server.ts
new file mode 100644
index 000000000..9d118dc6d
--- /dev/null
+++ b/std/http/racing_server.ts
@@ -0,0 +1,50 @@
+// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license.
+import { serve, ServerRequest } from "./server.ts";
+import { delay } from "../util/async.ts";
+
+const addr = Deno.args[1] || "127.0.0.1:4501";
+const server = serve(addr);
+
+const body = new TextEncoder().encode("Hello 1\n");
+const body4 = new TextEncoder().encode("World 4\n");
+
+async function delayedRespond(request: ServerRequest): Promise<void> {
+ await delay(3000);
+ await request.respond({ status: 200, body });
+}
+
+async function largeRespond(request: ServerRequest, c: string): Promise<void> {
+ const b = new Uint8Array(1024 * 1024);
+ b.fill(c.charCodeAt(0));
+ await request.respond({ status: 200, body: b });
+}
+
+async function main(): Promise<void> {
+ let step = 1;
+ for await (const request of server) {
+ switch (step) {
+ case 1:
+ // Try to wait long enough.
+ // For pipelining, this should cause all the following response
+ // to block.
+ delayedRespond(request);
+ break;
+ case 2:
+ // HUGE body.
+ largeRespond(request, "a");
+ break;
+ case 3:
+ // HUGE body.
+ largeRespond(request, "b");
+ break;
+ default:
+ request.respond({ status: 200, body: body4 });
+ break;
+ }
+ step++;
+ }
+}
+
+main();
+
+console.log("Racing server listening...\n");
diff --git a/std/http/racing_server_test.ts b/std/http/racing_server_test.ts
new file mode 100644
index 000000000..b66986247
--- /dev/null
+++ b/std/http/racing_server_test.ts
@@ -0,0 +1,65 @@
+const { dial, run } = Deno;
+
+import { test, runIfMain } from "../testing/mod.ts";
+import { assert, assertEquals } from "../testing/asserts.ts";
+import { BufReader } from "../io/bufio.ts";
+import { TextProtoReader } from "../textproto/mod.ts";
+
+let server: Deno.Process;
+async function startServer(): Promise<void> {
+ server = run({
+ args: [Deno.execPath(), "run", "-A", "http/racing_server.ts"],
+ stdout: "piped"
+ });
+ // Once racing server is ready it will write to its stdout.
+ const r = new TextProtoReader(new BufReader(server.stdout!));
+ const s = await r.readLine();
+ assert(s !== Deno.EOF && s.includes("Racing server listening..."));
+}
+function killServer(): void {
+ server.close();
+ server.stdout!.close();
+}
+
+const input = `GET / HTTP/1.1
+
+GET / HTTP/1.1
+
+GET / HTTP/1.1
+
+GET / HTTP/1.1
+
+`;
+const HUGE_BODY_SIZE = 1024 * 1024;
+const output = `HTTP/1.1 200 OK
+content-length: 8
+
+Hello 1
+HTTP/1.1 200 OK
+content-length: ${HUGE_BODY_SIZE}
+
+${"a".repeat(HUGE_BODY_SIZE)}HTTP/1.1 200 OK
+content-length: ${HUGE_BODY_SIZE}
+
+${"b".repeat(HUGE_BODY_SIZE)}HTTP/1.1 200 OK
+content-length: 8
+
+World 4
+`;
+
+test(async function serverPipelineRace(): Promise<void> {
+ await startServer();
+
+ const conn = await dial({ port: 4501 });
+ const r = new TextProtoReader(new BufReader(conn));
+ await conn.write(new TextEncoder().encode(input));
+ const outLines = output.split("\n");
+ // length - 1 to disregard last empty line
+ for (let i = 0; i < outLines.length - 1; i++) {
+ const s = await r.readLine();
+ assertEquals(s, outLines[i]);
+ }
+ killServer();
+});
+
+runIfMain(import.meta);
diff --git a/std/http/server.ts b/std/http/server.ts
new file mode 100644
index 000000000..f1ced0577
--- /dev/null
+++ b/std/http/server.ts
@@ -0,0 +1,408 @@
+// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license.
+const { listen, copy, toAsyncIterator } = Deno;
+type Listener = Deno.Listener;
+type Conn = Deno.Conn;
+type Reader = Deno.Reader;
+type Writer = Deno.Writer;
+import { BufReader, BufWriter, UnexpectedEOFError } from "../io/bufio.ts";
+import { TextProtoReader } from "../textproto/mod.ts";
+import { STATUS_TEXT } from "./http_status.ts";
+import { assert } from "../testing/asserts.ts";
+import {
+ collectUint8Arrays,
+ deferred,
+ Deferred,
+ MuxAsyncIterator
+} from "../util/async.ts";
+
+function bufWriter(w: Writer): BufWriter {
+ if (w instanceof BufWriter) {
+ return w;
+ } else {
+ return new BufWriter(w);
+ }
+}
+
+export function setContentLength(r: Response): void {
+ if (!r.headers) {
+ r.headers = new Headers();
+ }
+
+ if (r.body) {
+ if (!r.headers.has("content-length")) {
+ if (r.body instanceof Uint8Array) {
+ const bodyLength = r.body.byteLength;
+ r.headers.append("Content-Length", bodyLength.toString());
+ } else {
+ r.headers.append("Transfer-Encoding", "chunked");
+ }
+ }
+ }
+}
+
+async function writeChunkedBody(w: Writer, r: Reader): Promise<void> {
+ const writer = bufWriter(w);
+ const encoder = new TextEncoder();
+
+ for await (const chunk of toAsyncIterator(r)) {
+ if (chunk.byteLength <= 0) continue;
+ const start = encoder.encode(`${chunk.byteLength.toString(16)}\r\n`);
+ const end = encoder.encode("\r\n");
+ await writer.write(start);
+ await writer.write(chunk);
+ await writer.write(end);
+ }
+
+ const endChunk = encoder.encode("0\r\n\r\n");
+ await writer.write(endChunk);
+}
+
+export async function writeResponse(w: Writer, r: Response): Promise<void> {
+ const protoMajor = 1;
+ const protoMinor = 1;
+ const statusCode = r.status || 200;
+ const statusText = STATUS_TEXT.get(statusCode);
+ const writer = bufWriter(w);
+ if (!statusText) {
+ throw Error("bad status code");
+ }
+ if (!r.body) {
+ r.body = new Uint8Array();
+ }
+
+ let out = `HTTP/${protoMajor}.${protoMinor} ${statusCode} ${statusText}\r\n`;
+
+ setContentLength(r);
+ const headers = r.headers!;
+
+ for (const [key, value] of headers!) {
+ out += `${key}: ${value}\r\n`;
+ }
+ out += "\r\n";
+
+ const header = new TextEncoder().encode(out);
+ const n = await writer.write(header);
+ assert(n === header.byteLength);
+
+ if (r.body instanceof Uint8Array) {
+ const n = await writer.write(r.body);
+ assert(n === r.body.byteLength);
+ } else if (headers.has("content-length")) {
+ const bodyLength = parseInt(headers.get("content-length")!);
+ const n = await copy(writer, r.body);
+ assert(n === bodyLength);
+ } else {
+ await writeChunkedBody(writer, r.body);
+ }
+ await writer.flush();
+}
+
+export class ServerRequest {
+ url!: string;
+ method!: string;
+ proto!: string;
+ protoMinor!: number;
+ protoMajor!: number;
+ headers!: Headers;
+ conn!: Conn;
+ r!: BufReader;
+ w!: BufWriter;
+ done: Deferred<void> = deferred();
+
+ public async *bodyStream(): AsyncIterableIterator<Uint8Array> {
+ if (this.headers.has("content-length")) {
+ const len = +this.headers.get("content-length")!;
+ if (Number.isNaN(len)) {
+ return new Uint8Array(0);
+ }
+ let buf = new Uint8Array(1024);
+ let rr = await this.r.read(buf);
+ let nread = rr === Deno.EOF ? 0 : rr;
+ let nreadTotal = nread;
+ while (rr !== Deno.EOF && nreadTotal < len) {
+ yield buf.subarray(0, nread);
+ buf = new Uint8Array(1024);
+ rr = await this.r.read(buf);
+ nread = rr === Deno.EOF ? 0 : rr;
+ nreadTotal += nread;
+ }
+ yield buf.subarray(0, nread);
+ } else {
+ if (this.headers.has("transfer-encoding")) {
+ const transferEncodings = this.headers
+ .get("transfer-encoding")!
+ .split(",")
+ .map((e): string => e.trim().toLowerCase());
+ if (transferEncodings.includes("chunked")) {
+ // Based on https://tools.ietf.org/html/rfc2616#section-19.4.6
+ const tp = new TextProtoReader(this.r);
+ let line = await tp.readLine();
+ if (line === Deno.EOF) throw new UnexpectedEOFError();
+ // TODO: handle chunk extension
+ const [chunkSizeString] = line.split(";");
+ let chunkSize = parseInt(chunkSizeString, 16);
+ if (Number.isNaN(chunkSize) || chunkSize < 0) {
+ throw new Error("Invalid chunk size");
+ }
+ while (chunkSize > 0) {
+ const data = new Uint8Array(chunkSize);
+ if ((await this.r.readFull(data)) === Deno.EOF) {
+ throw new UnexpectedEOFError();
+ }
+ yield data;
+ await this.r.readLine(); // Consume \r\n
+ line = await tp.readLine();
+ if (line === Deno.EOF) throw new UnexpectedEOFError();
+ chunkSize = parseInt(line, 16);
+ }
+ const entityHeaders = await tp.readMIMEHeader();
+ if (entityHeaders !== Deno.EOF) {
+ for (const [k, v] of entityHeaders) {
+ this.headers.set(k, v);
+ }
+ }
+ /* Pseudo code from https://tools.ietf.org/html/rfc2616#section-19.4.6
+ length := 0
+ read chunk-size, chunk-extension (if any) and CRLF
+ while (chunk-size > 0) {
+ read chunk-data and CRLF
+ append chunk-data to entity-body
+ length := length + chunk-size
+ read chunk-size and CRLF
+ }
+ read entity-header
+ while (entity-header not empty) {
+ append entity-header to existing header fields
+ read entity-header
+ }
+ Content-Length := length
+ Remove "chunked" from Transfer-Encoding
+ */
+ return; // Must return here to avoid fall through
+ }
+ // TODO: handle other transfer-encoding types
+ }
+ // Otherwise...
+ yield new Uint8Array(0);
+ }
+ }
+
+ // Read the body of the request into a single Uint8Array
+ public async body(): Promise<Uint8Array> {
+ return collectUint8Arrays(this.bodyStream());
+ }
+
+ async respond(r: Response): Promise<void> {
+ // Write our response!
+ await writeResponse(this.w, r);
+ // Signal that this request has been processed and the next pipelined
+ // request on the same connection can be accepted.
+ this.done.resolve();
+ }
+}
+
+function fixLength(req: ServerRequest): void {
+ const contentLength = req.headers.get("Content-Length");
+ if (contentLength) {
+ const arrClen = contentLength.split(",");
+ if (arrClen.length > 1) {
+ const distinct = [...new Set(arrClen.map((e): string => e.trim()))];
+ if (distinct.length > 1) {
+ throw Error("cannot contain multiple Content-Length headers");
+ } else {
+ req.headers.set("Content-Length", distinct[0]);
+ }
+ }
+ const c = req.headers.get("Content-Length");
+ if (req.method === "HEAD" && c && c !== "0") {
+ throw Error("http: method cannot contain a Content-Length");
+ }
+ if (c && req.headers.has("transfer-encoding")) {
+ // A sender MUST NOT send a Content-Length header field in any message
+ // that contains a Transfer-Encoding header field.
+ // rfc: https://tools.ietf.org/html/rfc7230#section-3.3.2
+ throw new Error(
+ "http: Transfer-Encoding and Content-Length cannot be send together"
+ );
+ }
+ }
+}
+
+// ParseHTTPVersion parses a HTTP version string.
+// "HTTP/1.0" returns (1, 0, true).
+// Ported from https://github.com/golang/go/blob/f5c43b9/src/net/http/request.go#L766-L792
+export function parseHTTPVersion(vers: string): [number, number] {
+ switch (vers) {
+ case "HTTP/1.1":
+ return [1, 1];
+
+ case "HTTP/1.0":
+ return [1, 0];
+
+ default: {
+ const Big = 1000000; // arbitrary upper bound
+ const digitReg = /^\d+$/; // test if string is only digit
+
+ if (!vers.startsWith("HTTP/")) {
+ break;
+ }
+
+ const dot = vers.indexOf(".");
+ if (dot < 0) {
+ break;
+ }
+
+ const majorStr = vers.substring(vers.indexOf("/") + 1, dot);
+ const major = parseInt(majorStr);
+ if (
+ !digitReg.test(majorStr) ||
+ isNaN(major) ||
+ major < 0 ||
+ major > Big
+ ) {
+ break;
+ }
+
+ const minorStr = vers.substring(dot + 1);
+ const minor = parseInt(minorStr);
+ if (
+ !digitReg.test(minorStr) ||
+ isNaN(minor) ||
+ minor < 0 ||
+ minor > Big
+ ) {
+ break;
+ }
+
+ return [major, minor];
+ }
+ }
+
+ throw new Error(`malformed HTTP version ${vers}`);
+}
+
+export async function readRequest(
+ conn: Conn,
+ bufr: BufReader
+): Promise<ServerRequest | Deno.EOF> {
+ const tp = new TextProtoReader(bufr);
+ const firstLine = await tp.readLine(); // e.g. GET /index.html HTTP/1.0
+ if (firstLine === Deno.EOF) return Deno.EOF;
+ const headers = await tp.readMIMEHeader();
+ if (headers === Deno.EOF) throw new UnexpectedEOFError();
+
+ const req = new ServerRequest();
+ req.conn = conn;
+ req.r = bufr;
+ [req.method, req.url, req.proto] = firstLine.split(" ", 3);
+ [req.protoMinor, req.protoMajor] = parseHTTPVersion(req.proto);
+ req.headers = headers;
+ fixLength(req);
+ return req;
+}
+
+export class Server implements AsyncIterable<ServerRequest> {
+ private closing = false;
+
+ constructor(public listener: Listener) {}
+
+ close(): void {
+ this.closing = true;
+ this.listener.close();
+ }
+
+ // Yields all HTTP requests on a single TCP connection.
+ private async *iterateHttpRequests(
+ conn: Conn
+ ): AsyncIterableIterator<ServerRequest> {
+ const bufr = new BufReader(conn);
+ const w = new BufWriter(conn);
+ let req: ServerRequest | Deno.EOF;
+ let err: Error | undefined;
+
+ while (!this.closing) {
+ try {
+ req = await readRequest(conn, bufr);
+ } catch (e) {
+ err = e;
+ break;
+ }
+ if (req === Deno.EOF) {
+ break;
+ }
+
+ req.w = w;
+ yield req;
+
+ // Wait for the request to be processed before we accept a new request on
+ // this connection.
+ await req!.done;
+ }
+
+ if (req! === Deno.EOF) {
+ // The connection was gracefully closed.
+ } else if (err) {
+ // An error was thrown while parsing request headers.
+ try {
+ await writeResponse(req!.w, {
+ status: 400,
+ body: new TextEncoder().encode(`${err.message}\r\n\r\n`)
+ });
+ } catch (_) {
+ // The connection is destroyed.
+ // Ignores the error.
+ }
+ } else if (this.closing) {
+ // There are more requests incoming but the server is closing.
+ // TODO(ry): send a back a HTTP 503 Service Unavailable status.
+ }
+
+ conn.close();
+ }
+
+ // Accepts a new TCP connection and yields all HTTP requests that arrive on
+ // it. When a connection is accepted, it also creates a new iterator of the
+ // same kind and adds it to the request multiplexer so that another TCP
+ // connection can be accepted.
+ private async *acceptConnAndIterateHttpRequests(
+ mux: MuxAsyncIterator<ServerRequest>
+ ): AsyncIterableIterator<ServerRequest> {
+ if (this.closing) return;
+ // Wait for a new connection.
+ const conn = await this.listener.accept();
+ // Try to accept another connection and add it to the multiplexer.
+ mux.add(this.acceptConnAndIterateHttpRequests(mux));
+ // Yield the requests that arrive on the just-accepted connection.
+ yield* this.iterateHttpRequests(conn);
+ }
+
+ [Symbol.asyncIterator](): AsyncIterableIterator<ServerRequest> {
+ const mux: MuxAsyncIterator<ServerRequest> = new MuxAsyncIterator();
+ mux.add(this.acceptConnAndIterateHttpRequests(mux));
+ return mux.iterate();
+ }
+}
+
+export function serve(addr: string): Server {
+ // TODO(ry) Update serve to also take { hostname, port }.
+ const [hostname, port] = addr.split(":");
+ const listener = listen({ hostname, port: Number(port) });
+ return new Server(listener);
+}
+
+export async function listenAndServe(
+ addr: string,
+ handler: (req: ServerRequest) => void
+): Promise<void> {
+ const server = serve(addr);
+
+ for await (const request of server) {
+ handler(request);
+ }
+}
+
+export interface Response {
+ status?: number;
+ headers?: Headers;
+ body?: Uint8Array | Reader;
+}
diff --git a/std/http/server_test.ts b/std/http/server_test.ts
new file mode 100644
index 000000000..a49301790
--- /dev/null
+++ b/std/http/server_test.ts
@@ -0,0 +1,530 @@
+// Copyright 2010 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Ported from
+// https://github.com/golang/go/blob/master/src/net/http/responsewrite_test.go
+
+const { Buffer } = Deno;
+import { TextProtoReader } from "../textproto/mod.ts";
+import { test, runIfMain } from "../testing/mod.ts";
+import { assert, assertEquals, assertNotEquals } from "../testing/asserts.ts";
+import {
+ Response,
+ ServerRequest,
+ writeResponse,
+ readRequest,
+ parseHTTPVersion
+} from "./server.ts";
+import { delay } from "../util/async.ts";
+import {
+ BufReader,
+ BufWriter,
+ ReadLineResult,
+ UnexpectedEOFError
+} from "../io/bufio.ts";
+import { StringReader } from "../io/readers.ts";
+
+function assertNotEOF<T extends {}>(val: T | Deno.EOF): T {
+ assertNotEquals(val, Deno.EOF);
+ return val as T;
+}
+
+interface ResponseTest {
+ response: Response;
+ raw: string;
+}
+
+const enc = new TextEncoder();
+const dec = new TextDecoder();
+
+type Handler = () => void;
+
+const responseTests: ResponseTest[] = [
+ // Default response
+ {
+ response: {},
+ raw: "HTTP/1.1 200 OK\r\n" + "content-length: 0" + "\r\n\r\n"
+ },
+ // Empty body with status
+ {
+ response: {
+ status: 404
+ },
+ raw: "HTTP/1.1 404 Not Found\r\n" + "content-length: 0" + "\r\n\r\n"
+ },
+ // HTTP/1.1, chunked coding; empty trailer; close
+ {
+ response: {
+ status: 200,
+ body: new Buffer(new TextEncoder().encode("abcdef"))
+ },
+
+ raw:
+ "HTTP/1.1 200 OK\r\n" +
+ "transfer-encoding: chunked\r\n\r\n" +
+ "6\r\nabcdef\r\n0\r\n\r\n"
+ }
+];
+
+test(async function responseWrite(): Promise<void> {
+ for (const testCase of responseTests) {
+ const buf = new Buffer();
+ const bufw = new BufWriter(buf);
+ const request = new ServerRequest();
+ request.w = bufw;
+
+ request.conn = {
+ localAddr: "",
+ remoteAddr: "",
+ rid: -1,
+ closeRead: (): void => {},
+ closeWrite: (): void => {},
+ read: async (): Promise<number | Deno.EOF> => {
+ return 0;
+ },
+ write: async (): Promise<number> => {
+ return -1;
+ },
+ close: (): void => {}
+ };
+
+ await request.respond(testCase.response);
+ assertEquals(buf.toString(), testCase.raw);
+ await request.done;
+ }
+});
+
+test(async function requestBodyWithContentLength(): Promise<void> {
+ {
+ const req = new ServerRequest();
+ req.headers = new Headers();
+ req.headers.set("content-length", "5");
+ const buf = new Buffer(enc.encode("Hello"));
+ req.r = new BufReader(buf);
+ const body = dec.decode(await req.body());
+ assertEquals(body, "Hello");
+ }
+
+ // Larger than internal buf
+ {
+ const longText = "1234\n".repeat(1000);
+ const req = new ServerRequest();
+ req.headers = new Headers();
+ req.headers.set("Content-Length", "5000");
+ const buf = new Buffer(enc.encode(longText));
+ req.r = new BufReader(buf);
+ const body = dec.decode(await req.body());
+ assertEquals(body, longText);
+ }
+});
+
+test(async function requestBodyWithTransferEncoding(): Promise<void> {
+ {
+ const shortText = "Hello";
+ const req = new ServerRequest();
+ req.headers = new Headers();
+ req.headers.set("transfer-encoding", "chunked");
+ let chunksData = "";
+ let chunkOffset = 0;
+ const maxChunkSize = 70;
+ while (chunkOffset < shortText.length) {
+ const chunkSize = Math.min(maxChunkSize, shortText.length - chunkOffset);
+ chunksData += `${chunkSize.toString(16)}\r\n${shortText.substr(
+ chunkOffset,
+ chunkSize
+ )}\r\n`;
+ chunkOffset += chunkSize;
+ }
+ chunksData += "0\r\n\r\n";
+ const buf = new Buffer(enc.encode(chunksData));
+ req.r = new BufReader(buf);
+ const body = dec.decode(await req.body());
+ assertEquals(body, shortText);
+ }
+
+ // Larger than internal buf
+ {
+ const longText = "1234\n".repeat(1000);
+ const req = new ServerRequest();
+ req.headers = new Headers();
+ req.headers.set("transfer-encoding", "chunked");
+ let chunksData = "";
+ let chunkOffset = 0;
+ const maxChunkSize = 70;
+ while (chunkOffset < longText.length) {
+ const chunkSize = Math.min(maxChunkSize, longText.length - chunkOffset);
+ chunksData += `${chunkSize.toString(16)}\r\n${longText.substr(
+ chunkOffset,
+ chunkSize
+ )}\r\n`;
+ chunkOffset += chunkSize;
+ }
+ chunksData += "0\r\n\r\n";
+ const buf = new Buffer(enc.encode(chunksData));
+ req.r = new BufReader(buf);
+ const body = dec.decode(await req.body());
+ assertEquals(body, longText);
+ }
+});
+
+test(async function requestBodyStreamWithContentLength(): Promise<void> {
+ {
+ const shortText = "Hello";
+ const req = new ServerRequest();
+ req.headers = new Headers();
+ req.headers.set("content-length", "" + shortText.length);
+ const buf = new Buffer(enc.encode(shortText));
+ req.r = new BufReader(buf);
+ const it = await req.bodyStream();
+ let offset = 0;
+ for await (const chunk of it) {
+ const s = dec.decode(chunk);
+ assertEquals(shortText.substr(offset, s.length), s);
+ offset += s.length;
+ }
+ }
+
+ // Larger than internal buf
+ {
+ const longText = "1234\n".repeat(1000);
+ const req = new ServerRequest();
+ req.headers = new Headers();
+ req.headers.set("Content-Length", "5000");
+ const buf = new Buffer(enc.encode(longText));
+ req.r = new BufReader(buf);
+ const it = await req.bodyStream();
+ let offset = 0;
+ for await (const chunk of it) {
+ const s = dec.decode(chunk);
+ assertEquals(longText.substr(offset, s.length), s);
+ offset += s.length;
+ }
+ }
+});
+
+test(async function requestBodyStreamWithTransferEncoding(): Promise<void> {
+ {
+ const shortText = "Hello";
+ const req = new ServerRequest();
+ req.headers = new Headers();
+ req.headers.set("transfer-encoding", "chunked");
+ let chunksData = "";
+ let chunkOffset = 0;
+ const maxChunkSize = 70;
+ while (chunkOffset < shortText.length) {
+ const chunkSize = Math.min(maxChunkSize, shortText.length - chunkOffset);
+ chunksData += `${chunkSize.toString(16)}\r\n${shortText.substr(
+ chunkOffset,
+ chunkSize
+ )}\r\n`;
+ chunkOffset += chunkSize;
+ }
+ chunksData += "0\r\n\r\n";
+ const buf = new Buffer(enc.encode(chunksData));
+ req.r = new BufReader(buf);
+ const it = await req.bodyStream();
+ let offset = 0;
+ for await (const chunk of it) {
+ const s = dec.decode(chunk);
+ assertEquals(shortText.substr(offset, s.length), s);
+ offset += s.length;
+ }
+ }
+
+ // Larger than internal buf
+ {
+ const longText = "1234\n".repeat(1000);
+ const req = new ServerRequest();
+ req.headers = new Headers();
+ req.headers.set("transfer-encoding", "chunked");
+ let chunksData = "";
+ let chunkOffset = 0;
+ const maxChunkSize = 70;
+ while (chunkOffset < longText.length) {
+ const chunkSize = Math.min(maxChunkSize, longText.length - chunkOffset);
+ chunksData += `${chunkSize.toString(16)}\r\n${longText.substr(
+ chunkOffset,
+ chunkSize
+ )}\r\n`;
+ chunkOffset += chunkSize;
+ }
+ chunksData += "0\r\n\r\n";
+ const buf = new Buffer(enc.encode(chunksData));
+ req.r = new BufReader(buf);
+ const it = await req.bodyStream();
+ let offset = 0;
+ for await (const chunk of it) {
+ const s = dec.decode(chunk);
+ assertEquals(longText.substr(offset, s.length), s);
+ offset += s.length;
+ }
+ }
+});
+
+test(async function writeUint8ArrayResponse(): Promise<void> {
+ const shortText = "Hello";
+
+ const body = new TextEncoder().encode(shortText);
+ const res: Response = { body };
+
+ const buf = new Deno.Buffer();
+ await writeResponse(buf, res);
+
+ const decoder = new TextDecoder("utf-8");
+ const reader = new BufReader(buf);
+
+ let r: ReadLineResult;
+ r = assertNotEOF(await reader.readLine());
+ assertEquals(decoder.decode(r.line), "HTTP/1.1 200 OK");
+ assertEquals(r.more, false);
+
+ r = assertNotEOF(await reader.readLine());
+ assertEquals(decoder.decode(r.line), `content-length: ${shortText.length}`);
+ assertEquals(r.more, false);
+
+ r = assertNotEOF(await reader.readLine());
+ assertEquals(r.line.byteLength, 0);
+ assertEquals(r.more, false);
+
+ r = assertNotEOF(await reader.readLine());
+ assertEquals(decoder.decode(r.line), shortText);
+ assertEquals(r.more, false);
+
+ const eof = await reader.readLine();
+ assertEquals(eof, Deno.EOF);
+});
+
+test(async function writeStringReaderResponse(): Promise<void> {
+ const shortText = "Hello";
+
+ const body = new StringReader(shortText);
+ const res: Response = { body };
+
+ const buf = new Deno.Buffer();
+ await writeResponse(buf, res);
+
+ const decoder = new TextDecoder("utf-8");
+ const reader = new BufReader(buf);
+
+ let r: ReadLineResult;
+ r = assertNotEOF(await reader.readLine());
+ assertEquals(decoder.decode(r.line), "HTTP/1.1 200 OK");
+ assertEquals(r.more, false);
+
+ r = assertNotEOF(await reader.readLine());
+ assertEquals(decoder.decode(r.line), "transfer-encoding: chunked");
+ assertEquals(r.more, false);
+
+ r = assertNotEOF(await reader.readLine());
+ assertEquals(r.line.byteLength, 0);
+ assertEquals(r.more, false);
+
+ r = assertNotEOF(await reader.readLine());
+ assertEquals(decoder.decode(r.line), shortText.length.toString());
+ assertEquals(r.more, false);
+
+ r = assertNotEOF(await reader.readLine());
+ assertEquals(decoder.decode(r.line), shortText);
+ assertEquals(r.more, false);
+
+ r = assertNotEOF(await reader.readLine());
+ assertEquals(decoder.decode(r.line), "0");
+ assertEquals(r.more, false);
+});
+
+const mockConn = {
+ localAddr: "",
+ remoteAddr: "",
+ rid: -1,
+ closeRead: (): void => {},
+ closeWrite: (): void => {},
+ read: async (): Promise<number | Deno.EOF> => {
+ return 0;
+ },
+ write: async (): Promise<number> => {
+ return -1;
+ },
+ close: (): void => {}
+};
+
+test(async function readRequestError(): Promise<void> {
+ const input = `GET / HTTP/1.1
+malformedHeader
+`;
+ const reader = new BufReader(new StringReader(input));
+ let err;
+ try {
+ await readRequest(mockConn, reader);
+ } catch (e) {
+ err = e;
+ }
+ assert(err instanceof Error);
+ assertEquals(err.message, "malformed MIME header line: malformedHeader");
+});
+
+// Ported from Go
+// https://github.com/golang/go/blob/go1.12.5/src/net/http/request_test.go#L377-L443
+// TODO(zekth) fix tests
+test(async function testReadRequestError(): Promise<void> {
+ const testCases = [
+ {
+ in: "GET / HTTP/1.1\r\nheader: foo\r\n\r\n",
+ headers: [{ key: "header", value: "foo" }]
+ },
+ {
+ in: "GET / HTTP/1.1\r\nheader:foo\r\n",
+ err: UnexpectedEOFError
+ },
+ { in: "", err: Deno.EOF },
+ {
+ in: "HEAD / HTTP/1.1\r\nContent-Length:4\r\n\r\n",
+ err: "http: method cannot contain a Content-Length"
+ },
+ {
+ in: "HEAD / HTTP/1.1\r\n\r\n",
+ headers: []
+ },
+ // Multiple Content-Length values should either be
+ // deduplicated if same or reject otherwise
+ // See Issue 16490.
+ {
+ in:
+ "POST / HTTP/1.1\r\nContent-Length: 10\r\nContent-Length: 0\r\n\r\n" +
+ "Gopher hey\r\n",
+ err: "cannot contain multiple Content-Length headers"
+ },
+ {
+ in:
+ "POST / HTTP/1.1\r\nContent-Length: 10\r\nContent-Length: 6\r\n\r\n" +
+ "Gopher\r\n",
+ err: "cannot contain multiple Content-Length headers"
+ },
+ {
+ in:
+ "PUT / HTTP/1.1\r\nContent-Length: 6 \r\nContent-Length: 6\r\n" +
+ "Content-Length:6\r\n\r\nGopher\r\n",
+ headers: [{ key: "Content-Length", value: "6" }]
+ },
+ {
+ in: "PUT / HTTP/1.1\r\nContent-Length: 1\r\nContent-Length: 6 \r\n\r\n",
+ err: "cannot contain multiple Content-Length headers"
+ },
+ // Setting an empty header is swallowed by textproto
+ // see: readMIMEHeader()
+ // {
+ // in: "POST / HTTP/1.1\r\nContent-Length:\r\nContent-Length: 3\r\n\r\n",
+ // err: "cannot contain multiple Content-Length headers"
+ // },
+ {
+ in: "HEAD / HTTP/1.1\r\nContent-Length:0\r\nContent-Length: 0\r\n\r\n",
+ headers: [{ key: "Content-Length", value: "0" }]
+ },
+ {
+ in:
+ "POST / HTTP/1.1\r\nContent-Length:0\r\ntransfer-encoding: " +
+ "chunked\r\n\r\n",
+ headers: [],
+ err: "http: Transfer-Encoding and Content-Length cannot be send together"
+ }
+ ];
+ for (const test of testCases) {
+ const reader = new BufReader(new StringReader(test.in));
+ let err;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let req: any;
+ try {
+ req = await readRequest(mockConn, reader);
+ } catch (e) {
+ err = e;
+ }
+ if (test.err === Deno.EOF) {
+ assertEquals(req, Deno.EOF);
+ } else if (typeof test.err === "string") {
+ assertEquals(err.message, test.err);
+ } else if (test.err) {
+ assert(err instanceof (test.err as typeof UnexpectedEOFError));
+ } else {
+ assertEquals(err, undefined);
+ assertNotEquals(req, Deno.EOF);
+ for (const h of test.headers!) {
+ assertEquals((req! as ServerRequest).headers.get(h.key), h.value);
+ }
+ }
+ }
+});
+
+// Ported from https://github.com/golang/go/blob/f5c43b9/src/net/http/request_test.go#L535-L565
+test({
+ name: "[http] parseHttpVersion",
+ fn(): void {
+ const testCases = [
+ { in: "HTTP/0.9", want: [0, 9] },
+ { in: "HTTP/1.0", want: [1, 0] },
+ { in: "HTTP/1.1", want: [1, 1] },
+ { in: "HTTP/3.14", want: [3, 14] },
+ { in: "HTTP", err: true },
+ { in: "HTTP/one.one", err: true },
+ { in: "HTTP/1.1/", err: true },
+ { in: "HTTP/-1.0", err: true },
+ { in: "HTTP/0.-1", err: true },
+ { in: "HTTP/", err: true },
+ { in: "HTTP/1,0", err: true }
+ ];
+ for (const t of testCases) {
+ let r, err;
+ try {
+ r = parseHTTPVersion(t.in);
+ } catch (e) {
+ err = e;
+ }
+ if (t.err) {
+ assert(err instanceof Error, t.in);
+ } else {
+ assertEquals(err, undefined);
+ assertEquals(r, t.want, t.in);
+ }
+ }
+ }
+});
+
+test({
+ name: "[http] destroyed connection",
+ async fn(): Promise<void> {
+ // Runs a simple server as another process
+ const p = Deno.run({
+ args: [Deno.execPath(), "http/testdata/simple_server.ts", "--allow-net"],
+ stdout: "piped"
+ });
+
+ try {
+ const r = new TextProtoReader(new BufReader(p.stdout!));
+ const s = await r.readLine();
+ assert(s !== Deno.EOF && s.includes("server listening"));
+
+ let serverIsRunning = true;
+ p.status()
+ .then((): void => {
+ serverIsRunning = false;
+ })
+ .catch((_): void => {}); // Ignores the error when closing the process.
+
+ await delay(100);
+
+ // Reqeusts to the server and immediately closes the connection
+ const conn = await Deno.dial({ port: 4502 });
+ await conn.write(new TextEncoder().encode("GET / HTTP/1.0\n\n"));
+ conn.close();
+
+ // Waits for the server to handle the above (broken) request
+ await delay(100);
+
+ assert(serverIsRunning);
+ } finally {
+ // Stops the sever.
+ p.close();
+ }
+ }
+});
+
+runIfMain(import.meta);
diff --git a/std/http/testdata/simple_server.ts b/std/http/testdata/simple_server.ts
new file mode 100644
index 000000000..67b957ad5
--- /dev/null
+++ b/std/http/testdata/simple_server.ts
@@ -0,0 +1,11 @@
+// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license.
+// This is an example of a server that responds with an empty body
+import { serve } from "../server.ts";
+
+window.onload = async function main() {
+ const addr = "0.0.0.0:4502";
+ console.log(`Simple server listening on ${addr}`);
+ for await (const req of serve(addr)) {
+ req.respond({});
+ }
+}