diff options
Diffstat (limited to 'http')
-rw-r--r-- | http/README.md | 26 | ||||
-rwxr-xr-x | http/file_server.ts | 241 | ||||
-rw-r--r-- | http/file_server_test.ts | 51 | ||||
-rw-r--r-- | http/http.ts | 325 | ||||
-rw-r--r-- | http/http_bench.ts | 15 | ||||
-rw-r--r-- | http/http_status.ts | 134 | ||||
-rw-r--r-- | http/http_test.ts | 223 | ||||
-rw-r--r-- | http/mod.ts | 8 |
8 files changed, 1023 insertions, 0 deletions
diff --git a/http/README.md b/http/README.md new file mode 100644 index 000000000..e81e42a41 --- /dev/null +++ b/http/README.md @@ -0,0 +1,26 @@ +# net + +Usage: + +```typescript +import { serve } from "https://deno.land/x/http/mod.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. + +Add the following to your `.bash_profile` + +``` +alias file_server="deno https://deno.land/x/http/file_server.ts --allow-net" +``` diff --git a/http/file_server.ts b/http/file_server.ts new file mode 100755 index 000000000..4437a44e4 --- /dev/null +++ b/http/file_server.ts @@ -0,0 +1,241 @@ +#!/usr/bin/env deno --allow-net + +// 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 + +import { + listenAndServe, + ServerRequest, + setContentLength, + Response +} from "./mod.ts"; +import { cwd, DenoError, ErrorKind, args, stat, readDir, open } from "deno"; +import { extname } 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; + } +} +let currentDir = cwd(); +const target = serverArgs[1]; +if (target) { + currentDir = `${currentDir}/${target}`; +} +const addr = `0.0.0.0:${serverArgs[2] || 4500}`; +const encoder = new TextEncoder(); + +function modeToString(isDir: boolean, maybeMode: number | null) { + 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 => { + output = modeMap[+v] + output; + }); + output = `(${isDir ? "d" : "-"}${output})`; + return output; +} + +function fileLenToString(len: number) { + const multipler = 1024; + let base = 1; + const suffix = ["B", "K", "M", "G", "T"]; + let suffixIndex = 0; + + while (base * multipler < len) { + if (suffixIndex >= suffix.length - 1) { + break; + } + base *= multipler; + suffixIndex++; + } + + return `${(len / base).toFixed(2)}${suffix[suffixIndex]}`; +} + +function createDirEntryDisplay( + name: string, + path: string, + size: number | null, + mode: number | null, + isDir: boolean +) { + const sizeStr = size === null ? "" : "" + fileLenToString(size!); + return ` + <tr><td class="mode">${modeToString( + isDir, + mode + )}</td><td>${sizeStr}</td><td><a href="${path}">${name}${ + isDir ? "/" : "" + }</a></td> + </tr> + `; +} + +// TODO: simplify this after deno.stat and deno.readDir are fixed +async function serveDir(req: ServerRequest, dirPath: string, dirName: string) { + // dirname has no prefix + const listEntry: string[] = []; + const fileInfos = await readDir(dirPath); + for (const info of fileInfos) { + if (info.name === "index.html" && info.isFile()) { + // in case index.html as dir... + return await serveFile(req, info.path); + } + // Yuck! + let mode = null; + try { + mode = (await stat(info.path)).mode; + } catch (e) {} + listEntry.push( + createDirEntryDisplay( + info.name, + dirName + "/" + info.name, + info.isFile() ? info.len : null, + mode, + info.isDirectory() + ) + ); + } + + const page = new TextEncoder().encode( + dirViewerTemplate + .replace("<%DIRNAME%>", dirName + "/") + .replace("<%CONTENTS%>", listEntry.join("")) + ); + + const headers = new Headers(); + headers.set("content-type", "text/html"); + + const res = { + status: 200, + body: page, + headers + }; + setContentLength(res); + return res; +} + +async function serveFile(req: ServerRequest, filename: string) { + const file = await open(filename); + const fileInfo = await stat(filename); + const headers = new Headers(); + headers.set("content-length", fileInfo.len.toString()); + headers.set("content-type", contentType(extname(filename)) || "text/plain"); + + const res = { + status: 200, + body: file, + headers + }; + return res; +} + +async function serveFallback(req: ServerRequest, e: Error) { + if ( + e instanceof DenoError && + (e as DenoError<any>).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) { + 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) { + 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 => { + const fileName = req.url.replace(/\/$/, ""); + const filePath = currentDir + fileName; + + let response: Response; + + try { + const fileInfo = await stat(filePath); + if (fileInfo.isDirectory()) { + // Bug with deno.stat: name and path not populated + // Yuck! + response = await serveDir(req, filePath, fileName); + } else { + response = await serveFile(req, filePath); + } + } 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/http/file_server_test.ts b/http/file_server_test.ts new file mode 100644 index 000000000..bd00d749b --- /dev/null +++ b/http/file_server_test.ts @@ -0,0 +1,51 @@ +import { readFile } from "deno"; + +import { test, assert, assertEqual } from "../testing/mod.ts"; + +// Promise to completeResolve when all tests completes +let completeResolve; +export const completePromise = new Promise(res => (completeResolve = res)); +let completedTestCount = 0; + +function maybeCompleteTests() { + completedTestCount++; + // Change this when adding more tests + if (completedTestCount === 3) { + completeResolve(); + } +} + +export function runTests(serverReadyPromise: Promise<any>) { + test(async function serveFile() { + await serverReadyPromise; + 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")); + assertEqual(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") + ); + assertEqual(downloadedFile, localFile); + maybeCompleteTests(); + }); + + test(async function serveDirectory() { + await serverReadyPromise; + 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")); + maybeCompleteTests(); + }); + + test(async function serveFallback() { + await serverReadyPromise; + 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")); + assertEqual(res.status, 404); + maybeCompleteTests(); + }); +} diff --git a/http/http.ts b/http/http.ts new file mode 100644 index 000000000..da7bc0169 --- /dev/null +++ b/http/http.ts @@ -0,0 +1,325 @@ +import { listen, Conn, toAsyncIterator, Reader, copy } from "deno"; +import { BufReader, BufState, BufWriter } from "../io/bufio.ts"; +import { TextProtoReader } from "../textproto/mod.ts"; +import { STATUS_TEXT } from "./http_status.ts"; +import { assert } from "../io/util.ts"; + +interface Deferred { + promise: Promise<{}>; + resolve: () => void; + reject: () => void; +} + +function deferred(): Deferred { + let resolve, reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { + promise, + resolve, + reject + }; +} + +interface ServeEnv { + reqQueue: ServerRequest[]; + serveDeferred: Deferred; +} + +/** Continuously read more requests from conn until EOF + * Calls maybeHandleReq. + * bufr is empty on a fresh TCP connection. + * Would be passed around and reused for later request on same conn + * TODO: make them async function after this change is done + * https://github.com/tc39/ecma262/pull/1250 + * See https://v8.dev/blog/fast-async + */ +function serveConn(env: ServeEnv, conn: Conn, bufr?: BufReader) { + readRequest(conn, bufr).then(maybeHandleReq.bind(null, env, conn)); +} +function maybeHandleReq(env: ServeEnv, conn: Conn, maybeReq: any) { + const [req, _err] = maybeReq; + if (_err) { + conn.close(); // assume EOF for now... + return; + } + env.reqQueue.push(req); // push req to queue + env.serveDeferred.resolve(); // signal while loop to process it +} + +export async function* serve(addr: string) { + const listener = listen("tcp", addr); + const env: ServeEnv = { + reqQueue: [], // in case multiple promises are ready + serveDeferred: deferred() + }; + + // Routine that keeps calling accept + const acceptRoutine = () => { + const handleConn = (conn: Conn) => { + serveConn(env, conn); // don't block + scheduleAccept(); // schedule next accept + }; + const scheduleAccept = () => { + listener.accept().then(handleConn); + }; + scheduleAccept(); + }; + + acceptRoutine(); + + // Loop hack to allow yield (yield won't work in callbacks) + while (true) { + await env.serveDeferred.promise; + env.serveDeferred = deferred(); // use a new deferred + let queueToProcess = env.reqQueue; + env.reqQueue = []; + for (const result of queueToProcess) { + yield result; + // Continue read more from conn when user is done with the current req + // Moving this here makes it easier to manage + serveConn(env, result.conn, result.r); + } + } + listener.close(); +} + +export async function listenAndServe( + addr: string, + handler: (req: ServerRequest) => void +) { + const server = serve(addr); + + for await (const request of server) { + await handler(request); + } +} + +export interface Response { + status?: number; + headers?: Headers; + body?: Uint8Array | Reader; +} + +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"); + } + } + } +} + +export class ServerRequest { + url: string; + method: string; + proto: string; + headers: Headers; + conn: Conn; + r: BufReader; + w: BufWriter; + + public async *bodyStream() { + 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.nread; + while (!rr.eof && nread < len) { + yield buf.subarray(0, rr.nread); + buf = new Uint8Array(1024); + rr = await this.r.read(buf); + nread += rr.nread; + } + yield buf.subarray(0, rr.nread); + } else { + if (this.headers.has("transfer-encoding")) { + const transferEncodings = this.headers + .get("transfer-encoding") + .split(",") + .map(e => 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(); + // TODO: handle chunk extension + let [chunkSizeString, optExt] = line.split(";"); + let chunkSize = parseInt(chunkSizeString, 16); + if (Number.isNaN(chunkSize) || chunkSize < 0) { + throw new Error("Invalid chunk size"); + } + while (chunkSize > 0) { + let data = new Uint8Array(chunkSize); + let [nread, err] = await this.r.readFull(data); + if (nread !== chunkSize) { + throw new Error("Chunk data does not match size"); + } + yield data; + await this.r.readLine(); // Consume \r\n + [line, _] = await tp.readLine(); + chunkSize = parseInt(line, 16); + } + const [entityHeaders, err] = await tp.readMIMEHeader(); + if (!err) { + for (let [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 readAllIterator(this.bodyStream()); + } + + private async _streamBody(body: Reader, bodyLength: number) { + const n = await copy(this.w, body); + assert(n == bodyLength); + } + + private async _streamChunkedBody(body: Reader) { + const encoder = new TextEncoder(); + + for await (const chunk of toAsyncIterator(body)) { + const start = encoder.encode(`${chunk.byteLength.toString(16)}\r\n`); + const end = encoder.encode("\r\n"); + await this.w.write(start); + await this.w.write(chunk); + await this.w.write(end); + } + + const endChunk = encoder.encode("0\r\n\r\n"); + await this.w.write(endChunk); + } + + async respond(r: Response): Promise<void> { + const protoMajor = 1; + const protoMinor = 1; + const statusCode = r.status || 200; + const statusText = STATUS_TEXT.get(statusCode); + if (!statusText) { + throw Error("bad status code"); + } + + let out = `HTTP/${protoMajor}.${protoMinor} ${statusCode} ${statusText}\r\n`; + + setContentLength(r); + + if (r.headers) { + for (const [key, value] of r.headers) { + out += `${key}: ${value}\r\n`; + } + } + out += "\r\n"; + + const header = new TextEncoder().encode(out); + let n = await this.w.write(header); + assert(header.byteLength == n); + + if (r.body) { + if (r.body instanceof Uint8Array) { + n = await this.w.write(r.body); + assert(r.body.byteLength == n); + } else { + if (r.headers.has("content-length")) { + await this._streamBody( + r.body, + parseInt(r.headers.get("content-length")) + ); + } else { + await this._streamChunkedBody(r.body); + } + } + } + + await this.w.flush(); + } +} + +async function readRequest( + c: Conn, + bufr?: BufReader +): Promise<[ServerRequest, BufState]> { + if (!bufr) { + bufr = new BufReader(c); + } + const bufw = new BufWriter(c); + const req = new ServerRequest(); + req.conn = c; + req.r = bufr!; + req.w = bufw; + const tp = new TextProtoReader(bufr!); + + let s: string; + let err: BufState; + + // First line: GET /index.html HTTP/1.0 + [s, err] = await tp.readLine(); + if (err) { + return [null, err]; + } + [req.method, req.url, req.proto] = s.split(" ", 3); + + [req.headers, err] = await tp.readMIMEHeader(); + + return [req, err]; +} + +async function readAllIterator( + it: AsyncIterableIterator<Uint8Array> +): Promise<Uint8Array> { + const chunks = []; + let len = 0; + for await (const chunk of it) { + chunks.push(chunk); + len += chunk.length; + } + if (chunks.length === 0) { + // No need for copy + return chunks[0]; + } + const collected = new Uint8Array(len); + let offset = 0; + for (let chunk of chunks) { + collected.set(chunk, offset); + offset += chunk.length; + } + return collected; +} diff --git a/http/http_bench.ts b/http/http_bench.ts new file mode 100644 index 000000000..5aca12f55 --- /dev/null +++ b/http/http_bench.ts @@ -0,0 +1,15 @@ +import * as deno from "deno"; +import { serve } from "./mod.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> { + for await (const request of server) { + await request.respond({ status: 200, body }); + } +} + +main(); diff --git a/http/http_status.ts b/http/http_status.ts new file mode 100644 index 000000000..a3006d319 --- /dev/null +++ b/http/http_status.ts @@ -0,0 +1,134 @@ +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/http/http_test.ts b/http/http_test.ts new file mode 100644 index 000000000..ba0cec3e3 --- /dev/null +++ b/http/http_test.ts @@ -0,0 +1,223 @@ +// 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 + +import { Buffer } from "deno"; +import { test, assert, assertEqual } from "../testing/mod.ts"; +import { + listenAndServe, + ServerRequest, + setContentLength, + Response +} from "./mod.ts"; +import { BufWriter, BufReader } from "../io/bufio.ts"; + +interface ResponseTest { + response: Response; + raw: string; +} + +const enc = new TextEncoder(); +const dec = new TextDecoder(); + +const responseTests: ResponseTest[] = [ + // Default response + { + response: {}, + raw: "HTTP/1.1 200 OK\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() { + for (const testCase of responseTests) { + const buf = new Buffer(); + const bufw = new BufWriter(buf); + const request = new ServerRequest(); + request.w = bufw; + + await request.respond(testCase.response); + assertEqual(buf.toString(), testCase.raw); + } +}); + +test(async function requestBodyWithContentLength() { + { + 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()); + assertEqual(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()); + assertEqual(body, longText); + } +}); + +test(async function requestBodyWithTransferEncoding() { + { + 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()); + assertEqual(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()); + assertEqual(body, longText); + } +}); + +test(async function requestBodyStreamWithContentLength() { + { + 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); + assertEqual(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); + assertEqual(longText.substr(offset, s.length), s); + offset += s.length; + } + } +}); + +test(async function requestBodyStreamWithTransferEncoding() { + { + 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); + assertEqual(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); + assertEqual(longText.substr(offset, s.length), s); + offset += s.length; + } + } +}); diff --git a/http/mod.ts b/http/mod.ts new file mode 100644 index 000000000..217bd68b3 --- /dev/null +++ b/http/mod.ts @@ -0,0 +1,8 @@ +import { + serve, + listenAndServe, + Response, + setContentLength, + ServerRequest +} from "./http.ts"; +export { serve, listenAndServe, Response, setContentLength, ServerRequest }; |