From ebf1c331b0c2ed5b242fc418bfb56cca7029f506 Mon Sep 17 00:00:00 2001 From: Rasmus Schultz Date: Sun, 4 Jan 2026 13:41:54 +0100 Subject: [PATCH] feat(serve-static): initial check-in --- packages/serve-static/.gitignore | 2 + packages/serve-static/package.json | 40 +++++ packages/serve-static/readme.md | 19 +++ packages/serve-static/src/index.ts | 153 ++++++++++++++++++ .../serve-static/tests/resources/index.html | 1 + .../serve-static/tests/serve-static.test.ts | 23 +++ packages/serve-static/tsconfig.json | 13 ++ packages/serve-static/tsdown.config.ts | 9 ++ packages/serve-static/vitest.config.ts | 7 + 9 files changed, 267 insertions(+) create mode 100644 packages/serve-static/.gitignore create mode 100644 packages/serve-static/package.json create mode 100644 packages/serve-static/readme.md create mode 100644 packages/serve-static/src/index.ts create mode 100644 packages/serve-static/tests/resources/index.html create mode 100644 packages/serve-static/tests/serve-static.test.ts create mode 100644 packages/serve-static/tsconfig.json create mode 100644 packages/serve-static/tsdown.config.ts create mode 100644 packages/serve-static/vitest.config.ts diff --git a/packages/serve-static/.gitignore b/packages/serve-static/.gitignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/packages/serve-static/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/packages/serve-static/package.json b/packages/serve-static/package.json new file mode 100644 index 0000000..83bf154 --- /dev/null +++ b/packages/serve-static/package.json @@ -0,0 +1,40 @@ +{ + "name": "@kitojs/serve-static", + "version": "0.1.0", + "description": "Static file-server middleware for the Kito framework", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "readme.md", + "package.json" + ], + "scripts": { + "build": "tsdown", + "dev": "tsdown --watch", + "test": "vitest", + "ex:run": "pnpm build && tsx" + }, + "keywords": [ + "kito", + "plugin" + ], + "author": "Rasmus Schultz ", + "license": "MIT", + "dependencies": { + "kitojs": "latest" + }, + "devDependencies": { + "tsdown": "latest", + "typescript": "latest", + "tsx": "latest", + "vitest": "latest" + } +} diff --git a/packages/serve-static/readme.md b/packages/serve-static/readme.md new file mode 100644 index 0000000..55d4bf0 --- /dev/null +++ b/packages/serve-static/readme.md @@ -0,0 +1,19 @@ +# @kitojs/serve-static + +Static file-server middleware for the Kito framework + +## Installation + +```bash +pnpm add @kitojs/serve-static +``` + +## Usage + +TODO add docs + +```ts +import { serveStatic } from "@kitojs/serve-static"; + +app.use(serveStatic()); +``` diff --git a/packages/serve-static/src/index.ts b/packages/serve-static/src/index.ts new file mode 100644 index 0000000..aee8743 --- /dev/null +++ b/packages/serve-static/src/index.ts @@ -0,0 +1,153 @@ +import type { Stats } from "node:fs"; +import fs from "node:fs"; +import path from "node:path"; +import { URL } from "node:url"; + +import { + middleware, + type KitoContext, + type KitoResponse, + type SendFileOptions, +} from "kitojs"; + +export interface ServeStaticOptions { + /** + * Root directory path from which static files will be served. + */ + root: string; + + /** + * Optional list of file extension fallbacks. + * + * If specified, when a requested file is not found, this middleware + * will try these file extensions, in order. + * + * Example: `['html', 'htm']` + */ + extensions?: string[] | boolean; + + /** + * If `true` (the default) this middleware will delegate unhandled + * requests to the next middleware. + * + * If `false`, this middleware will abort the middleware stack and + * return a 404 response. + */ + fallthrough?: boolean; + + /** + * If `true` (the default) this middleware will serve `index.html` + * when a directory is requested. + * + * Alternatively, you may specify an array of index filenames, for + * example `["index.html", "index.htm"]` and these will be tried + * in the order they're provided. + */ + index?: string[] | boolean; + + /** + * If `true` (the default) when a directory is requested *without* + * a trailing slash, this middleware will redirect to the same + * path *with* a trailing slash. + * + * This works better in browsers when using relative paths. + * + * Redirecting to a canonical URL avoids serving the same resource + * under two different URLs. + */ + redirect?: boolean; + + /** + * Optional function to set custom response headers. + */ + setHeaders?: ( + res: KitoResponse, + path: string, + stat: Stats, + ) => void | Promise; +} + +export const serveStatic = ( + options: ServeStaticOptions, + sendFileOptions?: SendFileOptions, +) => + middleware(async (ctx, next) => { + const { method } = ctx.req; + + const { root, fallthrough = true, redirect = true } = options; + + const indexFiles = + typeof options.index === "boolean" && options.index === true + ? ["index.html"] + : options.index || []; + + const extensions = + typeof options.extensions === "boolean" && options.extensions === true + ? ["html", "htm"] + : options.extensions || []; + + if (method !== "GET" && method !== "HEAD") { + if (fallthrough) { + return next(); + } + + return ctx.res + .status(405) + .header("Allow", "GET, HEAD") + .send("Method Not Allowed"); + } + + const isRoot = ctx.req.url === "/"; + + const hasTrailingSlash = !isRoot && ctx.req.url.endsWith("/"); + + const baseURL = `http${ctx.req.secure ? "s" : ""}://${ctx.req.hostname}/`; + + const url = new URL(ctx.req.url.replace(/\/$/, ""), baseURL); + + const pathname = decodeURIComponent(url.pathname); + + // biome-ignore lint/suspicious/noControlCharactersInRegex: required for input sanitization + if (/(^|\/)\.+(\/|$)|\\|[\x00-\x1f]/.test(pathname)) { + return ctx.res.status(400).send("Bad Request"); + } + + const pathsToTry = isRoot ? [] : [path.join(root, pathname)]; + + for (const indexFile of indexFiles) { + pathsToTry.push(path.join(root, pathname, indexFile)); + } + + if (!isRoot && !hasTrailingSlash) { + for (const extension of extensions) { + pathsToTry.push(path.join(root, `${pathname}.${extension}`)); + } + } + + for (const resourcePath of pathsToTry) { + const stat = await fs.promises.stat(resourcePath).catch(() => null); + + if (stat) { + if (stat.isDirectory()) { + if (redirect && !hasTrailingSlash) { + return ctx.res + .status(301) + .header("Location", `${url.pathname}/${url.search}`) + .send("Moved Permanently"); + } + } else if (stat.isFile()) { + if (options.setHeaders) { + await options.setHeaders(ctx.res, resourcePath, stat); + } + + return ctx.res.sendFile(resourcePath, sendFileOptions); + } + } + } + + if (fallthrough) { + return next(); + } + + ctx.res.status(404).send("Not Found"); + }); diff --git a/packages/serve-static/tests/resources/index.html b/packages/serve-static/tests/resources/index.html new file mode 100644 index 0000000..aa7dea3 --- /dev/null +++ b/packages/serve-static/tests/resources/index.html @@ -0,0 +1 @@ +HELLO! \ No newline at end of file diff --git a/packages/serve-static/tests/serve-static.test.ts b/packages/serve-static/tests/serve-static.test.ts new file mode 100644 index 0000000..59cd942 --- /dev/null +++ b/packages/serve-static/tests/serve-static.test.ts @@ -0,0 +1,23 @@ +import path from "node:path"; + +import { test, expect } from "vitest"; + +import { server as createServer } from "kitojs"; +import { serveStatic } from "../src"; + +const host = "0.0.0.0"; +const port = 8088; + +const root = path.resolve(import.meta.dirname, "./resources"); + +test("Can serve index.html from root", async () => { + const app = createServer(); + + app.use(serveStatic({ root })); + + const server = await app.listen({ host, port }, () => { + console.log("this runs"); + }); + + console.log(`this never runs??`); +}); diff --git a/packages/serve-static/tsconfig.json b/packages/serve-static/tsconfig.json new file mode 100644 index 0000000..8269970 --- /dev/null +++ b/packages/serve-static/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "esModuleInterop": true, + "strict": true, + "declaration": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src", "tests"] +} diff --git a/packages/serve-static/tsdown.config.ts b/packages/serve-static/tsdown.config.ts new file mode 100644 index 0000000..8706a32 --- /dev/null +++ b/packages/serve-static/tsdown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: "src/index.ts", + outDir: "dist", + minify: true, + dts: true, + tsconfig: "tsconfig.json", +}); diff --git a/packages/serve-static/vitest.config.ts b/packages/serve-static/vitest.config.ts new file mode 100644 index 0000000..19384e8 --- /dev/null +++ b/packages/serve-static/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + }, +});