@setu-ts/static-plugin
Static file serving plugin for the Setu-TS framework.
Overview
Provides configurable static file serving with support for:
- Conditional requests (ETag, If-None-Match, If-Modified-Since)
- Range requests (partial content)
- Precompressed sidecar negotiation (.br, .gz)
- Directory index resolution
- SPA fallback
- Streaming for large files
Installation
npm install @setu-ts/static-plugin
# Deno: deno add npm:@setu-ts/static-plugin
Usage
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { StaticPlugin } from '@setu-ts/static-plugin';
const app = createApplication({
plugins: [
RuntimePlugin(),
StaticPlugin({
root: './public',
urlPrefix: '/assets',
}),
],
});
await app.start({ port: 3000 });
Options
| Option | Type | Default | Description |
|---|---|---|---|
root | string | (required) | The filesystem directory to serve files from |
urlPrefix | string | '/' | URL prefix for static routes |
index | string | 'index.html' | Index file to serve for directories. Set to '' to disable |
fallback | string | undefined | Fallback file for SPA routing (served when Accept includes text/html) |
cacheControl | string | (requestPath: string) => string | auto | Cache-Control header. A callback receives the full leading-slash request path including urlPrefix — see below |
etag | boolean | true | Enable ETag generation |
ranges | boolean | true | Enable Range request handling |
compressed | boolean | true | Enable precompressed sidecar negotiation |
maxBufferBytes | number | 1048576 | Maximum file size to read fully into memory (1MB) |
A root urlPrefix claims the bare wildcard
The plugin registers <urlPrefix>/* on GET and HEAD, so the default urlPrefix: '/' mounts
GET /*. The kernel admits one route per METHOD path and refuses a second, naming the plugin that
registered it first:
Route 'GET /*' is already registered by plugin 'react-router'.
So an application that serves SSR (or any other catch-all) at the root cannot also mount static
files there. Give the static files their own prefix — urlPrefix: '/assets' — which is what
content-hashed build output wants anyway. A prefixed mount never competes with a root catch-all: a
route with more static segments outranks a wildcard regardless of registration order.
Cache-Control Behavior
By default, the plugin uses content-hashed filenames (e.g., index-a1b2c3d4.js) to serve immutable
assets with Cache-Control: public, max-age=31536000, immutable. Non-hashed assets use
Cache-Control: public, max-age=0, must-revalidate.
You can override this with a string or function:
StaticPlugin({
root: './public',
urlPrefix: '/assets',
cacheControl: 'no-cache',
});
// Or per-path:
StaticPlugin({
root: './public',
urlPrefix: '/assets',
cacheControl: (requestPath) =>
requestPath.endsWith('.html') ? 'no-cache' : 'public, max-age=31536000',
});
The callback’s requestPath argument is the full leading-slash request path, INCLUDING
urlPrefix — /assets/app-A9acsx54.js for a file under the prefix above, and
/assets/index.html (the resolved index, never the literal '/') for a directory request against
it. Under a root mount the prefix is empty, so the whole request path is the story:
/app-A9acsx54.js and /index.html. It is never the prefix-stripped server path, never the
absolute filesystem path, and never the .br/.gz sidecar path, so a hashed asset keeps its policy
whichever encoding is negotiated. The prefix inclusion is deliberate: a cache policy is about the
URL the client caches under, so the served path is the input. (The one way to see a bare '/' is to
point root at a FILE rather than a directory, which is outside what root documents; with a
directory the index is always resolved first.)
SPA Fallback
Enable SPA fallback by setting the fallback option:
StaticPlugin({
root: './public',
fallback: 'index.html',
});
When a requested path doesn’t exist and the request’s Accept header includes text/html, the
plugin serves the fallback file instead of returning 404.
Precompressed Sidecars
The plugin automatically negotiates precompressed variants. Given a request for /app.js, it checks
for:
/app.js.br(Brotli) — preferred/app.js.gz(Gzip) — fallback
The selected .br sidecar is sent with Content-Encoding: br; the selected .gz sidecar is sent
with Content-Encoding: gzip. Every variant sends Vary: Accept-Encoding. Conditional requests are
evaluated after negotiation, against the selected representation’s ETag.
Health Indicator
The plugin registers a health indicator named static-files:
upwhen the root directory existsdownwhen the root is not a directory or stat failsupwithdata: { fs: false, detail }when no filesystem is available (e.g., Cloudflare Workers) — no route is registered, and/readystays 200
Exports
| Export | Kind |
|---|---|
createStaticHandler | function |
StaticPlugin | function |
StaticFilesService | class |
IStaticFiles | interface |
StaticPluginOptions | type |
Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it
drifts.
Example Application
See apps/static-site/ for a
complete example.
Full API
Every export and option is documented in PUBLIC_API.md.
Readiness and dependency outages (M113b)
On runtimes without a filesystem, static-files reports up with
data: { fs: false, detail: 'no file system on this runtime' }, keeping /ready at 200. No static
route is registered. Monitor data.fs if files are required.
API reference
Static Files Plugin (@setu-ts/static-plugin)
Package: @setu-ts/static-plugin
Token: CAPABILITIES.STATIC_FILES = 'static-files'
Registration
import { StaticPlugin } from '@setu-ts/static-plugin';
app.register(
StaticPlugin({
root: './public',
urlPrefix: '/assets',
}),
);
Options
| Option | Type | Default | Description |
|---|---|---|---|
root | string | (required) | Directory to serve files from |
urlPrefix | string | '/' | URL prefix for static routes |
index | string | 'index.html' | Index file for directories |
fallback | string | undefined | SPA fallback file |
cacheControl | string | ((requestPath) => string) | Hashed→immutable, else must-revalidate | Cache-Control header. A callback receives the full leading-slash request path including urlPrefix — for a directory root, never '/' (a directory delivers its resolved index path) |
etag | boolean | true | Enable ETag generation |
ranges | boolean | true | Enable Range requests |
compressed | boolean | true | Negotiate .br/.gz sidecars |
maxBufferBytes | number | 1048576 | Threshold for streaming |
Exports
| Export | Kind | Description |
|---|---|---|
StaticPlugin | function | Plugin factory |
StaticFilesService | class | Service implementing IStaticFiles |
createStaticHandler | function | Standalone route handler |
IStaticFiles | interface | Service interface |
StaticPluginOptions | type | Plugin options type |
IStaticFiles declares one method:
serve(ctx: IRequestContext): Promise<HandlerResult>;
Notes
- Mounts routes on both
GETandHEAD - A root
urlPrefixclaimsGET /*andHEAD /*, which no second plugin can share. The plugin registers<urlPrefix>/*, sourlPrefix: '/'mounts the bare wildcard — and the kernel refuses a duplicateMETHOD path, naming the plugin that registered it first (Route 'GET /*' is already registered by plugin 'react-router'.). An application serving SSR at the root therefore cannot also mount static files there: give the static files their own prefix (urlPrefix: '/assets'), which is the arrangement content-hashed assets want anyway - Conditional requests:
ETag,If-None-Match,If-Modified-Since→304 - Range requests:
206withContent-Range,416for unsatisfiable - The
ETagis strong ("<size>-<mtimeMs>") when the runtime reports anmtime, and degrades to a weak size-only validator when it does not. This matters for resumption:If-RangeMUST be ignored for a weak validator (RFC 9110 §13.1.5), so an interrupted download resumes only against the strong form.size+mtimeis what nginx and Apache emit as strong for static files - Precompressed sidecars:
.brpreferred over.gz, sent asContent-Encoding: brandContent-Encoding: gziprespectively; ETag and conditional evaluation use the selected sidecar stat rather than the uncompressed source representation Cache-Controlis resolved from the full leading-slash request path, INCLUDINGurlPrefix—/assets/app-A9acsx54.jsfor a file under that prefix,/assets/index.htmlfor a directory request against it, and/index.htmlunder a root mount; for arootthat is a directory — whatrootdocuments — the callback never receives the literal'/'(a directory is served by resolving its index), never the prefix-stripped server path, never the absolute filesystem path, and never the.br/.gzsidecar path — so a content-hashed asset keeps itsimmutablepolicy whichever encoding is negotiated. The leading slash is guaranteed for both a file and a resolved index. The prefix inclusion is deliberate (M95c C3): a cache policy is about the URL the client caches under, so the served path is the input, and stripping it would silently change what every existing callback matches.- A
HEADopens no body stream, so it cannot leak a file descriptor on a file abovemaxBufferBytes - An explicit
Accept-Encodingentry overrides the wildcard, sobr;q=0, *refuses brotli - Workers degradation: registers capability but mounts no route when
fsis absent - Health indicator: reports
up/downbased on root directory accessibility; on a runtime without fs it reportsupwithdata: { fs: false, detail: 'no file system on this runtime' }and registers no static route