#!/usr/bin/env -S deno run --allow-net --allow-read // Copyright 2018-2024 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 it directly: * * ```shell * > # start server * > deno run --allow-net --allow-read --allow-sys jsr:@std/http/file-server * > # show help * > deno run jsr:@std/http/file-server --help * ``` * * If you want to install and run: * * ```shell * > # install * > deno install --allow-net --allow-read --allow-sys --global jsr:@std/http/file-server * > # start server * > file-server * > # show help * > file-server --help * ``` * * @module */ import { join as posixJoin } from "@std/path/posix/join"; import { normalize as posixNormalize } from "@std/path/posix/normalize"; import { extname } from "@std/path/extname"; import { join } from "@std/path/join"; import { relative } from "@std/path/relative"; import { resolve } from "@std/path/resolve"; import { SEPARATOR_PATTERN } from "@std/path/constants"; import { contentType } from "@std/media-types/content-type"; import { eTag, ifNoneMatch } from "./etag.ts"; import { isRedirectStatus, STATUS_CODE, STATUS_TEXT, type StatusCode, } from "./status.ts"; import { ByteSliceStream } from "@std/streams/byte-slice-stream"; import { parseArgs } from "@std/cli/parse-args"; import denoConfig from "./deno.json" with { type: "json" }; import { format as formatBytes } from "@std/fmt/bytes"; import { getNetworkAddress } from "@std/net/get-network-address"; import { HEADER } from "./header.ts"; import { METHOD } from "./method.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 deploy const DENO_DEPLOYMENT_ID = ENV_PERM_STATUS === "granted" ? Deno.env.get("DENO_DEPLOYMENT_ID") : undefined; const HASHED_DENO_DEPLOYMENT_ID = DENO_DEPLOYMENT_ID ? eTag(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).padStart(3, "0"); let output = ""; mode .split("") .reverse() .slice(0, 3) .forEach((v) => { output = `${modeMap[+v]} ${output}`; }); output = `${isDir ? "d" : "-"} ${output}`; return output; } function createStandardResponse(status: StatusCode, init?: ResponseInit) { const statusText = STATUS_TEXT[status]; return new Response(statusText, { status, statusText, ...init }); } /** * 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=(?\d+)?-(?\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; } } } /** Options for {@linkcode serveFile}. */ export interface ServeFileOptions { /** * The algorithm to use for generating the ETag. * * @default {"SHA-256"} */ etagAlgorithm?: AlgorithmIdentifier; /** * An optional object returned by {@linkcode Deno.stat}. It is used for * optimization purposes. * * Defaults to the result of calling {@linkcode Deno.stat} with the provided * `filePath`. */ fileInfo?: Deno.FileInfo; } /** * Resolves a {@linkcode Response} with the requested file as the body. * * @example Usage * ```ts no-eval * import { serveFile } from "@std/http/file-server"; * * Deno.serve((req) => { * return serveFile(req, "README.md"); * }); * ``` * * @param req The server request context used to cleanup the file handle. * @param filePath Path of the file to serve. * @param options Additional options. * @returns A response for the request. */ export async function serveFile( req: Request, filePath: string, options?: ServeFileOptions, ): Promise { if (req.method !== METHOD.Get) { return createStandardResponse(STATUS_CODE.MethodNotAllowed); } let { etagAlgorithm: algorithm, fileInfo } = options ?? {}; try { fileInfo ??= await Deno.stat(filePath); } catch (error) { if (error instanceof Deno.errors.NotFound) { await req.body?.cancel(); return createStandardResponse(STATUS_CODE.NotFound); } else { throw error; } } if (fileInfo.isDirectory) { await req.body?.cancel(); return createStandardResponse(STATUS_CODE.NotFound); } const headers = createBaseHeaders(); // Set date header if access timestamp is available if (fileInfo.atime) { headers.set(HEADER.Date, fileInfo.atime.toUTCString()); } const etag = fileInfo.mtime ? await eTag(fileInfo, { algorithm }) : await HASHED_DENO_DEPLOYMENT_ID; // Set last modified header if last modification timestamp is available if (fileInfo.mtime) { headers.set(HEADER.LastModified, fileInfo.mtime.toUTCString()); } if (etag) { headers.set(HEADER.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(HEADER.IfNoneMatch); const ifModifiedSinceValue = req.headers.get(HEADER.IfModifiedSince); if ( (!ifNoneMatch(ifNoneMatchValue, etag)) || (ifNoneMatchValue === null && fileInfo.mtime && ifModifiedSinceValue && fileInfo.mtime.getTime() < new Date(ifModifiedSinceValue).getTime() + 1000) ) { const status = STATUS_CODE.NotModified; return new Response(null, { status, statusText: STATUS_TEXT[status], headers, }); } } // Set mime-type using the file extension in filePath const contentTypeValue = contentType(extname(filePath)); if (contentTypeValue) { headers.set(HEADER.ContentType, contentTypeValue); } const fileSize = fileInfo.size; const rangeValue = req.headers.get(HEADER.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(HEADER.ContentLength, `${fileSize}`); const file = await Deno.open(filePath); const status = STATUS_CODE.OK; return new Response(file.readable, { status, statusText: STATUS_TEXT[status], 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(HEADER.ContentRange, `bytes */${fileSize}`); return createStandardResponse( STATUS_CODE.RangeNotSatisfiable, { 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(HEADER.ContentRange, `bytes ${start}-${end}/${fileSize}`); // Set content length const contentLength = end - start + 1; headers.set(HEADER.ContentLength, `${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)); const status = STATUS_CODE.PartialContent; return new Response(sliced, { status, statusText: STATUS_TEXT[status], headers, }); } // Set content length headers.set(HEADER.ContentLength, `${fileSize}`); const file = await Deno.open(filePath); const status = STATUS_CODE.OK; return new Response(file.readable, { status, statusText: STATUS_TEXT[status], headers, }); } async function serveDirIndex( dirPath: string, options: { showDotfiles: boolean; target: string; urlRoot: string | undefined; quiet: boolean | undefined; }, ): Promise { const { showDotfiles } = options; const urlRoot = options.urlRoot ? "/" + options.urlRoot : ""; const dirUrl = `/${ relative(options.target, dirPath).replaceAll( new RegExp(SEPARATOR_PATTERN, "g"), "/", ) }`; const listEntryPromise: Promise[] = []; // if ".." makes sense if (dirUrl !== "/") { const prevPath = join(dirPath, ".."); const entryInfo = Deno.stat(prevPath).then((fileInfo): EntryInfo => ({ mode: modeToString(true, fileInfo.mode), size: "", name: "../", url: `${urlRoot}${posixJoin(dirUrl, "..")}`, })); listEntryPromise.push(entryInfo); } // Read fileInfo in parallel for await (const entry of Deno.readDir(dirPath)) { if (!showDotfiles && entry.name[0] === ".") { continue; } const filePath = join(dirPath, entry.name); const fileUrl = encodeURIComponent(posixJoin(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: `${urlRoot}${fileUrl}${entry.isDirectory ? "/" : ""}`, }; } catch (error) { // Note: Deno.stat for windows system files may be rejected with os error 32. if (!options.quiet) logError(error as Error); return { mode: "(unknown mode)", size: "", name: `${entry.name}${entry.isDirectory ? "/" : ""}`, url: `${urlRoot}${fileUrl}${entry.isDirectory ? "/" : ""}`, }; } })()); } const listEntry = await Promise.all(listEntryPromise); listEntry.sort((a, b) => // TODO(iuioiua): Add test to ensure list order is correct a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1 ); const formattedDirUrl = `${dirUrl.replace(/\/$/, "")}/`; const page = dirViewerTemplate(formattedDirUrl, listEntry); const headers = createBaseHeaders(); headers.set(HEADER.ContentType, "text/html; charset=UTF-8"); const status = STATUS_CODE.OK; return new Response(page, { status, statusText: STATUS_TEXT[status], headers, }); } function serveFallback(maybeError: unknown): Response { if (maybeError instanceof URIError) { return createStandardResponse(STATUS_CODE.BadRequest); } if (maybeError instanceof Deno.errors.NotFound) { return createStandardResponse(STATUS_CODE.NotFound); } return createStandardResponse(STATUS_CODE.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 [HEADER.AcceptRanges]: "bytes", }); } function dirViewerTemplate(dirname: string, entries: EntryInfo[]): string { const paths = dirname.split("/"); return ` Deno File Server

