Skip to content

Instantly share code, notes, and snippets.

@bpevs
Last active February 14, 2022 23:51
Show Gist options
  • Save bpevs/de2f30bda961aeac9444de5345f68db4 to your computer and use it in GitHub Desktop.
Save bpevs/de2f30bda961aeac9444de5345f68db4 to your computer and use it in GitHub Desktop.
File Server Based on Standard Deno Fileserver

File Server

A basic file server, adapted from https://deno.land/[email protected]/http/file_server.ts The only difference is this one attempts to serve .html if there is no file extension.

Can install same as deno file_server. I like to rename to be serve:

deno install -f --allow-net --allow-read --name=serve https://gist.githubusercontent.com/ivebencrazy/de2f30bda961aeac9444de5345f68db4/raw/ff486c1206b1ee3f92514c2f8a578a8e7bbc6b45/file_server.ts

#!/usr/bin/env -S deno run --allow-net --allow-read
// Adapted from https://deno.land/[email protected]/http/file_server.ts
// Attempts to serve `.html` if no file extension
// deno install -f --allow-net --allow-read https://gist.githubusercontent.com/ivebencrazy/de2f30bda961aeac9444de5345f68db4/raw/b455789756c75a38832aaa3e5da7bcefc527fccc/file_server.ts
// Copyright 2018-2022 the Deno authors. All rights reserved. MIT license.
// This program serves files in the current directory over HTTP.
// TODO(bartlomieju): Add tests like these:
// https://github.com/indexzero/http-server/blob/master/test/http-server-test.js
import { extname, posix } from "https://deno.land/[email protected]/path/mod.ts";
import { serve, serveTls } from "https://deno.land/[email protected]/http/server.ts";
import {
Status,
STATUS_TEXT,
} from "https://deno.land/[email protected]/http/http_status.ts";
import { parse } from "https://deno.land/[email protected]/flags/mod.ts";
import { assert } from "https://deno.land/[email protected]/_util/assert.ts";
import { red } from "https://deno.land/[email protected]/fmt/colors.ts";
const DEFAULT_CHUNK_SIZE = 16_640;
interface EntryInfo {
mode: string;
size: string;
url: string;
name: string;
}
interface FileServerArgs {
_: string[];
// -p --port
port: string;
// --cors
cors: boolean;
// --no-dir-listing
"dir-listing": boolean;
dotfiles: boolean;
// --host
host: string;
// -c --cert
cert: string;
// -k --key
key: string;
// -h --help
help: boolean;
// --quiet
quiet: boolean;
}
const encoder = new TextEncoder();
const MEDIA_TYPES: Record<string, string> = {
".md": "text/markdown",
".html": "text/html",
".htm": "text/html",
".json": "application/json",
".map": "application/json",
".txt": "text/plain",
".ts": "text/typescript",
".tsx": "text/tsx",
".js": "application/javascript",
".jsx": "text/jsx",
".gz": "application/gzip",
".css": "text/css",
".wasm": "application/wasm",
".mjs": "application/javascript",
".otf": "font/otf",
".ttf": "font/ttf",
".woff": "font/woff",
".woff2": "font/woff2",
".conf": "text/plain",
".list": "text/plain",
".log": "text/plain",
".ini": "text/plain",
".vtt": "text/vtt",
".yaml": "text/yaml",
".yml": "text/yaml",
".mid": "audio/midi",
".midi": "audio/midi",
".mp3": "audio/mp3",
".mp4a": "audio/mp4",
".m4a": "audio/mp4",
".ogg": "audio/ogg",
".spx": "audio/ogg",
".opus": "audio/ogg",
".wav": "audio/wav",
".webm": "audio/webm",
".aac": "audio/x-aac",
".flac": "audio/x-flac",
".mp4": "video/mp4",
".mp4v": "video/mp4",
".mkv": "video/x-matroska",
".mov": "video/quicktime",
".svg": "image/svg+xml",
".avif": "image/avif",
".bmp": "image/bmp",
".gif": "image/gif",
".heic": "image/heic",
".heif": "image/heif",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".tiff": "image/tiff",
".psd": "image/vnd.adobe.photoshop",
".ico": "image/vnd.microsoft.icon",
".webp": "image/webp",
".es": "application/ecmascript",
".epub": "application/epub+zip",
".jar": "application/java-archive",
".war": "application/java-archive",
".webmanifest": "application/manifest+json",
".doc": "application/msword",
".dot": "application/msword",
".docx":
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".dotx":
"application/vnd.openxmlformats-officedocument.wordprocessingml.template",
".cjs": "application/node",
".bin": "application/octet-stream",
".pkg": "application/octet-stream",
".dump": "application/octet-stream",
".exe": "application/octet-stream",
".deploy": "application/octet-stream",
".img": "application/octet-stream",
".msi": "application/octet-stream",
".pdf": "application/pdf",
".pgp": "application/pgp-encrypted",
".asc": "application/pgp-signature",
".sig": "application/pgp-signature",
".ai": "application/postscript",
".eps": "application/postscript",
".ps": "application/postscript",
".rdf": "application/rdf+xml",
".rss": "application/rss+xml",
".rtf": "application/rtf",
".apk": "application/vnd.android.package-archive",
".key": "application/vnd.apple.keynote",
".numbers": "application/vnd.apple.keynote",
".pages": "application/vnd.apple.pages",
".geo": "application/vnd.dynageo",
".gdoc": "application/vnd.google-apps.document",
".gslides": "application/vnd.google-apps.presentation",
".gsheet": "application/vnd.google-apps.spreadsheet",
".kml": "application/vnd.google-earth.kml+xml",
".mkz": "application/vnd.google-earth.kmz",
".icc": "application/vnd.iccprofile",
".icm": "application/vnd.iccprofile",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".xlm": "application/vnd.ms-excel",
".ppt": "application/vnd.ms-powerpoint",
".pot": "application/vnd.ms-powerpoint",
".pptx":
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
".potx":
"application/vnd.openxmlformats-officedocument.presentationml.template",
".xps": "application/vnd.ms-xpsdocument",
".odc": "application/vnd.oasis.opendocument.chart",
".odb": "application/vnd.oasis.opendocument.database",
".odf": "application/vnd.oasis.opendocument.formula",
".odg": "application/vnd.oasis.opendocument.graphics",
".odp": "application/vnd.oasis.opendocument.presentation",
".ods": "application/vnd.oasis.opendocument.spreadsheet",
".odt": "application/vnd.oasis.opendocument.text",
".rar": "application/vnd.rar",
".unityweb": "application/vnd.unity",
".dmg": "application/x-apple-diskimage",
".bz": "application/x-bzip",
".crx": "application/x-chrome-extension",
".deb": "application/x-debian-package",
".php": "application/x-httpd-php",
".iso": "application/x-iso9660-image",
".sh": "application/x-sh",
".sql": "application/x-sql",
".srt": "application/x-subrip",
".xml": "application/xml",
".zip": "application/zip",
};
/** Returns the content-type based on the extension of a path. */
function contentType(path: string): string | undefined {
return MEDIA_TYPES[extname(path)];
}
// Generates a SHA-1 hash for the provided string
async function createEtagHash(message: string) {
const byteToHex = (b: number) => b.toString(16).padStart(2, "00");
const hashType = "SHA-1"; // Faster, and this isn't a security sensitive cryptographic use case
// see: https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest
const msgUint8 = encoder.encode(message);
const hashBuffer = await crypto.subtle.digest(hashType, msgUint8);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(byteToHex).join("");
return hashHex;
}
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]}`;
}
/**
* Returns an HTTP Response with the requested file as the body.
* @param req The server request context used to cleanup the file handle.
* @param filePath Path of the file to serve.
*/
export async function serveFile(
req: Request,
filePath: string,
): Promise<Response> {
const [file, fileInfo] = await Promise.all([
Deno.open(filePath),
Deno.stat(filePath),
]);
const headers = setBaseHeaders();
// Set mime-type using the file extension in filePath
const contentTypeValue = contentType(filePath);
if (contentTypeValue) {
headers.set("content-type", contentTypeValue);
}
// Set date header if access timestamp is available
if (fileInfo.atime instanceof Date) {
const date = new Date(fileInfo.atime);
headers.set("date", date.toUTCString());
}
// Set last modified header if access timestamp is available
if (fileInfo.mtime instanceof Date) {
const lastModified = new Date(fileInfo.mtime);
headers.set("last-modified", lastModified.toUTCString());
// Create a simple etag that is an md5 of the last modified date and filesize concatenated
const simpleEtag = await createEtagHash(
`${lastModified.toJSON()}${fileInfo.size}`,
);
headers.set("etag", simpleEtag);
// If a `if-none-match` header is present and the value matches the tag or
// if a `if-modified-since` header is present and the value is bigger than
// the access timestamp value, then return 304
const ifNoneMatch = req.headers.get("if-none-match");
const ifModifiedSince = req.headers.get("if-modified-since");
if (
(ifNoneMatch && ifNoneMatch === simpleEtag) ||
(ifNoneMatch === null &&
ifModifiedSince &&
fileInfo.mtime.getTime() < new Date(ifModifiedSince).getTime() + 1000)
) {
const status = Status.NotModified;
const statusText = STATUS_TEXT.get(status);
file.close();
return new Response(null, {
status,
statusText,
headers,
});
}
}
// Get and parse the "range" header
const range = req.headers.get("range") as string;
const rangeRe = /bytes=(\d+)-(\d+)?/;
const parsed = rangeRe.exec(range);
// Use the parsed value if available, fallback to the start and end of the entire file
const start = parsed && parsed[1] ? +parsed[1] : 0;
const end = parsed && parsed[2] ? +parsed[2] : fileInfo.size - 1;
let status = Status.OK;
// If there is a range, set the status to 206, and set the "Content-range" header.
if (range && parsed) {
status = Status.PartialContent;
headers.set("content-range", `bytes ${start}-${end}/${fileInfo.size}`);
}
// Return 416 if `start` isn't less than or equal to `end`, or `start` or `end` are greater than the file's size
const maxRange = fileInfo.size - 1;
if (
range &&
(!parsed ||
typeof start !== "number" ||
start > end ||
start > maxRange ||
end > maxRange)
) {
const status = Status.RequestedRangeNotSatisfiable;
const statusText = STATUS_TEXT.get(status);
file.close();
return new Response(statusText, {
status,
statusText,
headers,
});
}
// Set content length
const contentLength = end - start + 1;
headers.set("content-length", `${contentLength}`);
// Create a stream of the file instead of loading it into memory
let bytesSent = 0;
const body = new ReadableStream({
async start() {
if (start > 0) {
await file.seek(start, Deno.SeekMode.Start);
}
},
async pull(controller) {
const bytes = new Uint8Array(DEFAULT_CHUNK_SIZE);
const bytesRead = await file.read(bytes);
if (bytesRead === null) {
file.close();
controller.close();
return;
}
controller.enqueue(
bytes.slice(0, Math.min(bytesRead, contentLength - bytesSent)),
);
bytesSent += bytesRead;
if (bytesSent > contentLength) {
file.close();
controller.close();
}
},
});
const statusText = STATUS_TEXT.get(status);
return new Response(body, {
status,
statusText,
headers,
});
}
// TODO(bartlomieju): simplify this after deno.stat and deno.readDir are fixed
async function serveDir(
req: Request,
dirPath: string,
options: {
dotfiles: boolean;
target: string;
},
): Promise<Response> {
const showDotfiles = options.dotfiles;
const dirUrl = `/${posix.relative(options.target, dirPath)}`;
const listEntry: EntryInfo[] = [];
// if ".." makes sense
if (dirUrl !== "/") {
const prevPath = posix.join(dirPath, "..");
const fileInfo = await Deno.stat(prevPath);
listEntry.push({
mode: modeToString(true, fileInfo.mode),
size: "",
name: "../",
url: posix.join(dirUrl, ".."),
});
}
for await (const entry of Deno.readDir(dirPath)) {
if (!showDotfiles && entry.name[0] === ".") {
continue;
}
const filePath = posix.join(dirPath, entry.name);
const fileUrl = encodeURI(posix.join(dirUrl, entry.name));
if (entry.name === "index.html" && entry.isFile) {
// in case index.html as dir...
return serveFile(req, filePath);
}
const fileInfo = await Deno.stat(filePath);
listEntry.push({
mode: modeToString(entry.isDirectory, fileInfo.mode),
size: entry.isFile ? fileLenToString(fileInfo.size ?? 0) : "",
name: `${entry.name}${entry.isDirectory ? "/" : ""}`,
url: `${fileUrl}${entry.isDirectory ? "/" : ""}`,
});
}
listEntry.sort((a, b) =>
a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1
);
const formattedDirUrl = `${dirUrl.replace(/\/$/, "")}/`;
const page = encoder.encode(dirViewerTemplate(formattedDirUrl, listEntry));
const headers = setBaseHeaders();
headers.set("content-type", "text/html");
return new Response(page, { status: Status.OK, headers });
}
function serveFallback(_req: Request, e: Error): Promise<Response> {
if (e instanceof URIError) {
return Promise.resolve(
new Response(STATUS_TEXT.get(Status.BadRequest), {
status: Status.BadRequest,
}),
);
} else if (e instanceof Deno.errors.NotFound) {
return Promise.resolve(
new Response(STATUS_TEXT.get(Status.NotFound), {
status: Status.NotFound,
}),
);
}
return Promise.resolve(
new Response(STATUS_TEXT.get(Status.InternalServerError), {
status: Status.InternalServerError,
}),
);
}
function serverLog(req: Request, res: Response): void {
const d = new Date().toISOString();
const dateFmt = `[${d.slice(0, 10)} ${d.slice(11, 19)}]`;
const normalizedUrl = normalizeURL(req.url);
const s = `${dateFmt} [${req.method}] ${normalizedUrl} ${res.status}`;
// using console.debug instead of console.log so chrome inspect users can hide request logs
console.debug(s);
}
function setBaseHeaders(): Headers {
const headers = new Headers();
headers.set("server", "deno");
// Set "accept-ranges" so that the client knows it can make range requests on future requests
headers.set("accept-ranges", "bytes");
headers.set("date", new Date().toUTCString());
return headers;
}
function setCORS(res: Response): void {
res.headers.append("access-control-allow-origin", "*");
res.headers.append(
"access-control-allow-headers",
"Origin, X-Requested-With, Content-Type, Accept, Range",
);
}
function dirViewerTemplate(dirname: string, entries: EntryInfo[]): string {
const paths = dirname.split("/");
return `
<!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>
:root {
--background-color: #fafafa;
--color: rgba(0, 0, 0, 0.87);
}
@media (prefers-color-scheme: dark) {
:root {
--background-color: #292929;
--color: #fff;
}
thead {
color: #7f7f7f;
}
}
@media (min-width: 960px) {
main {
max-width: 960px;
}
body {
padding-left: 32px;
padding-right: 32px;
}
}
@media (min-width: 600px) {
main {
padding-left: 24px;
padding-right: 24px;
}
}
body {
background: var(--background-color);
color: var(--color);
font-family: "Roboto", "Helvetica", "Arial", sans-serif;
font-weight: 400;
line-height: 1.43;
font-size: 0.875rem;
}
a {
color: #2196f3;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
thead {
text-align: left;
}
thead th {
padding-bottom: 12px;
}
table td {
padding: 6px 36px 6px 0px;
}
.size {
text-align: right;
padding: 6px 12px 6px 24px;
}
.mode {
font-family: monospace, monospace;
}
</style>
</head>
<body>
<main>
<h1>Index of
<a href="/">home</a>${
paths
.map((path, index, array) => {
if (path === "") return "";
const link = array.slice(0, index + 1).join("/");
return `<a href="${link}">${path}</a>`;
})
.join("/")
}
</h1>
<table>
<thead>
<tr>
<th>Mode</th>
<th>Size</th>
<th>Name</th>
</tr>
</thead>
${
entries
.map(
(entry) => `
<tr>
<td class="mode">
${entry.mode}
</td>
<td class="size">
${entry.size}
</td>
<td>
<a href="${entry.url}">${entry.name}</a>
</td>
</tr>
`,
)
.join("")
}
</table>
</main>
</body>
</html>
`;
}
function normalizeURL(url: string): string {
let normalizedUrl = url;
try {
//allowed per https://www.w3.org/Protocols/rfc2616/rfc2616-sec5.html
const absoluteURI = new URL(normalizedUrl);
normalizedUrl = absoluteURI.pathname;
} catch (e) {
//wasn't an absoluteURI
if (!(e instanceof TypeError)) {
throw e;
}
}
try {
normalizedUrl = decodeURI(normalizedUrl);
} catch (e) {
if (!(e instanceof URIError)) {
throw e;
}
}
if (normalizedUrl[0] !== "/") {
throw new URIError("The request URI is malformed.");
}
normalizedUrl = posix.normalize(normalizedUrl);
const startOfParams = normalizedUrl.indexOf("?");
return startOfParams > -1
? normalizedUrl.slice(0, startOfParams)
: normalizedUrl;
}
function main(): void {
const serverArgs = parse(Deno.args, {
string: ["port", "host", "cert", "key"],
boolean: ["help", "dir-listing", "dotfiles", "cors", "quiet"],
default: {
"dir-listing": true,
dotfiles: true,
cors: true,
quiet: false,
host: "0.0.0.0",
port: "4507",
cert: "",
key: "",
},
alias: {
p: "port",
c: "cert",
k: "key",
h: "help",
},
}) as FileServerArgs;
const CORSEnabled = serverArgs.cors;
const port = serverArgs.port;
const host = serverArgs.host;
const certFile = serverArgs.cert;
const keyFile = serverArgs.key;
const dirListingEnabled = serverArgs["dir-listing"];
const quiet = serverArgs.quiet;
if (serverArgs.help) {
printUsage();
Deno.exit();
}
if (keyFile || certFile) {
if (keyFile === "" || certFile === "") {
console.log("--key and --cert are required for TLS");
printUsage();
Deno.exit(1);
}
}
const target = posix.resolve(serverArgs._[0] ?? "");
const handler = async (req: Request): Promise<Response> => {
let response: Response;
try {
const normalizedUrl = normalizeURL(req.url);
let fsPath = posix.join(target, normalizedUrl);
if (fsPath.indexOf(target) !== 0) {
fsPath = target;
}
let fileInfo;
try {
fileInfo = await Deno.stat(fsPath);
} catch (e) {
fileInfo = await Deno.stat(`${fsPath}.html`);
fsPath = `${fsPath}.html`;
}
if (fileInfo.isDirectory) {
if (dirListingEnabled) {
response = await serveDir(req, fsPath, {
dotfiles: serverArgs.dotfiles,
target,
});
} else {
throw new Deno.errors.NotFound();
}
} else {
response = await serveFile(req, fsPath);
}
} catch (e) {
const err = e instanceof Error ? e : new Error("[non-error thrown]");
console.error(red(err.message));
response = await serveFallback(req, err);
}
if (CORSEnabled) {
assert(response!);
setCORS(response);
}
if (!quiet) serverLog(req, response!);
return response!;
};
let proto = "http";
if (keyFile || certFile) {
proto += "s";
serveTls(handler, {
port: Number(port),
hostname: host,
certFile,
keyFile,
});
} else {
serve(handler, { port: Number(port), hostname: host });
}
console.log(
`${proto.toUpperCase()} server listening on ${proto}://${
host.replace(
"0.0.0.0",
"localhost",
)
}:${port}/`,
);
}
function printUsage() {
console.log(`Deno File Server
Serves a local directory in HTTP.
INSTALL:
deno install --allow-net --allow-read https://deno.land/std/http/file_server.ts
USAGE:
file_server [path] [options]
OPTIONS:
-h, --help Prints help information
-p, --port <PORT> Set port
--cors Enable CORS via the "Access-Control-Allow-Origin" header
--host <HOST> Hostname (default is 0.0.0.0)
-c, --cert <FILE> TLS certificate file (enables TLS)
-k, --key <FILE> TLS key file (enables TLS)
--no-dir-listing Disable directory listing
--no-dotfiles Do not show dotfiles
--quiet Do not print request level logs
All TLS options are required when one is provided.`);
}
if (import.meta.main) {
main();
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment