@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

OptionTypeDefaultDescription
rootstring(required)The filesystem directory to serve files from
urlPrefixstring'/'URL prefix for static routes
indexstring'index.html'Index file to serve for directories. Set to '' to disable
fallbackstringundefinedFallback file for SPA routing (served when Accept includes text/html)
cacheControlstring | (requestPath: string) => stringautoCache-Control header. A callback receives the full leading-slash request path including urlPrefix — see below
etagbooleantrueEnable ETag generation
rangesbooleantrueEnable Range request handling
compressedbooleantrueEnable precompressed sidecar negotiation
maxBufferBytesnumber1048576Maximum 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:

  1. /app.js.br (Brotli) — preferred
  2. /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:

  • up when the root directory exists
  • down when the root is not a directory or stat fails
  • up with data: { fs: false, detail } when no filesystem is available (e.g., Cloudflare Workers) — no route is registered, and /ready stays 200

Exports

ExportKind
createStaticHandlerfunction
StaticPluginfunction
StaticFilesServiceclass
IStaticFilesinterface
StaticPluginOptionstype

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

OptionTypeDefaultDescription
rootstring(required)Directory to serve files from
urlPrefixstring'/'URL prefix for static routes
indexstring'index.html'Index file for directories
fallbackstringundefinedSPA fallback file
cacheControlstring | ((requestPath) => string)Hashed→immutable, else must-revalidateCache-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)
etagbooleantrueEnable ETag generation
rangesbooleantrueEnable Range requests
compressedbooleantrueNegotiate .br/.gz sidecars
maxBufferBytesnumber1048576Threshold for streaming

Exports

ExportKindDescription
StaticPluginfunctionPlugin factory
StaticFilesServiceclassService implementing IStaticFiles
createStaticHandlerfunctionStandalone route handler
IStaticFilesinterfaceService interface
StaticPluginOptionstypePlugin options type

IStaticFiles declares one method:

serve(ctx: IRequestContext): Promise<HandlerResult>;

Notes

  • Mounts routes on both GET and HEAD
  • A root urlPrefix claims GET /* and HEAD /*, which no second plugin can share. The plugin registers <urlPrefix>/*, so urlPrefix: '/' mounts the bare wildcard — and the kernel refuses a duplicate METHOD 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: 206 with Content-Range, 416 for unsatisfiable
  • The ETag is strong ("<size>-<mtimeMs>") when the runtime reports an mtime, and degrades to a weak size-only validator when it does not. This matters for resumption: If-Range MUST be ignored for a weak validator (RFC 9110 §13.1.5), so an interrupted download resumes only against the strong form. size+mtime is what nginx and Apache emit as strong for static files
  • Precompressed sidecars: .br preferred over .gz, sent as Content-Encoding: br and Content-Encoding: gzip respectively; ETag and conditional evaluation use the selected sidecar stat rather than the uncompressed source representation
  • Cache-Control is resolved from the full leading-slash request path, INCLUDING urlPrefix — /assets/app-A9acsx54.js for a file under that prefix, /assets/index.html for a directory request against it, and /index.html under a root mount; for a root that is a directory — what root documents — 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/.gz sidecar path — so a content-hashed asset keeps its immutable policy 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 HEAD opens no body stream, so it cannot leak a file descriptor on a file above maxBufferBytes
  • An explicit Accept-Encoding entry overrides the wildcard, so br;q=0, * refuses brotli
  • Workers degradation: registers capability but mounts no route when fs is absent
  • Health indicator: reports up/down based on root directory accessibility; on a runtime without fs it reports up with data: { fs: false, detail: 'no file system on this runtime' } and registers no static route

Agent card · TypeDoc API reference