Skip to main content
Module

std/http/file_server.ts

Deno standard library
Go to Latest
File
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806
#!/usr/bin/env -S deno run --allow-net --allow-read// Copyright 2018-2023 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
/** * Contains functions {@linkcode serveDir} and {@linkcode serveFile} for building a static file server. * * This module can also be used as a cli. If you want to run directly: * * ```shell * > # start server * > deno run --allow-net --allow-read https://deno.land/std@$STD_VERSION/http/file_server.ts * > # show help * > deno run --allow-net --allow-read https://deno.land/std@$STD_VERSION/http/file_server.ts --help * ``` * * If you want to install and run: * * ```shell * > # install * > deno install --allow-net --allow-read https://deno.land/std@$STD_VERSION/http/file_server.ts * > # start server * > file_server * > # show help * > file_server --help * ``` * * @module */
import { extname, posix } from "../path/mod.ts";import { contentType } from "../media_types/content_type.ts";import { serve, serveTls } from "./server.ts";import { calculate, ifNoneMatch } from "./etag.ts";import { isRedirectStatus, Status } from "./http_status.ts";import { ByteSliceStream } from "../streams/byte_slice_stream.ts";import { parse } from "../flags/mod.ts";import { red } from "../fmt/colors.ts";import { createCommonResponse } from "./util.ts";import { VERSION } from "../version.ts";import { format as formatBytes } from "../fmt/bytes.ts";
interface EntryInfo { mode: string; size: string; url: string; name: string;}
const ENV_PERM_STATUS = Deno.permissions.querySync?.({ name: "env", variable: "DENO_DEPLOYMENT_ID" }) .state ?? "granted"; // for deno deployconst DENO_DEPLOYMENT_ID = ENV_PERM_STATUS === "granted" ? Deno.env.get("DENO_DEPLOYMENT_ID") : undefined;const HASHED_DENO_DEPLOYMENT_ID = DENO_DEPLOYMENT_ID ? calculate(DENO_DEPLOYMENT_ID, { weak: true }) : undefined;
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) => { output = `${modeMap[+v]} ${output}`; }); output = `${isDir ? "d" : "-"} ${output}`; return output;}
/** * parse range header. * * ```ts ignore * parseRangeHeader("bytes=0-100", 500); // => { start: 0, end: 100 } * parseRangeHeader("bytes=0-", 500); // => { start: 0, end: 499 } * parseRangeHeader("bytes=-100", 500); // => { start: 400, end: 499 } * parseRangeHeader("bytes=invalid", 500); // => null * ``` * * Note: Currently, no support for multiple Ranges (e.g. `bytes=0-10, 20-30`) */function parseRangeHeader(rangeValue: string, fileSize: number) { const rangeRegex = /bytes=(?<start>\d+)?-(?<end>\d+)?$/u; const parsed = rangeValue.match(rangeRegex);
if (!parsed || !parsed.groups) { // failed to parse range header return null; }
const { start, end } = parsed.groups; if (start !== undefined) { if (end !== undefined) { return { start: +start, end: +end }; } else { return { start: +start, end: fileSize - 1 }; } } else { if (end !== undefined) { // example: `bytes=-100` means the last 100 bytes. return { start: fileSize - +end, end: fileSize - 1 }; } else { // failed to parse range header return null; } }}
/** Interface for serveFile options. */export interface ServeFileOptions { /** The algorithm to use for generating the ETag. * * @default {"SHA-256"} */ etagAlgorithm?: AlgorithmIdentifier; /** An optional FileInfo object returned by Deno.stat. It is used for optimization purposes. */ fileInfo?: Deno.FileInfo;}
/** * 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, { etagAlgorithm: algorithm, fileInfo }: ServeFileOptions = {},): Promise<Response> { try { fileInfo ??= await Deno.stat(filePath); } catch (error) { if (error instanceof Deno.errors.NotFound) { await req.body?.cancel(); return createCommonResponse(Status.NotFound); } else { throw error; } }
if (fileInfo.isDirectory) { await req.body?.cancel(); return createCommonResponse(Status.NotFound); }
const headers = createBaseHeaders();
// Set date header if access timestamp is available if (fileInfo.atime) { headers.set("date", fileInfo.atime.toUTCString()); }
const etag = fileInfo.mtime ? await calculate(fileInfo, { algorithm }) : await HASHED_DENO_DEPLOYMENT_ID;
// Set last modified header if last modification timestamp is available if (fileInfo.mtime) { headers.set("last-modified", fileInfo.mtime.toUTCString()); } if (etag) { headers.set("etag", etag); }
if (etag || fileInfo.mtime) { // 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 ifNoneMatchValue = req.headers.get("if-none-match"); const ifModifiedSinceValue = req.headers.get("if-modified-since"); if ( (!ifNoneMatch(ifNoneMatchValue, etag)) || (ifNoneMatchValue === null && fileInfo.mtime && ifModifiedSinceValue && fileInfo.mtime.getTime() < new Date(ifModifiedSinceValue).getTime() + 1000) ) { return createCommonResponse(Status.NotModified, null, { headers }); } }
// Set mime-type using the file extension in filePath const contentTypeValue = contentType(extname(filePath)); if (contentTypeValue) { headers.set("content-type", contentTypeValue); }
const fileSize = fileInfo.size;
const rangeValue = req.headers.get("range");
// handle range request // Note: Some clients add a Range header to all requests to limit the size of the response. // If the file is empty, ignore the range header and respond with a 200 rather than a 416. // https://github.com/golang/go/blob/0d347544cbca0f42b160424f6bc2458ebcc7b3fc/src/net/http/fs.go#L273-L276 if (rangeValue && 0 < fileSize) { const parsed = parseRangeHeader(rangeValue, fileSize);
// Returns 200 OK if parsing the range header fails if (!parsed) { // Set content length headers.set("content-length", `${fileSize}`);
const file = await Deno.open(filePath); return createCommonResponse(Status.OK, file.readable, { headers }); }
// Return 416 Range Not Satisfiable if invalid range header value if ( parsed.end < 0 || parsed.end < parsed.start || fileSize <= parsed.start ) { // Set the "Content-range" header headers.set("content-range", `bytes */${fileSize}`);
return createCommonResponse( Status.RequestedRangeNotSatisfiable, undefined, { headers }, ); }
// clamps the range header value const start = Math.max(0, parsed.start); const end = Math.min(parsed.end, fileSize - 1);
// Set the "Content-range" header headers.set("content-range", `bytes ${start}-${end}/${fileSize}`);
// Set content length const contentLength = end - start + 1; headers.set("content-length", `${contentLength}`);
// Return 206 Partial Content const file = await Deno.open(filePath); await file.seek(start, Deno.SeekMode.Start); const sliced = file.readable .pipeThrough(new ByteSliceStream(0, contentLength - 1)); return createCommonResponse(Status.PartialContent, sliced, { headers }); }
// Set content length headers.set("content-length", `${fileSize}`);
const file = await Deno.open(filePath); return createCommonResponse(Status.OK, file.readable, { headers });}
async function serveDirIndex( dirPath: string, options: { showDotfiles: boolean; target: string; quiet: boolean | undefined; },): Promise<Response> { const { showDotfiles } = options; const dirUrl = `/${posix.relative(options.target, dirPath)}`; const listEntryPromise: Promise<EntryInfo>[] = [];
// if ".." makes sense if (dirUrl !== "/") { const prevPath = posix.join(dirPath, ".."); const entryInfo = Deno.stat(prevPath).then((fileInfo): EntryInfo => ({ mode: modeToString(true, fileInfo.mode), size: "", name: "../", url: posix.join(dirUrl, ".."), })); listEntryPromise.push(entryInfo); }
// Read fileInfo in parallel for await (const entry of Deno.readDir(dirPath)) { if (!showDotfiles && entry.name[0] === ".") { continue; } const filePath = posix.join(dirPath, entry.name); const fileUrl = encodeURIComponent(posix.join(dirUrl, entry.name)) .replaceAll("%2F", "/");
listEntryPromise.push((async () => { try { const fileInfo = await Deno.stat(filePath); return { mode: modeToString(entry.isDirectory, fileInfo.mode), size: entry.isFile ? formatBytes(fileInfo.size ?? 0) : "", name: `${entry.name}${entry.isDirectory ? "/" : ""}`, url: `${fileUrl}${entry.isDirectory ? "/" : ""}`, }; } catch (error) { // Note: Deno.stat for windows system files may be rejected with os error 32. if (!options.quiet) logError(error); return { mode: "(unknown mode)", size: "", name: `${entry.name}${entry.isDirectory ? "/" : ""}`, url: `${fileUrl}${entry.isDirectory ? "/" : ""}`, }; } })()); }
const listEntry = await Promise.all(listEntryPromise); listEntry.sort((a, b) => a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1 ); const formattedDirUrl = `${dirUrl.replace(/\/$/, "")}/`; const page = dirViewerTemplate(formattedDirUrl, listEntry);
const headers = createBaseHeaders(); headers.set("content-type", "text/html; charset=UTF-8");
return createCommonResponse(Status.OK, page, { headers });}
function serveFallback(maybeError: unknown): Response { if (maybeError instanceof URIError) { return createCommonResponse(Status.BadRequest); }
if (maybeError instanceof Deno.errors.NotFound) { return createCommonResponse(Status.NotFound); }
return createCommonResponse(Status.InternalServerError);}
function serverLog(req: Request, status: number) { const d = new Date().toISOString(); const dateFmt = `[${d.slice(0, 10)} ${d.slice(11, 19)}]`; const url = new URL(req.url); const s = `${dateFmt} [${req.method}] ${url.pathname}${url.search} ${status}`; // using console.debug instead of console.log so chrome inspect users can hide request logs console.debug(s);}
function createBaseHeaders(): Headers { return new Headers({ server: "deno", // Set "accept-ranges" so that the client knows it can make range requests on future requests "accept-ranges": "bytes", });}
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> `;}
/** Interface for serveDir options. */export interface ServeDirOptions { /** Serves the files under the given directory root. Defaults to your current directory. * * @default {"."} */ fsRoot?: string; /** Specified that part is stripped from the beginning of the requested pathname. * * @default {undefined} */ urlRoot?: string; /** Enable directory listing. * * @default {false} */ showDirListing?: boolean; /** Serves dotfiles. * * @default {false} */ showDotfiles?: boolean; /** Serves index.html as the index file of the directory. * * @default {true} */ showIndex?: boolean; /** Enable CORS via the "Access-Control-Allow-Origin" header. * * @default {false} */ enableCors?: boolean; /** Do not print request level logs. Defaults to false. * * @default {false} */ quiet?: boolean; /** The algorithm to use for generating the ETag. * * @default {"SHA-256"} */ etagAlgorithm?: AlgorithmIdentifier; /** Headers to add to each response * * @default {[]} */ headers?: string[];}
/** * Serves the files under the given directory root (opts.fsRoot). * * ```ts * import { serve } from "https://deno.land/std@$STD_VERSION/http/server.ts"; * import { serveDir } from "https://deno.land/std@$STD_VERSION/http/file_server.ts"; * * serve((req) => { * const pathname = new URL(req.url).pathname; * if (pathname.startsWith("/static")) { * return serveDir(req, { * fsRoot: "path/to/static/files/dir", * }); * } * // Do dynamic responses * return new Response(); * }); * ``` * * Optionally you can pass `urlRoot` option. If it's specified that part is stripped from the beginning of the requested pathname. * * ```ts * import { serveDir } from "https://deno.land/std@$STD_VERSION/http/file_server.ts"; * * // ... * serveDir(new Request("http://localhost/static/path/to/file"), { * fsRoot: "public", * urlRoot: "static", * }); * ``` * * The above example serves `./public/path/to/file` for the request to `/static/path/to/file`. * * @param req The request to handle */export async function serveDir(req: Request, opts: ServeDirOptions = {}) { let response: Response; try { response = await createServeDirResponse(req, opts); } catch (error) { if (!opts.quiet) logError(error); response = serveFallback(error); }
// Do not update the header if the response is a 301 redirect. const isRedirectResponse = isRedirectStatus(response.status);
if (opts.enableCors && !isRedirectResponse) { response.headers.append("access-control-allow-origin", "*"); response.headers.append( "access-control-allow-headers", "Origin, X-Requested-With, Content-Type, Accept, Range", ); }
if (!opts.quiet) serverLog(req, response.status);
if (opts.headers && !isRedirectResponse) { for (const header of opts.headers) { const headerSplit = header.split(":"); const name = headerSplit[0]; const value = headerSplit.slice(1).join(":"); response.headers.append(name, value); } }
return response;}
async function createServeDirResponse( req: Request, opts: ServeDirOptions,) { const target = opts.fsRoot || "."; const urlRoot = opts.urlRoot; const showIndex = opts.showIndex ?? true; const showDotfiles = opts.showDotfiles || false; const { etagAlgorithm, showDirListing, quiet } = opts;
const url = new URL(req.url); const decodedUrl = decodeURIComponent(url.pathname); let normalizedPath = posix.normalize(decodedUrl);
if (urlRoot && !normalizedPath.startsWith("/" + urlRoot)) { return createCommonResponse(Status.NotFound); }
// Redirect paths like `/foo////bar` and `/foo/bar/////` to normalized paths. if (normalizedPath !== decodedUrl) { url.pathname = normalizedPath; return Response.redirect(url, 301); }
if (urlRoot) { normalizedPath = normalizedPath.replace(urlRoot, ""); }
// Remove trailing slashes to avoid ENOENT errors // when accessing a path to a file with a trailing slash. if (normalizedPath.endsWith("/")) { normalizedPath = normalizedPath.slice(0, -1); }
const fsPath = posix.join(target, normalizedPath); const fileInfo = await Deno.stat(fsPath);
// For files, remove the trailing slash from the path. if (fileInfo.isFile && url.pathname.endsWith("/")) { url.pathname = url.pathname.slice(0, -1); return Response.redirect(url, 301); } // For directories, the path must have a trailing slash. if (fileInfo.isDirectory && !url.pathname.endsWith("/")) { // On directory listing pages, // if the current URL's pathname doesn't end with a slash, any // relative URLs in the index file will resolve against the parent // directory, rather than the current directory. To prevent that, we // return a 301 redirect to the URL with a slash. url.pathname += "/"; return Response.redirect(url, 301); }
// if target is file, serve file. if (!fileInfo.isDirectory) { return serveFile(req, fsPath, { etagAlgorithm, fileInfo, }); }
// if target is directory, serve index or dir listing. if (showIndex) { // serve index.html const indexPath = posix.join(fsPath, "index.html");
let indexFileInfo: Deno.FileInfo | undefined; try { indexFileInfo = await Deno.lstat(indexPath); } catch (error) { if (!(error instanceof Deno.errors.NotFound)) { throw error; } // skip Not Found error }
if (indexFileInfo?.isFile) { return serveFile(req, indexPath, { etagAlgorithm, fileInfo: indexFileInfo, }); } }
if (showDirListing) { // serve directory list return serveDirIndex(fsPath, { showDotfiles, target, quiet }); }
return createCommonResponse(Status.NotFound);}
function logError(error: unknown) { console.error(red(error instanceof Error ? error.message : `${error}`));}
function main() { const serverArgs = parse(Deno.args, { string: ["port", "host", "cert", "key", "header"], boolean: ["help", "dir-listing", "dotfiles", "cors", "verbose", "version"], negatable: ["dir-listing", "dotfiles", "cors"], collect: ["header"], default: { "dir-listing": true, dotfiles: true, cors: true, verbose: false, version: false, host: "0.0.0.0", port: "4507", cert: "", key: "", }, alias: { p: "port", c: "cert", k: "key", h: "help", v: "verbose", V: "version", H: "header", }, }); const port = Number(serverArgs.port); const headers = serverArgs.header || []; const host = serverArgs.host; const certFile = serverArgs.cert; const keyFile = serverArgs.key;
if (serverArgs.help) { printUsage(); Deno.exit(); }
if (serverArgs.version) { console.log(`Deno File Server ${VERSION}`); Deno.exit(); }
if (keyFile || certFile) { if (keyFile === "" || certFile === "") { console.log("--key and --cert are required for TLS"); printUsage(); Deno.exit(1); } }
const wild = serverArgs._ as string[]; const target = posix.resolve(wild[0] ?? "");
const handler = (req: Request): Promise<Response> => { return serveDir(req, { fsRoot: target, showDirListing: serverArgs["dir-listing"], showDotfiles: serverArgs.dotfiles, enableCors: serverArgs.cors, quiet: !serverArgs.verbose, headers, }); };
const useTls = !!(keyFile && certFile);
if (useTls) { serveTls(handler, { port, hostname: host, certFile, keyFile, }); } else { serve(handler, { port, hostname: host }); }}
function printUsage() { console.log(`Deno File Server ${VERSION} 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) -H, --header <HEADER> Sets a header on every request. (e.g. --header "Cache-Control: no-cache") This option can be specified multiple times. --no-dir-listing Disable directory listing --no-dotfiles Do not show dotfiles --no-cors Disable cross-origin resource sharing -v, --verbose Print request level logs -V, --version Print version information
All TLS options are required when one is provided.`);}
if (import.meta.main) { main();}