@hyperfrontend/features/serverServer
Dev server and debug UI for testing host/hostee interactions — per-app static hosting plus display-mode, resize, message-log, and security controls — and the production static server behind hf serve.
Quick start
Start a dev server from a resolved hf-dev.config.*:
import { resolveDevConfig, startDevServer } from '@hyperfrontend/features/server'
const config = await resolveDevConfig({ cwd: process.cwd(), flags })
const handle = await startDevServer(config)
console.log(handle.debugUrl) // http://localhost:4280/
handle.apps.forEach((app) => console.log(app.name, app.url))
await handle.close()
Or serve a built site for production:
hf serve --root dist/site --port 8080
How it serves
A URL ending in / serves that directory's index.html, so a multi-page build's index.html and host/index.html load at / and /host/; the unslashed /host answers 301 to /host/.
Each configured app is served by its own static server bound to its port, so apps load at distinct origins, letting the host/hostee message channel and security envelope be exercised cross-origin, exactly as in production. The debug UI is hosted at / on a separate control server (default port 4280, movable with the config's debug.port or the --port flag, which wins), which also exposes the running-app manifest at /__apps and the compiled debug-UI assets under /__debug/. Resolution fails loudly when an app's port equals the enabled debug UI's port, naming both.
Production serving
hf serve serves one directory over one port, and its production behavior is fixed: text responses are brotli- or gzip-compressed per the client's Accept-Encoding (compressed bytes cached per file), every file carries a weak ETag and a matching If-None-Match answers 304, directory URLs resolve to index.html with the 301 redirect described above, dotfiles and the hf-serve.config.* file answer 404, only GET/HEAD are answered (anything else gets 405), a failure inside the pipeline answers 500 instead of crashing the server, each request writes one access-log line, and SIGINT/SIGTERM close the server gracefully.
What varies is data, declared in hf-serve.config.*: the served root, the listen port and host, the log switch, and ordered headers rules; each rule matches by optional path prefix/suffix, and later rules override earlier ones one header at a time:
{
"root": "dist/site",
"port": 8080,
"headers": [{ "suffix": ".html", "headers": { "Cache-Control": "no-cache" } }]
}
No config is required at all: with none found, the working directory is served on port 4284 on every interface. The file is selected by --config, else (when --root names a directory carrying one) the artifact's own <root>/hf-serve.config.json, else discovered in the working directory. A platform-assigned PORT environment variable overrides the config's port, and the --root/--port/--host flags override everything, so on a host that injects PORT, hf serve --root <dir> needs no port flag at all.
Deliberately absent: SPA rewrites, extensionless .html rewriting, directory listings, Range requests, and CORS. Symlinked paths answer 404, and each response is read whole into memory (feature artifacts are small; there is no streaming path). The escape hatch is code, not config: custom steps prepend to the built-in pipeline (method guard → compression → header rules → file serving; rules sit inside compression so a rule-set Cache-Control: no-transform or Content-Type shapes what the compressor sees), so a step sees every request first and every response last, and either answers itself or transforms what next() returns:
import type { ServeStep } from '@hyperfrontend/features/server'
import { resolveServeConfig, startStaticServer } from '@hyperfrontend/features/server'
const cors: ServeStep = (_request, _context, next) => {
const response = next()
return { ...response, headers: { ...response.headers, 'Access-Control-Allow-Origin': '*' } }
}
const config = await resolveServeConfig({ cwd: process.cwd(), flags })
const handle = await startStaticServer(config, { steps: [cors] })
Steps are deliberately not expressible in the config file: config files stay data.
API
| Export | Purpose |
|---|---|
resolveDevConfig | Resolve hf-dev.config.* + CLI flags into concrete app servers. |
startDevServer | Start the app servers and the debug-UI control server. |
validateDevConfig / validateApps / validateDevApp | Runtime validation of the config shape. |
createStaticHandler / serveFile | Static-file request handling used by the app servers. |
resolveServeConfig | Resolve hf-serve.config.* + CLI flags into a concrete serving plan. |
startStaticServer / createServeListener | Start the production static server, or host its request listener elsewhere. |
buildServeSteps / buildCompressionStep / runSteps | The built-in ServeStep pipeline and the runner custom steps compose with. |
validateServeConfig / validateHeaderRule | Runtime validation of the serve-config shape. |
The config schemas (apps/debug and root/port/host/headers/log) and the defineDevConfig() / defineServeConfig() authoring helpers live in the main @hyperfrontend/features entry.
API Reference
ƒ Functions
Accept-Encoding, with compressed bytes cached per file so a static deployment compresses each asset once per encoding. HEAD requests skip compression so their advertised
Content-Length stays the identity size, and a Cache-Control: no-transform set by a header rule is honored. Bodiless answers for compressible resources (HEAD, 304) still carry Vary: Accept-Encoding so caches keep encodings apart.Returns
ServeStepExample
Composing the step into a custom pipeline
const steps = [buildCompressionStep(), terminalStep]Header rules sit inside compression so a rule's headers shape what the compressor sees: a rule-set
Cache-Control: no-transform suppresses encoding and a rule-set Content-Type decides compressibility. Custom steps prepend to this chain, so a plugin sees every request first and every response last.Parameters
| Name | Type | Description |
|---|---|---|
§config | ResolvedServeConfig | The resolved serving plan. |
§deps | ServeStepDeps | Injectable file-system boundaries. (default: {}) |
Returns
ServeStep[]Example
Assembling the default pipeline
const steps = buildServeSteps(resolved, {})root, rejecting any path that escapes the root via ...Parameters
Returns
stringnull when the path escapes root.Example
Rejecting a traversal attempt
confineDecodedPath('/abs/dist', '/../secret') // nullContent-Type, falling back to octet-stream.Parameters
| Name | Type | Description |
|---|---|---|
§filePath | string | The path whose extension selects the MIME type. |
Returns
stringExample
Looking up a stylesheet's type
contentTypeFor('/abs/dist/app.css') // 'text/css; charset=utf-8'createServeListener(config: ResolvedServeConfig, deps: StaticServeDeps): (req: IncomingMessage, res: ServerResponse) => void
This is the replacement seam: anything that can call this listener — Node's
http.createServer, a test harness, or another runtime adapter — can host the pipeline unchanged.Parameters
| Name | Type | Description |
|---|---|---|
§config | ResolvedServeConfig | The resolved serving plan. |
§deps | StaticServeDeps | Optional file-system, logging, and pipeline overrides. (default: {}) |
Returns
(req: IncomingMessage, res: ServerResponse) => voidhttp.createServer.Example
Hosting the pipeline on a hand-made server
const server = createServer(createServeListener(resolved))createStaticHandler(root: string, deps: StaticHandlerDeps): (req: IncomingMessage, res: ServerResponse) => void
Parameters
| Name | Type | Description |
|---|---|---|
§root | string | The absolute directory files are served from. |
§deps | StaticHandlerDeps | Optional file-system overrides. (default: {}) |
Returns
(req: IncomingMessage, res: ServerResponse) => voidhttp.createServer.Example
Serving a compiled app directory
const server = createServer(createStaticHandler('/abs/dist'))decodeURIComponent throw inside a request handler.Parameters
| Name | Type | Description |
|---|---|---|
§path | string | The request path with the query string already stripped. |
Returns
stringnull when the encoding is malformed.Example
Rejecting a malformed encoding
decodeRequestPath('/%') // nullThe location is derived from the resolved path rather than the raw request, so it is always a single-slash path relative to this server's own root: a request that writes an authority into the URL (
//example.com/../host) is answered with the directory it actually resolved to, never with a location pointing off this origin.Parameters
Returns
stringExample
Redirecting a directory request
directoryLocation('/srv', '/srv/host', '/host?debug=1') // '/host/?debug=1'Parameters
Returns
stringundefined when absent.Example
Reading a content type set in any case
headerValue({ 'content-type': 'text/html' }, 'Content-Type') // 'text/html'Accept-Encoding header into the preferred supported encoding. Preference is brotli, then gzip: the order every mainstream browser also ranks them in. A
* token stands in for both. Encodings disabled with q=0 are never chosen.Parameters
| Name | Type | Description |
|---|---|---|
§header | string | The raw Accept-Encoding value. |
Returns
"br" | "gzip"'br', 'gzip', or null when neither is acceptable.Example
Negotiating a browser's default header
negotiateEncoding('gzip, deflate, br') // 'br'Returns
StaticResponseExample
Building a 404
plainResponse(404, 'Not Found')/ and dropping any query string.Parameters
| Name | Type | Description |
|---|---|---|
§url | string | The raw request URL ( req.url), which may be undefined. |
Returns
stringExample
Stripping a query string
requestPath('/app.js?v=2') // '/app.js'hf-dev.config.* into concrete app servers and debug settings, applying config file < flags precedence: --apps replaces the apps array, --port overrides the config's debug.port for the debug UI, and --config selects the file.Parameters
| Name | Type | Description |
|---|---|---|
§options | ResolveDevConfigOptions | The working directory, parsed flags, and injectable deps. |
Returns
Promise<ResolvedDevConfig>Example
Resolving a discovered dev config
const resolved = await resolveDevConfig({ cwd: process.cwd(), flags })hf-serve.config.* into a concrete serving plan, applying defaults < config file < PORT environment variable < flags precedence: --root sets the served directory, --port/--host the listen address, and --config selects the file. A platform-assigned PORT beats a port baked into the served artifact's config, and an explicit --port still beats both, so hf serve --root <dir> works without a port flag wherever the platform injects one. Unlike the dev server, serving is valid with no config at all: the working directory is served with defaults.Parameters
| Name | Type | Description |
|---|---|---|
§options | ResolveServeConfigOptions | The working directory, parsed flags, and injectable deps. |
Returns
Promise<ResolvedServeConfig>Example
Resolving a static-serve config
const resolved = await resolveServeConfig({ cwd: process.cwd(), flags })runSteps(steps: unknown, request: StaticRequest, context: ServeStepContext): StaticResponse
next(). A pipeline whose steps all delegate past the end answers 404, so a custom chain without a terminal step still returns a response.Parameters
| Name | Type | Description |
|---|---|---|
§steps | unknown | The ordered steps, outermost first. |
§request | StaticRequest | The request to answer. |
§context | ServeStepContext | The shared per-request context. |
Returns
StaticResponseExample
Running a request through a custom step and the built-ins
const response = runSteps([...customSteps, ...builtInSteps], request, { config })serveFile(root: string, urlPath: string, res: ServerResponse, deps: StaticHandlerDeps): void
root to the response, sending 403 on a traversal attempt and 404 when the file is missing. A URL ending in
/ serves that directory's index.html, so / and /host/ reach the pages a multi-page build emits as index.html and host/index.html. A directory URL written without the trailing slash (/host) answers 301 to the slashed form, keeping relative asset URLs on the page resolvable.Parameters
| Name | Type | Description |
|---|---|---|
§root | string | The absolute directory files are served from. |
§urlPath | string | The request path (with or without a query string). |
§res | ServerResponse | The HTTP response to write. |
§deps | StaticHandlerDeps | Optional file-system overrides. (default: {}) |
Examples
Serving an app's `index.html`
serveFile('/abs/dist', '/', res)Serving a companion page from a multi-page build
serveFile('/abs/dist', '/host/', res) // sends /abs/dist/host/index.htmlParameters
| Name | Type | Description |
|---|---|---|
§config | ResolvedDevConfig | The resolved dev-server config. |
§deps | DevServerDeps | Optional server-creation, asset-location, and file-system overrides. (default: {}) |
Returns
Promise<DevServerHandle>Example
Starting a dev server from a resolved config
const handle = await startDevServer(resolved)
console.log(handle.debugUrl)
await handle.close()startStaticServer(config: ResolvedServeConfig, deps: StaticServeDeps): Promise<StaticServerHandle>
Parameters
| Name | Type | Description |
|---|---|---|
§config | ResolvedServeConfig | The resolved serving plan. |
§deps | StaticServeDeps | Optional server-creation, file-system, logging, and pipeline overrides. (default: {}) |
Returns
Promise<StaticServerHandle>Example
Serving a built site
const handle = await startStaticServer(resolved)
console.log(handle.url)
await handle.close()Parameters
Returns
DevAppConfig[]Example
Validating an apps array loaded from `--apps`
const apps = validateApps([{ name: 'clock', outputDir: './dist' }], '/p/apps.json')name/outputDir strings and an optional numeric port.Parameters
Returns
DevAppConfigExample
Validating a single app entry
const app = validateDevApp({ name: 'clock', outputDir: './dist' }, 0, '/p/hf-dev.config.json')Parameters
Returns
DevConfigExample
Validating a loaded dev config
const config = validateDevConfig({ apps: [{ name: 'clock', outputDir: './dist' }] }, '/p/hf-dev.config.json')prefix/suffix strings and a string-valued headers record.Parameters
Returns
ServeHeaderRuleExample
Validating a single header rule
const rule = validateHeaderRule({ suffix: '.html', headers: { 'Cache-Control': 'no-cache' } }, 0, '/p/hf-serve.config.json')Parameters
Returns
ServeConfigExample
Validating a loaded serve config
const config = validateServeConfig({ root: 'dist/site' }, '/p/hf-serve.config.json')◈ Interfaces
Properties
Properties
Properties
Properties
readonly assetRoot?:stringreadonly createServer?:(handler: (req: IncomingMessage, res: ServerResponse) => void) => ServerProperties
Properties
hf-dev.config.*: concrete app servers plus debug settings.Properties
Properties
Properties
Properties
readonly discover?:(directory: string, baseName: string) => stringhf-serve.config.*: concrete root, listen address, and header rules.Properties
readonly sourcePath?:stringProperties
filePath?:stringProperties
Properties
Properties
Properties
Properties
readonly createServer?:(handler: (req: IncomingMessage, res: ServerResponse) => void) => Serverreadonly stat?:(filePath: string) => FileStatsnull when the path is unreadable.readonly steps?:unknownProperties
◆ Types
next() and transform what comes back: the innermost step serves the file, so next() always yields a complete response.type ServeStep = (request: StaticRequest, context: ServeStepContext, next: () => StaticResponse) => StaticResponse