diff options
Diffstat (limited to 'std/http')
-rw-r--r-- | std/http/README.md | 77 | ||||
-rw-r--r-- | std/http/cookie.ts | 133 | ||||
-rw-r--r-- | std/http/cookie_test.ts | 210 | ||||
-rwxr-xr-x | std/http/file_server.ts | 262 | ||||
-rw-r--r-- | std/http/file_server_test.ts | 88 | ||||
-rw-r--r-- | std/http/http_bench.ts | 15 | ||||
-rw-r--r-- | std/http/http_status.ts | 135 | ||||
-rw-r--r-- | std/http/racing_server.ts | 50 | ||||
-rw-r--r-- | std/http/racing_server_test.ts | 65 | ||||
-rw-r--r-- | std/http/server.ts | 408 | ||||
-rw-r--r-- | std/http/server_test.ts | 530 | ||||
-rw-r--r-- | std/http/testdata/simple_server.ts | 11 |
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({}); + } +} |