From 8d4c5c18191307646f73d981c8c3a3ee15e00fe5 Mon Sep 17 00:00:00 2001 From: Mark Gibson Date: Thu, 11 Jul 2024 15:34:16 +0100 Subject: [PATCH] Introduce @http/fs and fileBody() Restructure for cross-runtime file server --- CHANGELOG.md | 10 +- deno.json | 7 +- import_map_local.json | 19 +++- packages/examples/docs/getting-started.md | 5 +- packages/examples/intercept_response.ts | 2 +- packages/examples/static_route.ts | 2 +- packages/fs/README.md | 28 ++++++ .../{route-deno => fs}/_testdata/%25A.txt | 0 .../{route-deno => fs}/_testdata/desktop.ini | 0 .../{route-deno => fs}/_testdata/hello.html | 0 .../_testdata/subdir-with-index/index.html | 0 .../_testdata/test_empty_file.txt | 0 .../_testdata/test_file.txt | 0 packages/fs/deno.json | 22 +++++ packages/fs/file_body.ts | 12 +++ packages/fs/file_body_bun.ts | 23 +++++ packages/fs/file_body_deno.ts | 25 +++++ packages/fs/file_desc.ts | 47 ++++++++++ packages/fs/file_not_found.ts | 6 ++ .../{route-deno => fs}/file_server.test.ts | 28 +++--- .../_serve_dir.ts => fs/serve_dir.ts} | 49 ++++++---- .../_serve_file.ts => fs/serve_file.ts} | 82 ++++++++-------- packages/fs/stat.ts | 24 +++++ packages/fs/types.ts | 93 +++++++++++++++++++ packages/host-deno-deploy/README.md | 20 ++++ packages/host-deno-deploy/deno.json | 1 + .../deno_deploy_etag.ts | 0 packages/interceptor/README.md | 4 +- packages/route-deno/README.md | 58 +----------- packages/route-deno/_fs.ts | 24 ----- packages/route-deno/deno.json | 5 +- packages/route-deno/static_route.ts | 47 +--------- packages/route-deno/types.ts | 39 -------- packages/route/README.md | 5 +- packages/route/deno.json | 1 + packages/route/static_route.ts | 49 ++++++++++ 36 files changed, 481 insertions(+), 256 deletions(-) create mode 100644 packages/fs/README.md rename packages/{route-deno => fs}/_testdata/%25A.txt (100%) rename packages/{route-deno => fs}/_testdata/desktop.ini (100%) rename packages/{route-deno => fs}/_testdata/hello.html (100%) rename packages/{route-deno => fs}/_testdata/subdir-with-index/index.html (100%) rename packages/{route-deno => fs}/_testdata/test_empty_file.txt (100%) rename packages/{route-deno => fs}/_testdata/test_file.txt (100%) create mode 100644 packages/fs/deno.json create mode 100644 packages/fs/file_body.ts create mode 100644 packages/fs/file_body_bun.ts create mode 100644 packages/fs/file_body_deno.ts create mode 100644 packages/fs/file_desc.ts create mode 100644 packages/fs/file_not_found.ts rename packages/{route-deno => fs}/file_server.test.ts (95%) rename packages/{route-deno/_serve_dir.ts => fs/serve_dir.ts} (76%) rename packages/{route-deno/_serve_file.ts => fs/serve_file.ts} (76%) create mode 100644 packages/fs/stat.ts create mode 100644 packages/fs/types.ts rename packages/{route-deno => host-deno-deploy}/deno_deploy_etag.ts (100%) delete mode 100644 packages/route-deno/_fs.ts delete mode 100644 packages/route-deno/types.ts create mode 100644 packages/route/static_route.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 746fb3c..8620898 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,12 +12,16 @@ This changelog will need to be split between individual packages ### Changed -- [@http/route-deno] use own `serveDir()`/`serveFile()` rather than - `@std/http/file-server`, preparing for cross-runtime support in a later - release +- [@http/route] introduced a cross-runtime `staticRoute()` +- [@http/route-deno] is now deprecated ### Added +- [@http/fs] new package for filesystem based functions +- [@http/fs] added `fileBody()` as a cross-runtime way to turn a file into a + Response body +- [@http/fs] added `serveDir()`/`serveFile()`, adapted from + `@std/http/file-server` - [@http/response] add responses required by `serveDir`/`serveFile` ## [0.19.0] diff --git a/deno.json b/deno.json index e61c6ae..8ad7d5c 100644 --- a/deno.json +++ b/deno.json @@ -24,10 +24,10 @@ ] }, "imports": { - "$test/generate/": "./packages/generate/_test/", "@http/assert": "jsr:@http/assert@^0.19.0", "@http/discovery": "jsr:@http/discovery@^0.19.0", "@http/examples": "jsr:@http/examples@^0.19.0", + "@http/fs": "jsr:@http/fs@^0.19.0", "@http/generate": "jsr:@http/generate@^0.19.0", "@http/host-deno-deploy": "jsr:@http/host-deno-deploy@^0.19.0", "@http/host-deno-local": "jsr:@http/host-deno-local@^0.19.0", @@ -48,12 +48,17 @@ "@std/streams": "jsr:@std/streams@^1.0.0-rc.2", "@std/testing": "jsr:@std/testing@^1.0.0-rc.3", "@std/url": "jsr:@std/url@^1.0.0-rc.2", + "@types/bun": "npm:@types/bun@^1.1.6", + "@types/node": "npm:@types/node@^20.14.10", + "$test/generate/": "./packages/generate/_test/", + "bun-types": "npm:bun-types@^1.1.18", "ts-poet": "npm:ts-poet@^6.9.0" }, "workspaces": [ "./packages/assert", "./packages/discovery", "./packages/examples", + "./packages/fs", "./packages/generate", "./packages/host-deno-deploy", "./packages/host-deno-local", diff --git a/import_map_local.json b/import_map_local.json index 3220bca..e533839 100644 --- a/import_map_local.json +++ b/import_map_local.json @@ -34,6 +34,15 @@ "@http/examples/static-route": "./packages/examples/static_route.ts", "@http/examples/verify-header": "./packages/examples/verify_header.ts", "@http/examples/when-pattern": "./packages/examples/when_pattern.ts", + "@http/fs/file-body": "./packages/fs/file_body.ts", + "@http/fs/file-body-bun": "./packages/fs/file_body_bun.ts", + "@http/fs/file-body-deno": "./packages/fs/file_body_deno.ts", + "@http/fs/file-desc": "./packages/fs/file_desc.ts", + "@http/fs/file-not-found": "./packages/fs/file_not_found.ts", + "@http/fs/serve-dir": "./packages/fs/serve_dir.ts", + "@http/fs/serve-file": "./packages/fs/serve_file.ts", + "@http/fs/stat": "./packages/fs/stat.ts", + "@http/fs/types": "./packages/fs/types.ts", "@http/generate/code-builder": "./packages/generate/code_builder.ts", "@http/generate/default-handler-generator": "./packages/generate/default_handler_generator.ts", "@http/generate/deno/write-module": "./packages/generate/deno/write_module.ts", @@ -42,6 +51,7 @@ "@http/generate/node/write-module": "./packages/generate/node/write_module.ts", "@http/generate/types": "./packages/generate/types.ts", "@http/generate/write-module": "./packages/generate/write_module.ts", + "@http/host-deno-deploy/deno-deploy-etag": "./packages/host-deno-deploy/deno_deploy_etag.ts", "@http/host-deno-deploy/init": "./packages/host-deno-deploy/init.ts", "@http/host-deno-deploy/types": "./packages/host-deno-deploy/types.ts", "@http/host-deno-local/init": "./packages/host-deno-local/init.ts", @@ -91,9 +101,7 @@ "@http/response/set-headers": "./packages/response/set_headers.ts", "@http/response/temporary-redirect": "./packages/response/temporary_redirect.ts", "@http/response/unauthorized": "./packages/response/unauthorized.ts", - "@http/route-deno/deno-deploy-etag": "./packages/route-deno/deno_deploy_etag.ts", "@http/route-deno/static-route": "./packages/route-deno/static_route.ts", - "@http/route-deno/types": "./packages/route-deno/types.ts", "@http/route/as-url-pattern": "./packages/route/as_url_pattern.ts", "@http/route/by-media-type": "./packages/route/by_media_type.ts", "@http/route/by-method": "./packages/route/by_method.ts", @@ -102,6 +110,7 @@ "@http/route/cascade": "./packages/route/cascade.ts", "@http/route/handle": "./packages/route/handle.ts", "@http/route/lazy": "./packages/route/lazy.ts", + "@http/route/static-route": "./packages/route/static_route.ts", "@http/route/types": "./packages/route/types.ts", "@http/route/with-fallback": "./packages/route/with_fallback.ts", "@std/assert": "jsr:@std/assert@^1.0.0", @@ -128,7 +137,13 @@ "@std/testing/": "jsr:/@std/testing@^1.0.0-rc.3/", "@std/url": "jsr:@std/url@^1.0.0-rc.2", "@std/url/": "jsr:/@std/url@^1.0.0-rc.2/", + "@types/bun": "npm:@types/bun@^1.1.6", + "@types/bun/": "npm:/@types/bun@^1.1.6/", + "@types/node": "npm:@types/node@^20.14.10", + "@types/node/": "npm:/@types/node@^20.14.10/", "$test/generate/": "./packages/generate/_test/", + "bun-types": "npm:bun-types@^1.1.18", + "bun-types/": "npm:/bun-types@^1.1.18/", "ts-poet": "npm:ts-poet@^6.9.0", "ts-poet/": "npm:/ts-poet@^6.9.0/" } diff --git a/packages/examples/docs/getting-started.md b/packages/examples/docs/getting-started.md index 8364f79..65fd8ed 100644 --- a/packages/examples/docs/getting-started.md +++ b/packages/examples/docs/getting-started.md @@ -170,17 +170,14 @@ And in this example, we'll add the ability to serve up static files. ```sh mkdir app/static -deno add @http/route-deno ``` -(`@http/route-deno` contains the Deno specific router function `staticRoute`) - Create `app/handler.ts`: ```ts import routes from "./routes.ts"; import { handle } from "@http/route/handle"; -import { staticRoute } from "@http/route-deno/static-route"; +import { staticRoute } from "@http/route/static-route"; export default handle([ routes, diff --git a/packages/examples/intercept_response.ts b/packages/examples/intercept_response.ts index 833c9d9..0233f25 100644 --- a/packages/examples/intercept_response.ts +++ b/packages/examples/intercept_response.ts @@ -27,7 +27,7 @@ * @module */ -import { staticRoute } from "@http/route-deno/static-route"; +import { staticRoute } from "@http/route/static-route"; import { withFallback } from "@http/route/with-fallback"; import { interceptResponse } from "@http/interceptor/intercept-response"; import { skip } from "@http/interceptor/skip"; diff --git a/packages/examples/static_route.ts b/packages/examples/static_route.ts index 347c41e..4e2c335 100644 --- a/packages/examples/static_route.ts +++ b/packages/examples/static_route.ts @@ -18,7 +18,7 @@ * @module */ -import { staticRoute } from "@http/route-deno/static-route"; +import { staticRoute } from "@http/route/static-route"; import { withFallback } from "@http/route/with-fallback"; import { port } from "@http/host-deno-local/port"; diff --git a/packages/fs/README.md b/packages/fs/README.md new file mode 100644 index 0000000..5565aac --- /dev/null +++ b/packages/fs/README.md @@ -0,0 +1,28 @@ +# Filesystem functions for HTTP servers + +Cross-runtime functions for interacting with the Filesystem from a HTTP server. + +This package doesn't aim to cover all possible filesystem operations or file +data, just enough to aid in sending files to/from a HTTP server. + +## `fileBody()` + +Is a cross-runtime function to construct the body for a Response from a file +given its path and optionally start/end offsets. The actual return value of this +is a `BodyInit` type, ie. something suitable for passing into a `Response`. The +actual object may vary depending on the runtime. + +## `serveDir()` & `serveFile()` + +These functions have been copied from `@std/http/file-server` and adapted to fit +better with other `@http` functions, and to provide the basis for `staticRoute`. + +They have some features stripped out that were present in +`@std/http/file-server`: + +- No directory listing renderer +- No built-in support for Deno Deploy (see `denoDeployEtag()`) +- No CORS support (can use `cors()` interceptor instead) +- No custom headers added to response (can use an interceptor and + `appendHeaders()` instead) +- No standalone server diff --git a/packages/route-deno/_testdata/%25A.txt b/packages/fs/_testdata/%25A.txt similarity index 100% rename from packages/route-deno/_testdata/%25A.txt rename to packages/fs/_testdata/%25A.txt diff --git a/packages/route-deno/_testdata/desktop.ini b/packages/fs/_testdata/desktop.ini similarity index 100% rename from packages/route-deno/_testdata/desktop.ini rename to packages/fs/_testdata/desktop.ini diff --git a/packages/route-deno/_testdata/hello.html b/packages/fs/_testdata/hello.html similarity index 100% rename from packages/route-deno/_testdata/hello.html rename to packages/fs/_testdata/hello.html diff --git a/packages/route-deno/_testdata/subdir-with-index/index.html b/packages/fs/_testdata/subdir-with-index/index.html similarity index 100% rename from packages/route-deno/_testdata/subdir-with-index/index.html rename to packages/fs/_testdata/subdir-with-index/index.html diff --git a/packages/route-deno/_testdata/test_empty_file.txt b/packages/fs/_testdata/test_empty_file.txt similarity index 100% rename from packages/route-deno/_testdata/test_empty_file.txt rename to packages/fs/_testdata/test_empty_file.txt diff --git a/packages/route-deno/_testdata/test_file.txt b/packages/fs/_testdata/test_file.txt similarity index 100% rename from packages/route-deno/_testdata/test_file.txt rename to packages/fs/_testdata/test_file.txt diff --git a/packages/fs/deno.json b/packages/fs/deno.json new file mode 100644 index 0000000..3600606 --- /dev/null +++ b/packages/fs/deno.json @@ -0,0 +1,22 @@ +{ + "name": "@http/fs", + "version": "0.19.0", + "exports": { + "./file-body": "./file_body.ts", + "./file-body-bun": "./file_body_bun.ts", + "./file-body-deno": "./file_body_deno.ts", + "./file-desc": "./file_desc.ts", + "./file-not-found": "./file_not_found.ts", + "./serve-dir": "./serve_dir.ts", + "./serve-file": "./serve_file.ts", + "./stat": "./stat.ts", + "./types": "./types.ts" + }, + "publish": { + "exclude": [ + "_test/**", + "_testdata/**", + "*.test.ts" + ] + } +} diff --git a/packages/fs/file_body.ts b/packages/fs/file_body.ts new file mode 100644 index 0000000..a3baa65 --- /dev/null +++ b/packages/fs/file_body.ts @@ -0,0 +1,12 @@ +import type { FileBodyFn } from "./types.ts"; + +/** + * Create a Response body for a given file + */ +export const fileBody: FileBodyFn = "Deno" in globalThis + ? (await import("./file_body_deno.ts")).default + : "Bun" in globalThis + ? (await import("./file_body_bun.ts")).default + : () => { + throw new Error("fileBody not supported on this runtime"); + }; diff --git a/packages/fs/file_body_bun.ts b/packages/fs/file_body_bun.ts new file mode 100644 index 0000000..9d4db23 --- /dev/null +++ b/packages/fs/file_body_bun.ts @@ -0,0 +1,23 @@ +/// + +import type { FileBodyOptions } from "./types.ts"; + +/** + * Create a Response body for a given file + */ +export function fileBodyBun( + filePath: string, + opts?: FileBodyOptions, +): Promise { + const { start = 0, end } = opts ?? {}; + + let file = Bun.file(filePath); + + if (start > 0 || end !== undefined) { + file = file.slice(start, end); + } + + return Promise.resolve(file.stream() as ReadableStream); +} + +export default fileBodyBun; diff --git a/packages/fs/file_body_deno.ts b/packages/fs/file_body_deno.ts new file mode 100644 index 0000000..69055ac --- /dev/null +++ b/packages/fs/file_body_deno.ts @@ -0,0 +1,25 @@ +import { ByteSliceStream } from "@std/streams/byte-slice-stream"; +import type { FileBodyOptions } from "./types.ts"; + +/** + * Create a Response body for a given file + */ +export async function fileBodyDeno( + filePath: string, + opts?: FileBodyOptions, +): Promise { + const { start = 0, end } = opts ?? {}; + const file = await Deno.open(filePath); + + if (start > 0) { + await file.seek(start, Deno.SeekMode.Start); + } + + if (end !== undefined) { + return file.readable.pipeThrough(new ByteSliceStream(0, end - start)); + } + + return file.readable; +} + +export default fileBodyDeno; diff --git a/packages/fs/file_desc.ts b/packages/fs/file_desc.ts new file mode 100644 index 0000000..f4d862a --- /dev/null +++ b/packages/fs/file_desc.ts @@ -0,0 +1,47 @@ +import type { FileDesc } from "./types.ts"; + +export type { FileDesc }; + +/** + * Does the given file descriptor represent a directory? + * + * @example + * ```ts + * import { stat } from "jsr:@http/fs/stat"; + * import { isDirectory } from "jsr:@http/fs/file-desc"; + * + * const fileInfo = await stat("./foo"); + * + * if (isDirectory(fileInfo)) { + * ... + * } + * ``` + * + * @param entry may be a Deno `FileInfo`, or Node `Stats` object + */ +export function isDirectory(entry: FileDesc): boolean { + return typeof entry.isDirectory === "function" + ? entry.isDirectory() + : !!entry.isDirectory; +} + +/** + * Does the given file descriptor represent a regular file? + * + * @example + * ```ts + * import { stat } from "jsr:@http/fs/stat"; + * import { isFile } from "jsr:@http/fs/file-desc"; + * + * const fileInfo = await stat("./foo"); + * + * if (isFile(fileInfo)) { + * ... + * } + * ``` + * + * @param entry may be a Deno `FileInfo`, or Node `Stats` object + */ +export function isFile(entry: FileDesc): boolean { + return typeof entry.isFile === "function" ? entry.isFile() : !!entry.isFile; +} diff --git a/packages/fs/file_not_found.ts b/packages/fs/file_not_found.ts new file mode 100644 index 0000000..d1f35ea --- /dev/null +++ b/packages/fs/file_not_found.ts @@ -0,0 +1,6 @@ +/** + * Check whether an error is a file not found + */ +export function fileNotFound(error: unknown): boolean { + return error instanceof Error && "code" in error && error.code === "ENOENT"; +} diff --git a/packages/route-deno/file_server.test.ts b/packages/fs/file_server.test.ts similarity index 95% rename from packages/route-deno/file_server.test.ts rename to packages/fs/file_server.test.ts index 9404d79..908d97d 100644 --- a/packages/route-deno/file_server.test.ts +++ b/packages/fs/file_server.test.ts @@ -8,8 +8,8 @@ import { assertEquals, assertStringIncludes, } from "@std/assert"; -import { serveDir, type ServeDirOptions } from "./_serve_dir.ts"; -import { serveFile } from "./_serve_file.ts"; +import { serveDir, type ServeDirOptions } from "./serve_dir.ts"; +import { serveFile } from "./serve_file.ts"; import { calculate as eTag } from "@std/http/etag"; import { dirname, fromFileUrl, join, resolve } from "@std/path"; import { MINUTE } from "@std/datetime/constants"; @@ -473,7 +473,7 @@ Deno.test("serveFile() only uses if-none-match header if if-non-match and if-mod Deno.test("serveDir() without options serves files in current directory", async () => { const req = new Request( - "http://localhost/packages/route-deno/_testdata/hello.html", + "http://localhost/packages/fs/_testdata/hello.html", ); const res = await serveDir(req); @@ -486,7 +486,7 @@ Deno.test("serveDir() with fsRoot and urlRoot option serves files in given direc "http://localhost/my-static-root/_testdata/hello.html", ); const res = await serveDir(req, { - fsRoot: "packages/route-deno", + fsRoot: "packages/fs", urlRoot: "my-static-root", }); @@ -495,8 +495,7 @@ Deno.test("serveDir() with fsRoot and urlRoot option serves files in given direc }); Deno.test("serveDir() serves index.html when showIndex is true", async () => { - const url = - "http://localhost/packages/route-deno/_testdata/subdir-with-index/"; + const url = "http://localhost/packages/fs/_testdata/subdir-with-index/"; const expectedText = "This is subdir-with-index/index.html"; { const res = await serveDir(new Request(url), { showIndex: true }); @@ -514,7 +513,7 @@ Deno.test("serveDir() serves index.html when showIndex is true", async () => { Deno.test("serveDir() doesn't serve index.html when showIndex is false", async () => { const req = new Request( - "http://localhost/packages/route-deno/_testdata/subdir-with-index/", + "http://localhost/packages/fs/_testdata/subdir-with-index/", ); const res = await serveDir(req, { showIndex: false }); @@ -524,39 +523,36 @@ Deno.test("serveDir() doesn't serve index.html when showIndex is false", async ( Deno.test( "serveDir() redirects a directory URL not ending with a slash if it has an index", async () => { - const url = - "http://localhost/packages/route-deno/_testdata/subdir-with-index"; + const url = "http://localhost/packages/fs/_testdata/subdir-with-index"; const res = await serveDir(new Request(url), { showIndex: true }); assertEquals(res.status, 301); assertEquals( res.headers.get("Location"), - "http://localhost/packages/route-deno/_testdata/subdir-with-index/", + "http://localhost/packages/fs/_testdata/subdir-with-index/", ); }, ); Deno.test("serveDir() redirects a directory URL not ending with a slash correctly even with a query string", async () => { - const url = - "http://localhost/packages/route-deno/_testdata/subdir-with-index?test"; + const url = "http://localhost/packages/fs/_testdata/subdir-with-index?test"; const res = await serveDir(new Request(url), { showIndex: true }); assertEquals(res.status, 301); assertEquals( res.headers.get("Location"), - "http://localhost/packages/route-deno/_testdata/subdir-with-index/?test", + "http://localhost/packages/fs/_testdata/subdir-with-index/?test", ); }); Deno.test("serveDir() redirects a file URL ending with a slash correctly even with a query string", async () => { - const url = - "http://localhost/packages/route-deno/_testdata/test_file.txt/?test"; + const url = "http://localhost/packages/fs/_testdata/test_file.txt/?test"; const res = await serveDir(new Request(url), { showIndex: true }); assertEquals(res.status, 301); assertEquals( res.headers.get("Location"), - "http://localhost/packages/route-deno/_testdata/test_file.txt?test", + "http://localhost/packages/fs/_testdata/test_file.txt?test", ); }); diff --git a/packages/route-deno/_serve_dir.ts b/packages/fs/serve_dir.ts similarity index 76% rename from packages/route-deno/_serve_dir.ts rename to packages/fs/serve_dir.ts index ca2ce91..2cba91c 100644 --- a/packages/route-deno/_serve_dir.ts +++ b/packages/fs/serve_dir.ts @@ -7,9 +7,11 @@ import { join } from "@std/path/join"; import { notFound } from "@http/response/not-found"; import { badRequest } from "@http/response/bad-request"; import { movedPermanently } from "@http/response/moved-permanently"; -import { serveFile } from "./_serve_file.ts"; -import { stat } from "./_fs.ts"; -import type { ServeDirOptions } from "./types.ts"; +import { serveFile } from "./serve_file.ts"; +import { stat } from "./stat.ts"; +import type { FileStats, ServeDirOptions } from "./types.ts"; +import { isDirectory, isFile } from "./file_desc.ts"; +import { fileNotFound } from "./file_not_found.ts"; export type { ServeDirOptions }; @@ -18,7 +20,7 @@ export type { ServeDirOptions }; * * @example Usage * ```ts no-eval - * import { serveDir } from "@http/route-deno/serve-dir"; + * import { serveDir } from "@http/fs/serve-dir"; * * Deno.serve((req) => { * const pathname = new URL(req.url).pathname; @@ -37,7 +39,7 @@ export type { ServeDirOptions }; * Requests to `/static/path/to/file` will be served from `./public/path/to/file`. * * ```ts no-eval - * import { serveDir } from "@http/route-deno/serve-dir"; + * import { serveDir } from "@http/fs/serve-dir"; * * Deno.serve((req) => serveDir(req, { * fsRoot: "public", @@ -87,19 +89,24 @@ export async function serveDir( } const fsPath = join(target, normalizedPath); - const fileInfo = await stat(fsPath); - if (!fileInfo) { - return notFound(); + let fileInfo: FileStats; + try { + fileInfo = await stat(fsPath); + } catch (error) { + if (fileNotFound(error)) { + return notFound(); + } + throw error; } // For files, remove the trailing slash from the path. - if (fileInfo.isFile && url.pathname.endsWith("/")) { + if (isFile(fileInfo) && url.pathname.endsWith("/")) { url.pathname = url.pathname.slice(0, -1); return movedPermanently(url); } // For directories, the path must have a trailing slash. - if (fileInfo.isDirectory && !url.pathname.endsWith("/")) { + if (isDirectory(fileInfo) && !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 @@ -110,7 +117,7 @@ export async function serveDir( } // if target is file, serve file. - if (!fileInfo.isDirectory) { + if (!isDirectory(fileInfo)) { return serveFile(req, fsPath, { etagAlgorithm, fileInfo, @@ -122,13 +129,19 @@ export async function serveDir( if (showIndex) { // serve index.html const indexPath = join(fsPath, "index.html"); - const indexFileInfo = await stat(indexPath); - if (indexFileInfo?.isFile) { - return serveFile(req, indexPath, { - etagAlgorithm, - fileInfo: indexFileInfo, - etagDefault, - }); + try { + const indexFileInfo = await stat(indexPath); + if (isFile(indexFileInfo)) { + return serveFile(req, indexPath, { + etagAlgorithm, + fileInfo: indexFileInfo, + etagDefault, + }); + } + } catch (error) { + if (!fileNotFound(error)) { + throw error; + } } } diff --git a/packages/route-deno/_serve_file.ts b/packages/fs/serve_file.ts similarity index 76% rename from packages/route-deno/_serve_file.ts rename to packages/fs/serve_file.ts index ce2f84f..b7ea388 100644 --- a/packages/route-deno/_serve_file.ts +++ b/packages/fs/serve_file.ts @@ -6,13 +6,16 @@ import { extname } from "@std/path/extname"; import { contentType } from "@std/media-types/content-type"; import { calculate as eTag, ifNoneMatch } from "@std/http/etag"; -import { ByteSliceStream } from "@std/streams/byte-slice-stream"; import { notFound } from "@http/response/not-found"; import { ok } from "@http/response/ok"; import { notModified } from "@http/response/not-modified"; import { rangeNotSatisfiable } from "@http/response/range-not-satisfiable"; import { partialContent } from "@http/response/partial-content"; -import { openFileStream, stat } from "./_fs.ts"; +import { fileBody } from "./file_body.ts"; +import { stat } from "./stat.ts"; +import type { FileStats } from "./types.ts"; +import { isDirectory } from "./file_desc.ts"; +import { fileNotFound } from "./file_not_found.ts"; /** * parse range header. @@ -65,8 +68,11 @@ export interface ServeFileOptions { /** A default ETag value to fallback on if the file has no mtime */ etagDefault?: string | Promise; - /** An optional FileInfo object returned by Deno.stat. It is used for optimization purposes. */ - fileInfo?: Deno.FileInfo; + /** + * An optional file stats object returned by `Deno.stat` or Node's `stat`. + * It is used for optimization purposes. + */ + fileInfo?: FileStats; } /** @@ -74,7 +80,7 @@ export interface ServeFileOptions { * * @example Usage * ```ts no-eval - * import { serveFile } from "@http/route-deno/serve-file"; + * import { serveFile } from "@http/fs/serve-file"; * * Deno.serve((req) => { * return serveFile(req, "README.md"); @@ -90,13 +96,19 @@ export async function serveFile( filePath: string, { etagAlgorithm: algorithm, fileInfo, etagDefault }: ServeFileOptions = {}, ): Promise { - fileInfo ??= await stat(filePath); + try { + fileInfo ??= await stat(filePath); + } catch (error: unknown) { + if (!fileNotFound(error)) { + throw error; + } + } if (!fileInfo) { return notFound(); } - if (fileInfo.isDirectory) { + if (isDirectory(fileInfo)) { return notFound(); } @@ -157,45 +169,41 @@ export async function serveFile( 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}`); + if (parsed) { + // 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 ok(await openFileStream(filePath), headers); - } + return rangeNotSatisfiable(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 rangeNotSatisfiable(headers); - } + // clamps the range header value + const start = Math.max(0, parsed.start); + const end = Math.min(parsed.end, fileSize - 1); - // 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 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}`); - // Set content length - const contentLength = end - start + 1; - headers.set("content-length", `${contentLength}`); + const body = await fileBody(filePath, { start, end }); - // Return 206 Partial Content - const sliced = (await openFileStream(filePath, start)) - .pipeThrough(new ByteSliceStream(0, contentLength - 1)); - return partialContent(sliced, headers); + // Return 206 Partial Content + return body ? partialContent(body, headers) : notFound(); + } } // Set content length headers.set("content-length", `${fileSize}`); - return ok(await openFileStream(filePath), headers); + const body = await fileBody(filePath); + + return body ? ok(body, headers) : notFound(); } diff --git a/packages/fs/stat.ts b/packages/fs/stat.ts new file mode 100644 index 0000000..f3a58bb --- /dev/null +++ b/packages/fs/stat.ts @@ -0,0 +1,24 @@ +import type { StatFn } from "./types.ts"; + +/** + * An appropriate file `stat` function for the runtime. + * + * @example + * ```ts + * import { stat } from "jsr:@http/fs/stat"; + * import { isDirectory } from "jsr:@http/fs/file-desc"; + * + * const fileInfo = await stat("./foo"); + * + * if (isDirectory(fileInfo)) { + * ... + * } + * ``` + * + * @param filePath the path to a file + * @returns a Deno `FileInfo` or Node `Stats` object + */ +export const stat: StatFn = + "Deno" in globalThis && typeof Deno.stat === "function" + ? Deno.stat + : (await import("node:fs/promises")).stat; diff --git a/packages/fs/types.ts b/packages/fs/types.ts new file mode 100644 index 0000000..efa8279 --- /dev/null +++ b/packages/fs/types.ts @@ -0,0 +1,93 @@ +/** + * A result that may be `await`ed. + */ +export type Awaitable = T | Promise; + +/** + * Options for a {@linkcode FileBodyFn} function, eg. {@linkcode fileBody} + */ +export interface FileBodyOptions { + /** + * Offset from the start of the file to start body from + */ + start?: number; + + /** + * Offset from the start of the file at which to end the body + */ + end?: number; +} + +/** + * Signature of a function that returns a Response body representing a file + * + * @returns the body for a a Response or undefined if not found or not readable + */ +export type FileBodyFn = ( + filePath: string, + opts?: FileBodyOptions, +) => Promise; + +/** + * Signature of the cross-runtime `stat` function + */ +export type StatFn = (path: string) => Promise; + +/** + * File stats interface compatible with Deno and Node + * + * This is the minimal amount of info we need for various http related functions + */ +export interface FileStats extends FileDesc { + mtime: Date | null; + + atime: Date | null; + + size: number; +} + +/** + * File descriptor interface compatible with Deno and Node + */ +export interface FileDesc { + /** + * Whether the entry is a directory + */ + isDirectory?: boolean | (() => boolean); + /** + * Whether the entry is a file + */ + isFile?: boolean | (() => boolean); +} + +/** + * Options for {@linkcode serveDir}. + */ +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; + + /** Serves `index.html` as the index file of the directory. + * + * @default {true} + */ + showIndex?: boolean; + + /** The algorithm to use for generating the ETag. + * + * @default {"SHA-256"} + */ + etagAlgorithm?: AlgorithmIdentifier; + + /** A default ETag value to fallback on if the file has no mtime */ + etagDefault?: string | Promise; +} diff --git a/packages/host-deno-deploy/README.md b/packages/host-deno-deploy/README.md index a6bbf58..eb7cd02 100644 --- a/packages/host-deno-deploy/README.md +++ b/packages/host-deno-deploy/README.md @@ -18,3 +18,23 @@ It's a pretty simple function that provides some out of the box conveniences: - Provides a fallback response (404 Not Found), if the handler 'skips' (ie. returns `null`), as is the convention in `@http` routing functions (eg. `cascade`). + +## `denoDeployEtag()` + +This can be used to generate a constant ETag based on the `DENO_DEPLOYMENT_ID` +if set, and passed into `etagDefault` of the file server functions to restore +the original behaviour of the `@std/http/file-server`. + +```ts +import { staticRoute } from "@http/route/static-route"; +import { denoDeployEtag } from "@http/host-deno-deploy/deno-deploy-etag"; +import { withFallback } from "@http/route/with-fallback"; + +Deno.serve( + withFallback( + staticRoute("/", import.meta.resolve("./public"), { + etagDefault: denoDeployEtag(), + }), + ), +); +``` diff --git a/packages/host-deno-deploy/deno.json b/packages/host-deno-deploy/deno.json index b35d9df..572c828 100644 --- a/packages/host-deno-deploy/deno.json +++ b/packages/host-deno-deploy/deno.json @@ -2,6 +2,7 @@ "name": "@http/host-deno-deploy", "version": "0.19.0", "exports": { + "./deno-deploy-etag": "./deno_deploy_etag.ts", "./init": "./init.ts", "./types": "./types.ts" }, diff --git a/packages/route-deno/deno_deploy_etag.ts b/packages/host-deno-deploy/deno_deploy_etag.ts similarity index 100% rename from packages/route-deno/deno_deploy_etag.ts rename to packages/host-deno-deploy/deno_deploy_etag.ts diff --git a/packages/interceptor/README.md b/packages/interceptor/README.md index ccc5782..7402c0b 100644 --- a/packages/interceptor/README.md +++ b/packages/interceptor/README.md @@ -205,8 +205,8 @@ Use of just Response Interceptors is the most common pattern, and so there is a shortcut to `intercept()` for just those. This example looks for static files first using the -[staticRoute](https://jsr.io/@http/route-deno/doc/static-route/~/staticRoute), -which results in a 404 response if the file is not found. So we use the +[staticRoute](https://jsr.io/@http/route/doc/static-route/~/staticRoute), which +results in a 404 response if the file is not found. So we use the [skip](https://jsr.io/@http/interceptor/doc/skip/~/skip) interceptor to convert any 404 response to a `null` (unhandled) response, the request can then be delegated to later handlers supplied to the diff --git a/packages/route-deno/README.md b/packages/route-deno/README.md index c27540d..196c920 100644 --- a/packages/route-deno/README.md +++ b/packages/route-deno/README.md @@ -1,58 +1,6 @@ # HTTP Routing functions (Deno specific) -See [@http/route](https://jsr.io/@http/route) for platform agnostic functions. +This package is now **OBSOLETE**. -This package provides static file routing for Deno that works neatly with other -`@http` functions. It's based upon the -[Standard library file server](https://jsr.io/@std/http/doc/file-server/~), but -no longer uses it directly. - -## Example - -```ts -import { staticRoute } from "@http/route-deno/static-route"; -import { withFallback } from "@http/route/with-fallback"; - -Deno.serve( - withFallback( - staticRoute("/", import.meta.resolve("./public")), - ), -); -``` - -See [full example](https://jsr.io/@http/examples/doc/static-route/~). - -## `serveDir` & `serveFile` - -These functions have been copied from `@std/http/file-server` and adapted to fit -better with other `@http` functions, and to provide the basis for `staticRoute`. - -They have some features stripped out that were present in -`@std/http/file-server`: - -- No directory listing renderer -- No built-in support for Deno Deploy (see `denoDeployEtag()`) -- No CORS support (can use `cors()` interceptor instead) -- No custom headers added to response (can use an interceptor and - `appendHeaders()` instead) -- No standalone server - -## `denoDeployEtag()` - -This function can be used to generate a constant ETag based on the -`DENO_DEPLOYMENT_ID` if set, and passed into `etagDefault` of the file server -functions to restore the original behaviour of the `@std/http/file-server`. - -```ts -import { staticRoute } from "@http/route-deno/static-route"; -import { denoDeployEtag } from "@http/route-deno/deno-deploy-etag"; -import { withFallback } from "@http/route/with-fallback"; - -Deno.serve( - withFallback( - staticRoute("/", import.meta.resolve("./public"), { - etagDefault: denoDeployEtag(), - }), - ), -); -``` +A cross-runtime `staticRoute()` function is now part of +[@http/route](https://jsr.io/@http/route). diff --git a/packages/route-deno/_fs.ts b/packages/route-deno/_fs.ts deleted file mode 100644 index 0dda804..0000000 --- a/packages/route-deno/_fs.ts +++ /dev/null @@ -1,24 +0,0 @@ -export async function stat( - filePath: string, -): Promise { - try { - return await Deno.stat(filePath); - } catch (error) { - if (error instanceof Deno.errors.NotFound) { - return undefined; - } else { - throw error; - } - } -} - -export async function openFileStream( - filePath: string, - start = 0, -): Promise> { - const file = await Deno.open(filePath); - if (start > 0) { - await file.seek(start, Deno.SeekMode.Start); - } - return file.readable; -} diff --git a/packages/route-deno/deno.json b/packages/route-deno/deno.json index d1bb18a..e040d51 100644 --- a/packages/route-deno/deno.json +++ b/packages/route-deno/deno.json @@ -2,14 +2,11 @@ "name": "@http/route-deno", "version": "0.19.0", "exports": { - "./deno-deploy-etag": "./deno_deploy_etag.ts", - "./static-route": "./static_route.ts", - "./types": "./types.ts" + "./static-route": "./static_route.ts" }, "publish": { "exclude": [ "_test/**", - "_testdata/**", "*.test.ts" ] } diff --git a/packages/route-deno/static_route.ts b/packages/route-deno/static_route.ts index 471c5f8..926b729 100644 --- a/packages/route-deno/static_route.ts +++ b/packages/route-deno/static_route.ts @@ -1,46 +1 @@ -import { byPattern } from "@http/route/by-pattern"; -import { byMethod } from "@http/route/by-method"; -import { serveDir } from "./_serve_dir.ts"; -import { fromFileUrl } from "@std/path/from-file-url"; -import type { Awaitable, StaticRouteOptions } from "./types.ts"; - -export type { StaticRouteOptions }; - -/** - * Create a Request handler that serves static files under a matched URL pattern. - * - * @example - * ```ts - * Deno.serve( - * withFallback( - * staticRoute("/", import.meta.resolve("./public")), - * ) - * ); - * ``` - * - * @param pattern the URL pattern to match - * @param fileRootUrl the root from where the files are served (this should be a file:// URL) - * @returns a Request handler that always returns a Response - */ -export function staticRoute( - pattern: string, - fileRootUrl: string, - options?: StaticRouteOptions, -): (request: Request) => Awaitable { - const fsRoot = fromFileUrl(fileRootUrl); - - pattern = pattern.replace(/\/$/, ""); - - return byPattern( - [`${pattern}/`, `${pattern}/:path+`], - byMethod({ - GET(req, info) { - const urlRoot = info.pathname.input.slice( - 1, - -(info.pathname.groups.path?.length ?? 0), - ); - return serveDir(req, { ...options, fsRoot, urlRoot }); - }, - }), - ); -} +export { staticRoute } from "@http/route/static-route"; diff --git a/packages/route-deno/types.ts b/packages/route-deno/types.ts deleted file mode 100644 index dbddc03..0000000 --- a/packages/route-deno/types.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * A result that may be `await`ed. - */ -export type Awaitable = T | Promise; - -/** 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; - - /** Serves `index.html` as the index file of the directory. - * - * @default {true} - */ - showIndex?: boolean; - - /** The algorithm to use for generating the ETag. - * - * @default {"SHA-256"} - */ - etagAlgorithm?: AlgorithmIdentifier; - - /** A default ETag value to fallback on if the file has no mtime */ - etagDefault?: string | Promise; -} - -/** - * Options for creating a static route handler. - */ -export type StaticRouteOptions = Omit; diff --git a/packages/route/README.md b/packages/route/README.md index 57ef5d0..df823e0 100644 --- a/packages/route/README.md +++ b/packages/route/README.md @@ -10,9 +10,8 @@ standard [Request] => [Response] handler function, aka These functions should be usable on any platform that supports the web standards: Deno, Bun, Cloudflare Workers, browsers, ServiceWorker. -There is also a platform specific -[@http/route-deno](https://jsr.io/@http/route-deno) package that provides static -routing functions. +NOTE: A cross-runtime `staticRoute()` is now part of this package, so the +`@http/route-deno` package is now obsolete. ## An Example diff --git a/packages/route/deno.json b/packages/route/deno.json index 32f8729..2378e99 100644 --- a/packages/route/deno.json +++ b/packages/route/deno.json @@ -10,6 +10,7 @@ "./cascade": "./cascade.ts", "./handle": "./handle.ts", "./lazy": "./lazy.ts", + "./static-route": "./static_route.ts", "./types": "./types.ts", "./with-fallback": "./with_fallback.ts" }, diff --git a/packages/route/static_route.ts b/packages/route/static_route.ts new file mode 100644 index 0000000..84a8b0d --- /dev/null +++ b/packages/route/static_route.ts @@ -0,0 +1,49 @@ +import { byPattern } from "@http/route/by-pattern"; +import { byMethod } from "@http/route/by-method"; +import { serveDir, type ServeDirOptions } from "@http/fs/serve-dir"; +import { fromFileUrl } from "@std/path/from-file-url"; +import type { Awaitable } from "./types.ts"; + +/** + * Options for creating a static route handler. + */ +export type StaticRouteOptions = Omit; + +/** + * Create a Request handler that serves static files under a matched URL pattern. + * + * @example + * ```ts + * Deno.serve( + * withFallback( + * staticRoute("/", import.meta.resolve("./public")), + * ) + * ); + * ``` + * + * @param pattern the URL pattern to match + * @param fileRootUrl the root from where the files are served (this should be a file:// URL) + * @returns a Request handler that always returns a Response + */ +export function staticRoute( + pattern: string, + fileRootUrl: string, + options?: StaticRouteOptions, +): (request: Request) => Awaitable { + const fsRoot = fromFileUrl(fileRootUrl); + + pattern = pattern.replace(/\/$/, ""); + + return byPattern( + [`${pattern}/`, `${pattern}/:path+`], + byMethod({ + GET(req, info) { + const urlRoot = info.pathname.input.slice( + 1, + -(info.pathname.groups.path?.length ?? 0), + ); + return serveDir(req, { ...options, fsRoot, urlRoot }); + }, + }), + ); +}