Index of home${ paths .map((path, index, array) => { if (path === "") return ""; const link = array.slice(0, index + 1).join("/"); return `${path}`; }) .join("/") }

${ entries .map( (entry) => ` `, ) .join("") }
Mode Size Name
${entry.mode} ${entry.size} ${entry.name}
`; } /** 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. */ 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 * {@linkcode https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin | Access-Control-Allow-Origin} * header. * * @default {false} */ enableCors?: boolean; /** Do not print request level logs. * * @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). * * @example Usage * ```ts no-eval * import { serveDir } from "@std/http/file-server"; * * Deno.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(); * }); * ``` * * @example Changing the URL root * * Requests to `/static/path/to/file` will be served from `./public/path/to/file`. * * ```ts no-eval * import { serveDir } from "@std/http/file-server"; * * Deno.serve((req) => serveDir(req, { * fsRoot: "public", * urlRoot: "static", * })); * ``` * * @param req The request to handle * @param opts Additional options. * @returns A response for the request. */ export async function serveDir( req: Request, opts: ServeDirOptions = {}, ): Promise { if (req.method !== METHOD.Get) { return createStandardResponse(STATUS_CODE.MethodNotAllowed); } let response: Response; try { response = await createServeDirResponse(req, opts); } catch (error) { if (!opts.quiet) logError(error as 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(HEADER.AccessControlAllowOrigin, "*"); response.headers.append( HEADER.AccessControlAllowHeaders, "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 = posixNormalize(decodedUrl); if (urlRoot && !normalizedPath.startsWith("/" + urlRoot)) { return createStandardResponse(STATUS_CODE.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 = 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 = 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, { urlRoot, showDotfiles, target, quiet }); } return createStandardResponse(STATUS_CODE.NotFound); } function logError(error: Error) { console.error(`%c${error.message}`, "color: red"); } function main() { const serverArgs = parseArgs(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: undefined, cert: "", key: "", }, alias: { p: "port", c: "cert", k: "key", h: "help", v: "verbose", V: "version", H: "header", }, }); const port = serverArgs.port ? Number(serverArgs.port) : undefined; 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 ${denoConfig.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 = resolve(wild[0] ?? ""); const handler = (req: Request): Promise => { return serveDir(req, { fsRoot: target, showDirListing: serverArgs["dir-listing"], showDotfiles: serverArgs.dotfiles, enableCors: serverArgs.cors, quiet: !serverArgs.verbose, headers, }); }; const useTls = !!(keyFile && certFile); function onListen({ port, hostname }: { port: number; hostname: string }) { let networkAddress: string | undefined = undefined; if ( Deno.permissions.querySync({ name: "sys", kind: "networkInterfaces" }) .state === "granted" ) { networkAddress = getNetworkAddress(); } const protocol = useTls ? "https" : "http"; let message = `Listening on:\n- Local: ${protocol}://${hostname}:${port}`; if (networkAddress && !DENO_DEPLOYMENT_ID) { message += `\n- Network: ${protocol}://${networkAddress}:${port}`; } console.log(message); } if (useTls) { Deno.serve({ port, hostname: host, onListen, cert: Deno.readTextFileSync(certFile), key: Deno.readTextFileSync(keyFile), }, handler); } else { Deno.serve({ port, hostname: host, onListen, }, handler); } } function printUsage() { console.log(`Deno File Server ${denoConfig.version} Serves a local directory in HTTP. INSTALL: deno install --allow-net --allow-read --allow-sys jsr:@std/http@${denoConfig.version}/file-server USAGE: file_server [path] [options] OPTIONS: -h, --help Prints help information -p, --port Set port (default is 8000) --cors Enable CORS via the "Access-Control-Allow-Origin" header --host Hostname (default is 0.0.0.0) -c, --cert TLS certificate file (enables TLS) -k, --key TLS key file (enables TLS) -H, --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(); }