From b78f4e9fbd477b026f1a309c64f4f8f75cd4d5d6 Mon Sep 17 00:00:00 2001 From: "Kevin (Kun) \"Kassimo\" Qian" Date: Tue, 11 Dec 2018 17:56:32 -0500 Subject: [PATCH] Serve directory for file server & Fix bufio flush bug (#15) --- .travis.yml | 2 +- bufio.ts | 2 +- file_server.ts | 189 +++++++++++++++++++++++++++++++++++++++----- file_server_test.ts | 46 +++++++++++ http.ts | 13 +-- test.ts | 15 ++++ 6 files changed, 240 insertions(+), 27 deletions(-) create mode 100644 file_server_test.ts diff --git a/.travis.yml b/.travis.yml index b3b2391af..8c4abc16f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,4 +5,4 @@ install: - export PATH="$HOME/.deno/bin:$PATH" script: -- deno test.ts +- deno test.ts --allow-run --allow-net diff --git a/bufio.ts b/bufio.ts index 819c610f9..a1f673653 100644 --- a/bufio.ts +++ b/bufio.ts @@ -425,7 +425,7 @@ export class BufWriter implements Writer { } else { n = copyBytes(this.buf, p, this.n); this.n += n; - this.flush(); + await this.flush(); } nn += n; p = p.subarray(n); diff --git a/file_server.ts b/file_server.ts index 9dcac8704..d2b9fe0b0 100755 --- a/file_server.ts +++ b/file_server.ts @@ -5,42 +5,191 @@ // TODO Add tests like these: // https://github.com/indexzero/http-server/blob/master/test/http-server-test.js -import { listenAndServe } from "./http"; -import { cwd, readFile, DenoError, ErrorKind, args } from "deno"; +import { listenAndServe, ServerRequest, setContentLength } from "./http"; +import { cwd, readFile, DenoError, ErrorKind, args, stat, readDir } from "deno"; + +const dirViewerTemplate = ` + + + + + + + Deno File Server + + + +

Index of <%DIRNAME%>

+ + + <%CONTENTS%> +
ModeSizeName
+ + +`; -const addr = "0.0.0.0:4500"; let currentDir = cwd(); const target = args[1]; if (target) { currentDir = `${currentDir}/${target}`; } +const addr = `0.0.0.0:${args[2] || 4500}`; const encoder = new TextEncoder(); -listenAndServe(addr, async req => { - const fileName = req.url.replace(/\/$/, '/index.html'); - const filePath = currentDir + fileName; - let file; +function modeToString(isDir: boolean, maybeMode: number | null) { + const modeMap = ["---", "--x", "-w-", "-wx", "r--", "r-x", "rw-", "rwx"]; - try { - file = await readFile(filePath); - } catch (e) { - if (e instanceof DenoError && e.kind === ErrorKind.NotFound) { - await req.respond({ status: 404, body: encoder.encode("Not found") }); - } else { - await req.respond({ status: 500, body: encoder.encode("Internal server error") }); - } - return; + 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 ` + ${modeToString( + isDir, + mode + )}${sizeStr}${name}${ + isDir ? "/" : "" + } + + `; +} + +// 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... + await serveFile(req, info.path); + return; + } + // 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', 'octet-stream'); + headers.set("content-type", "text/html"); + + const res = { + status: 200, + body: page, + headers + }; + setContentLength(res); + await req.respond(res); +} + +async function serveFile(req: ServerRequest, filename: string) { + let file = await readFile(filename); + const headers = new Headers(); + headers.set("content-type", "octet-stream"); const res = { status: 200, body: file, - headers, - } + headers + }; await req.respond(res); +} + +async function serveFallback(req: ServerRequest, e: Error) { + if ( + e instanceof DenoError && + (e as DenoError).kind === ErrorKind.NotFound + ) { + await req.respond({ status: 404, body: encoder.encode("Not found") }); + } else { + await req.respond({ + status: 500, + body: encoder.encode("Internal server error") + }); + } +} + +listenAndServe(addr, async req => { + const fileName = req.url.replace(/\/$/, ""); + const filePath = currentDir + fileName; + + try { + const fileInfo = await stat(filePath); + if (fileInfo.isDirectory()) { + // Bug with deno.stat: name and path not populated + // Yuck! + await serveDir(req, filePath, fileName); + } else { + await serveFile(req, filePath); + } + } catch (e) { + await serveFallback(req, e); + return; + } }); console.log(`HTTP server listening on http://${addr}/`); diff --git a/file_server_test.ts b/file_server_test.ts new file mode 100644 index 000000000..a04ced7e5 --- /dev/null +++ b/file_server_test.ts @@ -0,0 +1,46 @@ +import { readFile } from "deno"; + +import { + test, + assert, + assertEqual +} from "https://deno.land/x/testing/testing.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) { + test(async function serveFile() { + await serverReadyPromise; + const res = await fetch("http://localhost:4500/.travis.yml"); + const downloadedFile = await res.text(); + const localFile = new TextDecoder().decode(await readFile("./.travis.yml")); + assertEqual(downloadedFile, localFile); + maybeCompleteTests(); + }); + + test(async function serveDirectory() { + await serverReadyPromise; + const res = await fetch("http://localhost:4500/"); + const page = await res.text(); + assert(page.includes(".travis.yml")); + maybeCompleteTests(); + }); + + test(async function serveFallback() { + await serverReadyPromise; + const res = await fetch("http://localhost:4500/badfile.txt"); + assertEqual(res.status, 404); + maybeCompleteTests(); + }); +} diff --git a/http.ts b/http.ts index b11e2b369..6954a48ba 100644 --- a/http.ts +++ b/http.ts @@ -82,7 +82,10 @@ export async function* serve(addr: string) { listener.close(); } -export async function listenAndServe(addr: string, handler: (req: ServerRequest) => void) { +export async function listenAndServe( + addr: string, + handler: (req: ServerRequest) => void +) { const server = serve(addr); for await (const request of server) { @@ -90,23 +93,23 @@ export async function listenAndServe(addr: string, handler: (req: ServerRequest) } } -interface Response { +export interface Response { status?: number; headers?: Headers; body?: Uint8Array; } -function setContentLength(r: Response): void { +export function setContentLength(r: Response): void { if (!r.headers) { r.headers = new Headers(); } if (!r.headers.has("content-length")) { - const bodyLength = r.body ? r.body.byteLength : 0 + const bodyLength = r.body ? r.body.byteLength : 0; r.headers.append("Content-Length", bodyLength.toString()); } } -class ServerRequest { +export class ServerRequest { url: string; method: string; proto: string; diff --git a/test.ts b/test.ts index 2ee9a820b..44a692015 100644 --- a/test.ts +++ b/test.ts @@ -1,4 +1,19 @@ +import { run } from "deno"; + import "./buffer_test.ts"; import "./bufio_test.ts"; import "./textproto_test.ts"; +import { runTests, completePromise } from "./file_server_test.ts"; + +// file server test +const fileServer = run({ + args: ["deno", "--allow-net", "file_server.ts", "."] +}); +// I am also too lazy to do this properly LOL +runTests(new Promise(res => setTimeout(res, 1000))); +(async () => { + await completePromise; + fileServer.close(); +})(); + // TODO import "./http_test.ts";