/** * Static asset serving for the server module. * * `src/server` had sessions, CSRF, guards, auth, cookies, errors, file routes * and WebSocket sessions — but no way to send a file from disk, so every app * needed a reverse proxy just to deliver its own `client.js` (#222). * * `serveStatic()` is middleware: it answers for paths it can resolve to a file * under `root` and calls `next()` for everything else, so it composes with the * rest of the pipeline rather than taking over the server. * * File access goes through `node:fs`, which Bun, Node and Deno all provide, so * this works on every runtime `listen()` supports. * * @module bquery/server */ import type { ServerMiddleware } from './types.js'; /** Options for {@link serveStatic}. */ export interface ServeStaticOptions { /** Directory to serve from. Files outside it are never reachable. */ root: string; /** * URL prefix the assets are mounted under, e.g. `'/assets'`. The prefix is * stripped before resolving against `root`. Default: `'/'`. */ prefix?: string; /** `Cache-Control` max-age in seconds. Default: `0`. */ maxAge?: number; /** * Add `immutable` to `Cache-Control`. Only correct for content-hashed * filenames, where the URL changes whenever the bytes do. Default: `false`. */ immutable?: boolean; /** * File served for a directory request. `false` disables directory indexes. * Default: `'index.html'`. */ index?: string | false; /** * Serve a `.br`/`.gz` sidecar when the client accepts that encoding and the * file exists. Default: `false`. */ precompressed?: boolean; /** * Serve dotfiles. Off by default, so `.env` and `.git` are not exposed by * pointing `root` at a project directory. * Default: `false`. */ dotfiles?: boolean; /** * Extra or overriding extension → MIME mappings. Keys include the leading * dot and are matched case-insensitively. */ contentTypes?: Readonly>; /** Fallback MIME type. Default: `'application/octet-stream'`. */ defaultContentType?: string; } interface FsPromisesModule { stat: typeof import('node:fs/promises').stat; realpath: typeof import('node:fs/promises').realpath; } interface PathModule { join: typeof import('node:path').join; normalize: typeof import('node:path').normalize; resolve: typeof import('node:path').resolve; sep: string; extname: typeof import('node:path').extname; } /** * A weak ETag from size and mtime. Cheap, and it changes whenever the file * does, which is what a validator needs — hashing every response body would * cost more than the request it saves. * @internal */ export declare const fileEtag: (size: number, mtimeMs: number) => string; /** * Distinguish a precompressed representation's validator from the identity * one. Two representations of a URL that share an ETag are indistinguishable * to a cache, which is how compressed bytes end up served as identity. * @internal */ export declare const encodedEtag: (etag: string, encoding: string) => string; /** * Parse `Accept-Encoding` into the set of encodings the client will take. * * A substring test cannot see quality values, and `q=0` means "not * acceptable" (RFC 9110 §12.5.3) — it is exactly how a client says *do not * send me brotli*. Matching on the token alone answered `gzip;q=1.0, br;q=0` * with brotli, an encoding the client had just refused. * @internal */ export declare const parseAcceptEncoding: (header: string | null) => Map; /** * Whether the client will accept an encoding, honouring `q=0` and `*`. * * An explicit entry always beats the `*` wildcard, so `br;q=0, *` refuses * brotli while still accepting anything else. * @internal */ export declare const acceptsEncoding: (qualities: Map, encoding: string) => boolean; /** * Why {@link resolveAssetPath} declined to serve a path. * * The distinction matters to the caller: an escape attempt is answered `403`, * but a dotfile or an undecodable path is simply not ours to serve, so the * request falls through to the routes. Collapsing them made global * `serveStatic()` veto `/.well-known/acme-challenge/` and any URL with * a stray `%`, neither of which is an attack. */ export type AssetPathRejection = 'escape' | 'not-ours'; /** * Decode a URL path segment-wise and reject anything that escapes the root. * * Returns a rejection reason instead of a path when the path cannot be * served. Traversal is checked on the decoded form, so `%2e%2e%2f` is caught * along with a literal `../`, and the resolved path is re-checked against the * root afterwards as a second line of defence. * @internal */ export declare const resolveAssetPath: (root: string, relativePath: string, path: PathModule, allowDotfiles: boolean) => string | AssetPathRejection; /** * Whether a path, with symlinks resolved, is still inside the root. * * {@link resolveAssetPath}'s containment check is lexical, but `stat()` * follows symlinks — so a link *inside* the root pointing outside it passed * both layers and served the outside file, contradicting the documented * guarantee. Build outputs are a realistic place for such links to appear. * @internal */ export declare const isInsideRoot: (root: string, candidate: string, fsp: FsPromisesModule, path: PathModule) => Promise; /** * Parse a single-range `Range` header against a known size. * * Returns `null` when the header is absent or not a byte range this * implementation handles (multi-range requests included — serving the whole * body is a valid response to those). Returns `'unsatisfiable'` when the range * is syntactically fine but outside the file. * @internal */ export declare const parseRange: (header: string | null, size: number) => { start: number; end: number; } | "unsatisfiable" | null; /** Pick the MIME type for a path. @internal */ export declare const contentTypeFor: (filePath: string, extname: PathModule["extname"], overrides: Readonly> | undefined, fallback: string) => string; /** Build the `Cache-Control` value. @internal */ export declare const cacheControlFor: (maxAge: number, immutable: boolean) => string; /** * Whether the request's validators say the client's copy is still good. * `If-None-Match` wins over `If-Modified-Since` when both are present. * @internal */ export declare const isNotModified: (ifNoneMatch: string | null, ifModifiedSince: string | null, etag: string, mtimeMs: number) => boolean; /** Strip the mount prefix, or null when the path is outside it. @internal */ export declare const stripPrefix: (pathname: string, prefix: string) => string | null; /** * Serve files from disk. * * Answers `GET` and `HEAD` for paths that resolve to a file under `root`, and * calls `next()` for anything else — a missing file, another method, a path * outside the mount prefix — so routes can still handle those. * * @example Serve a build directory under `/assets` * ```ts * import { createServer, serveStatic } from '@bquery/bquery/server'; * * const app = createServer(); * app.use( * serveStatic({ * root: './dist/client', * prefix: '/assets', * maxAge: 31_536_000, * immutable: true, // content-hashed filenames only * precompressed: true, * }) * ); * ``` */ export declare const serveStatic: (options: ServeStaticOptions) => ServerMiddleware; export {}; //# sourceMappingURL=static.d.ts.map