# @api.global/typedserver

> A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.

Latest version **13.0.2** (published 2026-09-24) · MIT license · 0 weekly downloads

## Install

```sh
npm install @api.global/typedserver
pnpm add @api.global/typedserver
yarn add @api.global/typedserver
bun add @api.global/typedserver
```

## Health

**Score 60/100 (C)** — status: active.

Positive: esm support; no vulnerabilities; recently updated; high maintenance score.

Warnings: low downloads; no types; large bundle.

## Facts

| | |
|---|---|
| Version | 13.0.2 |
| Published | 2026-09-24 |
| First published | 2023-08-03 |
| Weekly downloads | 0 |
| License | MIT |
| TypeScript types | none |
| Module format | ESM |
| Dependencies | 31 |
| Unpacked size | 24.6 MB |
| Known vulnerabilities | 0 |
| Install scripts | no |
| Author | Task Venture Capital GmbH <hello@task.vc> |
| Maintainers | lossless |
| Keywords | TypeScript, static file serving, live reload, compression, typed requests, HTTP server, SSL, cors, proxy, sitemap, feeds, robots.txt, compression (gzip, deflate, brotli) |

## Links

- npm: https://www.npmjs.com/package/@api.global/typedserver
- Repository: https://code.foss.global/api.global/typedserver
- Issues: https://code.foss.global/api.global/typedserver/issues
- npm.io page: https://npm.io/package/@api.global/typedserver

## Dependencies (31)

- [lit](https://npm.io/package/lit.md) ^3.3.3
- [@push.rocks/lik](https://npm.io/package/@push.rocks/lik.md) ^6.4.2
- [@tsclass/tsclass](https://npm.io/package/@tsclass/tsclass.md) ^9.5.1
- [@push.rocks/smartfs](https://npm.io/package/@push.rocks/smartfs.md) ^1.6.0
- [@push.rocks/smartrx](https://npm.io/package/@push.rocks/smartrx.md) ^3.0.12
- [@push.rocks/smartenv](https://npm.io/package/@push.rocks/smartenv.md) ^6.1.0
- [@push.rocks/smartlog](https://npm.io/package/@push.rocks/smartlog.md) ^3.2.2
- [@push.rocks/webstore](https://npm.io/package/@push.rocks/webstore.md) ^2.2.0
- [@push.rocks/smartfeed](https://npm.io/package/@push.rocks/smartfeed.md) ^1.4.1
- [@push.rocks/smartfile](https://npm.io/package/@push.rocks/smartfile.md) ^13.1.3
- [@push.rocks/smartjson](https://npm.io/package/@push.rocks/smartjson.md) ^6.0.2
- [@push.rocks/smartmime](https://npm.io/package/@push.rocks/smartmime.md) ^2.1.0
- [@push.rocks/smartntml](https://npm.io/package/@push.rocks/smartntml.md) ^3.0.0
- [@push.rocks/smartopen](https://npm.io/package/@push.rocks/smartopen.md) ^2.0.0
- [@push.rocks/smartpath](https://npm.io/package/@push.rocks/smartpath.md) ^6.0.0
- [@push.rocks/smarttime](https://npm.io/package/@push.rocks/smarttime.md) ^4.2.3
- [@push.rocks/smartdelay](https://npm.io/package/@push.rocks/smartdelay.md) ^3.1.0
- [@push.rocks/smartmatch](https://npm.io/package/@push.rocks/smartmatch.md) ^2.0.0
- [@push.rocks/smartserve](https://npm.io/package/@push.rocks/smartserve.md) ^7.2.0
- [@push.rocks/smartwatch](https://npm.io/package/@push.rocks/smartwatch.md) ^6.5.0
- [@push.rocks/webrequest](https://npm.io/package/@push.rocks/webrequest.md) ^4.3.1
- [@api.global/typedsocket](https://npm.io/package/@api.global/typedsocket.md) ^8.5.0
- [@push.rocks/smartstream](https://npm.io/package/@push.rocks/smartstream.md) ^3.4.2
- [@api.global/typedrequest](https://npm.io/package/@api.global/typedrequest.md) ^8.0.3
- [@push.rocks/smartpromise](https://npm.io/package/@push.rocks/smartpromise.md) ^4.2.4
- [@push.rocks/smartrequest](https://npm.io/package/@push.rocks/smartrequest.md) ^5.0.4
- [@push.rocks/smartsitemap](https://npm.io/package/@push.rocks/smartsitemap.md) ^4.0.2
- [@cloudflare/workers-types](https://npm.io/package/@cloudflare/workers-types.md) ^5.20260722.1
- [@design.estate/dees-comms](https://npm.io/package/@design.estate/dees-comms.md) ^1.1.0
- [@push.rocks/smartmanifest](https://npm.io/package/@push.rocks/smartmanifest.md) ^2.0.2
- [@api.global/typedrequest-interfaces](https://npm.io/package/@api.global/typedrequest-interfaces.md) ^7.1.0

## Alternatives

- [lodash.startswith](https://npm.io/package/lodash.startswith.md) — 769.7K weekly downloads
- [@tarojs/service](https://npm.io/package/@tarojs/service.md) — 33.9K weekly downloads
- [io.extendreality.tilia.indicators.spatialtargets.unity](https://npm.io/package/io.extendreality.tilia.indicators.spatialtargets.unity.md) — 131 weekly downloads
- [@rtarojs/taro](https://npm.io/package/@rtarojs/taro.md) — 90 weekly downloads
- [node-branch-io](https://npm.io/package/node-branch-io.md) — 50 weekly downloads

## Recent versions

- 13.0.2 (latest) — 2026-09-24
- 13.0.1 — 2026-09-24
- 13.0.0 — 2026-09-24
- 12.0.0 — 2026-09-23
- 11.7.0 — 2026-09-23
- 11.6.0 — 2026-09-21
- 11.5.0 — 2026-09-21
- 11.4.1 — 2026-09-12
- 11.4.0 — 2026-09-09
- 11.3.0 — 2026-09-09
- 11.2.1 — 2026-09-03
- 11.2.0 — 2026-09-03
- 11.1.2 — 2026-08-31
- 11.1.1 — 2026-08-29
- 11.1.0 — 2026-08-20
- … 142 more at https://npm.io/package/@api.global/typedserver/versions

## README

# @api.global/typedserver

A powerful TypeScript-first web server framework for building modern full-stack applications. Features static file serving, live reload, type-safe API integration, decorator-based routing, service worker support, and edge computing capabilities. Part of the `@api.global` ecosystem.

## Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.

## ✨ Features

- 🔒 **Type-Safe API** — Full TypeScript support with `@api.global/typedrequest` and `@api.global/typedsocket`
- 🎯 **Decorator Routing** — Clean, expressive routing with `@Route`, `@Get`, `@Post` decorators via smartserve
- 🛡️ **Security Headers** — Built-in CSP, HSTS, X-Frame-Options, and comprehensive security configuration
- ⚡ **Live Reload** — Automatic browser refresh on file changes during development
- 🛠️ **Service Worker** — Network-first pages with offline copies, cache-first static assets, and one update model with versioned caches
- ☁️ **Edge Workers** — Cloudflare Workers compatible edge computing with domain routing
- 📡 **WebSocket** — Real-time bidirectional communication via TypedSocket
- 🗺️ **SEO Tools** — Built-in sitemap, RSS feed, and robots.txt generation
- 🎯 **SPA Support** — Single-page application fallback routing
- 📱 **PWA Ready** — Web App Manifest generation for progressive web apps
- 🗜️ **Compression** — Automatic Brotli + Gzip response compression
- 📦 **Bundled Content** — Serve pre-bundled content from memory for zero-filesystem deployments

## 📦 Installation

```bash
# Using pnpm (recommended)
pnpm add @api.global/typedserver

# Using npm
npm install @api.global/typedserver
```

## 🚀 Quick Start

### Basic Server

```typescript
import { TypedServer } from '@api.global/typedserver';

const server = new TypedServer({
  serveDir: './public',
  cors: true,
  watch: true,           // Enable file watching
  injectReload: true,    // Inject live reload script
});

await server.start();
console.log('Server running on port 3000!');
```

### Full Configuration

```typescript
import { TypedServer } from '@api.global/typedserver';

const server = new TypedServer({
  port: 8080,
  serveDir: './dist',
  cors: true,

  // Development
  watch: true,
  injectReload: true,
  noCache: true,            // Disable browser caching

  // Production
  forceSsl: true,
  spaFallback: true,        // Serve index.html for client-side routes

  // SEO
  sitemap: true,
  feed: true,
  robots: true,
  domain: 'example.com',
  blockWaybackMachine: false,

  // PWA
  appVersion: 'v1.0.0',
  manifest: {
    name: 'My App',
    short_name: 'myapp',
    start_url: '/',
    display: 'standalone',
    background_color: '#ffffff',
    theme_color: '#000000',
  },

  // Compression
  compression: {
    enabled: true,
    algorithms: ['br', 'gzip'],
    threshold: 1024,
  },
});

await server.start();
```

## 🛣️ Routing

TypedServer uses a unified routing system powered by `@push.rocks/smartserve`. You can add routes using decorators or the programmatic API.

Every TypedServer has its own route table: a route or controller is served only by the server it
was registered with, also when several servers run in one process. Register them before or after
`start()`; `stop()` releases them, and a stopped server accepts no new ones.

### Decorator-Based Routing

Create clean, expressive controllers using decorators:

```typescript
import * as smartserve from '@push.rocks/smartserve';
import { TypedServer } from '@api.global/typedserver';

@smartserve.Route('/api/users')
class UserController {
  @smartserve.Get('/')
  async listUsers(ctx: smartserve.IRequestContext): Promise<Response> {
    const users = await getUsersFromDb();
    return new Response(JSON.stringify(users), {
      headers: { 'Content-Type': 'application/json' },
    });
  }

  @smartserve.Get('/:id')
  async getUser(ctx: smartserve.IRequestContext): Promise<Response> {
    const userId = ctx.params.id;
    const user = await getUserById(userId);
    return new Response(JSON.stringify(user), {
      headers: { 'Content-Type': 'application/json' },
    });
  }

  @smartserve.Post('/')
  async createUser(ctx: smartserve.IRequestContext): Promise<Response> {
    const userData = await ctx.json();
    const newUser = await createUserInDb(userData);
    return new Response(JSON.stringify(newUser), {
      status: 201,
      headers: { 'Content-Type': 'application/json' },
    });
  }
}

const server = new TypedServer({ serveDir: './public', cors: true });
server.registerController(new UserController());
await server.start();
```

A server serves one instance per controller class, and only methods with an HTTP-method decorator
(`@Get`, `@Post`, ...) are routes. A request runs through its route the way smartserve runs it:
OpenAPI request validation, `@Guard` (including `rateLimit()`, which counts per server) and
`@Intercept` before the handler, `@Transform` after it. A result that is not a `Response` answers
`204` for `null` or `undefined`, `text/plain` for a string and JSON for anything else; so does an
`addRoute()` handler that returns `null`.

A controller route or `addRoute()` handler that throws smartserve's `HttpError` (for example
`HttpError.unauthorized()`) answers with `error.toResponse()`: its status and a JSON body.
`RouteNotFoundError` passes the request on to the static files and fallbacks, and any other error
answers `500`.

### Programmatic Routes with `addRoute()`

Add routes dynamically using the `addRoute()` API:

```typescript
import { TypedServer } from '@api.global/typedserver';

const server = new TypedServer({ serveDir: './public', cors: true });

// Simple route
server.addRoute('/api/health', 'GET', async (ctx) => {
  return new Response(JSON.stringify({ status: 'ok' }), {
    headers: { 'Content-Type': 'application/json' },
  });
});

// Route with parameters (Express-style :param syntax)
server.addRoute('/api/items/:id', 'GET', async (ctx) => {
  const itemId = ctx.params.id;
  return new Response(JSON.stringify({ id: itemId }), {
    headers: { 'Content-Type': 'application/json' },
  });
});

// Wildcard routes: `*` matches the rest of the path, slashes included. It is
// not named and adds no entry to ctx.params, so read the matched part from ctx.path.
server.addRoute('/files/*', 'GET', async (ctx) => {
  const filePath = ctx.path.slice('/files/'.length);
  return new Response(`Requested: ${filePath}`);
});

await server.start();
```

Files served from `serveDir` or `bundledContent` (including the SPA fallback,
pages with the injected live reload client and `304` answers) always get
`Cache-Control: no-cache`: their paths carry no content hash, so a cache may
keep them but must revalidate them against their ETag before every use (a `304`
while unchanged). The first navigation after a deploy, the reload the service
worker asks for and the worker filling the new version's cache therefore load
the new files, and the service worker can still hold them as offline copies.

When `noCache: true` is configured, every other response (routes, built-in
endpoints, decorated controllers, errors) gets `Cache-Control: no-store,
no-cache, must-revalidate, proxy-revalidate, max-age=0` plus `Pragma: no-cache`
and `Expires: 0`, even when its handler set `Cache-Control`. Nothing stores it,
the service worker included.

A `Response` returned by `addRoute()` can deliberately own its policy by setting
an explicit `Cache-Control` header. TypedServer preserves that value and does not
add conflicting `Pragma` or `Expires` headers; CORS and security headers still
apply.

`server.markStaticResponse(response)` gives the response of a route or a
controller the policy of static files instead: `Cache-Control: no-cache` and the
`appHash` stamp, so the service worker serves it cache-first per app version (see
Route output, per-user data and logout).

### Request connection info

Every HTTP request context TypedServer builds carries `ctx.connectionInfo`, the
transport-level peer of the connection the request arrived on. It is set for
`addRoute()` handlers, decorated controller handlers, `requestAdmission`, every
surface `requestAdmission` and `httpHandler`, and for HTTP TypedRequest handlers
through `tools.localData.requestContext`. WebSocket upgrade contexts handed to
`websocketAdmission` carry the same field, and SmartServe exposes it on the peer
as `peer.connectionInfo`.

```typescript
import type { IConnectionInfo } from '@api.global/typedserver';

// IConnectionInfo:
//   remoteAddr: string;   // direct peer address, or 'unknown'
//   remotePort: number;   // direct peer port, or 0
//   localAddr: string;    // bound listener address
//   localPort: number;    // bound listener port
//   encrypted: boolean;   // TLS terminated by this server
//   tlsVersion?: string;  // negotiated TLS version where the runtime reports one

const server = new TypedServer({
  requestAdmission: (context) => acceptSource(context.connectionInfo?.remoteAddr),
  websocketAdmission: (context) => acceptSource(context.connectionInfo?.remoteAddr),
});

server.addRoute('/whoami', 'GET', async (context) =>
  Response.json({ peer: context.connectionInfo?.remoteAddr }));
```

**This is the direct peer, not the end client behind a proxy.** When a reverse
proxy terminates the connection, `remoteAddr` is the proxy's address. TypedServer
never inspects `X-Forwarded-For`, `Forwarded`, or PROXY protocol and has no
trust-proxy configuration: deciding which proxies are trusted and resolving a
client address from their headers stays the consumer's responsibility, because
only the deployment knows its trust boundary.

Values are reported exactly as the runtime reports them and are never
normalized, so a dual-stack listener reports IPv4 peers in the IPv4-mapped form
`::ffff:1.2.3.4`. `remoteAddr` is `'unknown'` with `remotePort` `0` when the
runtime cannot name the peer; treat that as an unidentified source, never as an
address. The field is optional on the type because `IRequestContext` values can
also be constructed outside the server (test doubles); contexts built by
TypedServer always carry it.

### HTTP request body limits

Set `requestMaxBodyBytes` to bound native HTTP bodies before request admission,
route dispatch, or body parsing. A positive integer applies to all requests;
a synchronous metadata selector can select an endpoint-specific policy:

```typescript
const server = new TypedServer({
  cors: false,
  noCache: true,
  requestMaxBodyBytes: ({ method, url }) =>
    method === 'POST' && new URL(url).pathname === '/oauth/introspect'
      ? 64 * 1024
      : undefined,
});
```

The selector receives only the URL, method, and a copy of the headers. Returning
`undefined` leaves that request without this generic cap. When a TypedRequest
endpoint also has `typedRequestMaxBodyBytes`, the smaller limit applies. Both
static and selected limits must be integers from 1 through 2147483647; malformed
static options fail construction, and a throwing or invalid selector rejects
with a sanitized JSON 500 before admission. Selectors must be synchronous.

Limits count actual body bytes, including chunked requests, without trusting
`Content-Length`. Accepted bodies remain available through the normal context
parsers. Oversized bodies receive JSON 413, stream failures JSON 400. Rejections
use the selected surface's security/no-cache policy without enabling CORS. The
existing transport drains rejected bodies for HTTP keep-alive; configure
`requestTimeout` and `connectionTimeout` to bound request reception time. This is
a body-size cap, not a header-size, decompression, or concurrency limit.

## 🔌 Type-Safe API Integration

### Adding TypedRequest Handlers

```typescript
import { TypedServer } from '@api.global/typedserver';
import * as typedrequest from '@api.global/typedrequest';
import type {
  ITypedRequest,
  implementsTR,
} from '@api.global/typedrequest-interfaces';

// Define your typed request interface
interface IGetUser extends implementsTR<ITypedRequest, IGetUser> {
  method: 'getUser';
  request: { userId: string };
  response: { name: string; email: string };
}

const server = new TypedServer({ serveDir: './public', cors: true });

// Add a typed handler directly to the server's router
server.typedrouter.addTypedHandler<IGetUser>(
  new typedrequest.TypedHandler('getUser', async (data) => {
    return { name: 'John Doe', email: 'john@example.com' };
  })
);

await server.start();
```

### Real-Time WebSocket Communication

TypedServer automatically sets up TypedSocket 8 for real-time communication. It
binds each application router through TypedSocket's generated transport routing
surface and requires the exact package-major handshake before application RPC:

```typescript
import { TypedServer } from '@api.global/typedserver';
import * as typedrequest from '@api.global/typedrequest';
import type {
  ITypedRequest,
  implementsTR,
} from '@api.global/typedrequest-interfaces';

interface IChatMessage extends implementsTR<ITypedRequest, IChatMessage> {
  method: 'sendMessage';
  request: { text: string; room: string };
  response: { messageId: string; timestamp: number };
}

const server = new TypedServer({ serveDir: './public', cors: true });

// Handle real-time messages
server.typedrouter.addTypedHandler<IChatMessage>(
  new typedrequest.TypedHandler('sendMessage', async (data) => {
    return { messageId: crypto.randomUUID(), timestamp: Date.now() };
  })
);

await server.start();

// Push messages to connected clients
const connections = await server.typedsocket.findAllTargetConnectionsByTag('chat-member');
for (const conn of connections) {
  // Push to specific clients via TypedSocket
}
```

Client-owned connection tags are denied unless `clientTagPolicy` contains an
exact rule for the name. Authentication and authorization tags must be assigned
by a server handler to the exact request peer:

```typescript
interface IRegisterConnection extends implementsTR<ITypedRequest, IRegisterConnection> {
  method: 'registerConnection';
  request: { identity: { accessToken: string } };
  response: { registered: true };
}

declare const identityVerifier: {
  verifyAccessToken(accessToken: string): Promise<{ userId: string }>;
};

server.typedrouter.addTypedHandler(
  new typedrequest.TypedHandler<IRegisterConnection>(
    'registerConnection',
    async ({ identity }, typedTools) => {
      const verifiedIdentity = await identityVerifier.verifyAccessToken(identity.accessToken);
      const connection = server.getServerConnectionForRequest(typedTools);
      server.setServerTag(connection, 'authenticated', { userId: verifiedIdentity.userId });
      return { registered: true };
    },
  ),
);
```

`getServerConnectionForRequest()` fails closed for HTTP requests and detached
or forged request metadata. A name assigned through `setServerTag()` remains
server-owned; clients cannot overwrite or remove it. Use `removeServerTag()`
when the server revokes that connection state.

Configure `virtualStreamAuthorizationAdapter` when server handlers create
VirtualStreams through `server.typedsocket.createVirtualStream()`. The adapter
must synchronously bind application authority and revalidate it for the exact
request peer as defined by TypedSocket 8.

For incoming streams that require processing after all bytes are delivered, set
`receiverAcceptanceTimeoutMs` to a bounded application receipt deadline in
milliseconds (1–3,600,000; default 30,000). This local receiver policy does not
change byte-transfer progress deadlines or let a remote sender extend resource
retention. The sender must separately choose its own compatible acceptance
deadline. Cancellation and disconnect still terminate the stream.

TypedSocket and native HTTP requests expose transport-owned cancellation through
`TypedTools.abortSignal`. TypedSocket 8 propagates exact remote request cancellation;
the native `/typedrequest` route forwards the request connection signal. Handlers
must stop their owned work when that signal aborts.

TypedServer's own frontend and service-worker bundles register their connection
role through the built-in `registerTypedServerConnection` RPC during every
initial connection and reconnect. Those infrastructure tags are server-owned;
application broadcasts should use a separate application-specific tag such as
`chat-member`.

The built-in `serviceworker_speedtest` RPC answers a `download_chunk` with a
generated payload of `chunkSizeKB` kilobytes. That client-supplied size must be
an integer from `1` through `1024`; anything else — a non-number, a fractional
value, `0`, a negative number, or a larger size — is refused with a
`TypedResponseError` instead of being allocated, so the endpoint cannot be used
to amplify memory or bandwidth. Omitting `chunkSizeKB` keeps the documented
default of `64` KB, which is the only size the shipped service worker dashboard
client requests. `upload_chunk` allocates nothing on the server; it only reports
the length of the payload it received. The accepted payload size is bounded only
where `typedRequestMaxBodyBytes`/`requestMaxBodyBytes` (HTTP) or
`websocketMaxPayloadBytes` (WebSocket) are configured; all three are unset by
default.

## ☁️ Edge Worker (Cloudflare Workers)

Deploy your application to the edge with Cloudflare Workers:

```typescript
import { EdgeWorker } from '@api.global/typedserver/edgeworker';

// The constructor registers its FetchEvent listener and applies the package's
// configured domain instructions and responder pipeline.
new EdgeWorker();
```

## 🔧 Service Worker

TypedServer serves a service worker at `/serviceworker.bundle.js`; the
`web_serviceworker_client` export registers it from your frontend. The worker is
served by a TypedServer without `surfaces`; surface mode does not serve it. Its
source map is served at `/serviceworker.bundle.js.map`, adjusted for the
configuration line the server puts in front of the bundle, so dev tools show
the worker's sources.

The worker needs the Web Locks API, which Chrome 69, Firefox 96, Safari 15.4
and later provide in service workers: its instances apply app versions, record
the app versions of the pages and change their caches one at a time through it. In an older browser the worker
script throws when it starts, so the browser does not install it and the pages
run without a service worker; a worker of an earlier typedserver version that
is installed there stays in place.

```typescript
import { getServiceworkerClient } from '@api.global/typedserver/web_serviceworker_client';

const swClient = await getServiceworkerClient({
  autoReload: true, // default: reload the page when a new app version is served
});

swClient.isControlled; // false after a hard reload (Shift+Reload) until the next navigation
```

A page has one client: every later `getServiceworkerClient()` call returns the
same instance, and a call with different options fails. `await
swClient.destroy()` ends the client: it stops checking for updates, ignores new
app versions, leaves `globalThis.globalSw` and closes its message channel, so
its `actionManager` receives nothing from the worker any more, drops its status
subscribers and can no longer send requests; the next call creates a new
client.

### Request strategies

- **Navigations and other same-origin GET requests** are network-first: the
  worker answers from the network while it is reachable and falls back to the
  stored copy when it is not. A navigation also gets its stored copy when a
  proxy or CDN answers with a gateway failure (`502`, `503`, `504`, `520` to
  `526`, `530`), or when the server has not answered within
  `serviceWorker.navigationTimeoutMs` (default: 10 s); the late answer then
  refreshes the stored copy. Without a stored copy, and for `fetch()` calls,
  the server's answer reaches the page as it is. Visitors with a stored copy of
  a page therefore see that copy instead of a `503` maintenance page.
- **Static subresources** (scripts, styles, fonts, images, manifests, workers)
  of the same origin are cache-first when they are static responses of the
  app version the page runs (see Update model): files of `serveDir` and
  `bundledContent`, and route output passed through `markStaticResponse()`,
  all stamped with the app hash.
  Other route output, a per-user image for instance, comes from the network on
  every request. A response stamped with another app version, as a replica
  still on the previous version answers during a rolling deploy, passes through
  and is not stored.
- **Everything else goes to the network untouched**: non-GET requests,
  requests with `Range` or `Authorization` headers, `/api/` and socket.io
  paths, and requests to other origins. Hosts you list in
  `serviceWorker.cacheFirstHostnames` are the exception: their static
  subresources are cache-first too. They must answer CORS requests. They send
  no app hash, so the worker keeps their responses in the cache of the current
  app version without one.
- Only complete `200` responses are stored, as the server sent them: nothing
  partial, redirected, opaque, `Vary: *`, `no-store` or `private`. Error
  responses reach the page unchanged, apart from the gateway failures of a
  navigation with a stored copy. When the network fails and nothing is
  stored, the worker answers `503` with a plain-text body.

### Route output, per-user data and logout

Assets a route generates are dynamic unless the route marks them:

```typescript
server.addRoute('/assets/app.js', 'GET', async () => {
  return server.markStaticResponse(new Response(await buildBundle(), {
    headers: { 'Content-Type': 'text/javascript' },
  }));
});
```

`markStaticResponse()` sets `Cache-Control: no-cache` and the `appHash` stamp,
also over a `Cache-Control` the handler set; a response that is neither
successful nor a `304` stays dynamic. The worker keeps a marked response until
the app version changes, so give the server a new `appVersion` whenever such
output changes.

Navigations and other network-first requests keep a copy of every cacheable
response as their offline fallback. Responses for one user must say so: send
them `Cache-Control: private` or `no-store`, which the worker never stores
(with `noCache: true`, the `UtilityWebsiteServer` default, every dynamic
response is `no-store`). When a user logs out, drop what the worker stored:

```typescript
await swClient.actionManager.purgeServiceWorkerCache();
```

### Update model

Every app version gets its own cache, named `typedserver-sw-<appHash>`. The app
hash comes from the server's built-in `serviceworker_versionInfo` request: a
digest of the files in `serveDir`, the `bundledContent` and `appVersion`,
computed on start and after every change the file watcher sees. A server that
generates its assets per request should set `appVersion` to a new value on
every deploy and pass those assets through `markStaticResponse()`.

Every static response carries that hash in its `appHash` header
(`appHashHeaderName` in the interfaces): the files of `serveDir` and
`bundledContent`, the SPA fallback, pages with the injected live reload client
and `304` answers. Routes, built-in endpoints and errors carry none. The edge
worker keys its cache on the header. In surfaces mode, a surface's static
responses carry the hash of its bundled content.

The worker compares that hash with the one its caches belong to:

- when it activates, and whenever its connection to the server is established
  (a restored connection, e.g. after a server restart, is checked right away).
  A worker that installs only learns the version when none is known yet; it
  applies and announces nothing while the worker it replaces controls the
  pages;
- on navigations, at most once per `updateCheckIntervalMs` (default: 100 s). A
  navigation that finds a new version is answered only once that version has its
  fresh cache, so the page never loads assets of the version it replaces;
- when a page becomes visible again, at most once a minute per page and once
  per `updateCheckIntervalMs`; the client then also checks for a new worker
  script;
- right away when a navigation is answered with a page stamped with another
  app hash, a deploy the worker has not seen yet; at most once per 10 s.

The version request also reports since when the server serves its hash
(`servedSince`, `TypedServer#servedSince`). The worker moves to a different
hash only when it is served since later than the one its caches belong to, so
during a rolling deploy a replica still on the previous version does not move
it back; the worker logs such an answer and changes nothing. A rollback starts
new replicas, so it counts as a new version.

Failed checks back off, doubling the interval up to an hour. The worker keeps
the time of its last check and the count of failed ones in its state store, so
the interval and the backoff also hold after the browser stopped an idle
worker; a newly activated worker checks right away. A trigger that comes while
a check runs joins it, and the pages hear of the version that check applies
once. The worker being replaced and its successor may check at the same time:
they compare the server's answer with the stored version and store it one at a
time, under a Web Lock and never while waiting for the server, so the instance
that stores a new version applies and announces it, and the other finds it
stored and announces nothing.

Every page runs the app version its document came with: the version stamped
on a static page, or the current one for a page a route generates. Its
scripts, styles and other requests use the cache of that version, so a page of
a deploy the worker has not applied yet gets that deploy's assets from the
network, never the previous version's from the cache.

A new version gets a fresh cache and retires the cache of the previous one;
caches the app itself created on the origin are never touched. Then the worker
posts a `newVersion` message to every page that runs another version, or one
it has no record of, and each page running the client reloads once;
TypedServer serves its HTML documents `no-cache`, so the reload gets the new
page from the server, not from the browser's HTTP cache. The first navigation
that found the new version, or joined the check that did, sends the message: it
waits for the page it creates, which runs that version and is not told. A page
that fired `pagehide` does not reload itself over the navigation that replaces
it; restored from the back/forward cache, it asks the worker, which answers a
page of an earlier version with the message it missed. With
`autoReload: false` the page reports an `update-available` status instead and
the app decides when to reload:

```typescript
const swClient = await getServiceworkerClient({ autoReload: false });
swClient.actionManager.subscribeToStatusUpdates((statusArg) => {
  if (statusArg.type === 'update-available') {
    // swClient.availableUpdate holds { appHash, appSemVer?, servedSince? } of the served version.
    showUpdateBanner(() => window.location.reload());
  }
});

// Ask for a check yourself, e.g. after a long idle period:
await swClient.checkForUpdate();
```

With `autoReload` on, a page can defer the reload while it has work that must
not be lost. While a hold is open, a new version sets `availableUpdate` and
reports `update-available` instead; the page reloads once the last hold is
released:

```typescript
const releaseReload = swClient.holdReload();
try {
  await saveDraft();
} finally {
  releaseReload(); // reloads now if a new version arrived meanwhile
}
```

Until it reloads, such a page keeps running its version: it gets the assets
the worker stored for that version from the retired cache, which is read-only.
What the retired cache lacks, such as a chunk no page of that version loaded,
comes from the network, that is from the new version; give chunks hashed names,
or reload on `update-available`. The worker keeps the caches of at most two
retired versions that open pages run, and deletes a retired cache once no open
page runs it: on its activation, after a new version, and after navigations,
at most once a minute. Both instances of the worker during an update work on
the same caches and the same records of the pages: a cache is opened to store a
response under a shared Web Lock and caches are deleted under an exclusive one,
every change to the records is made to the stored map under a lock of its own,
and all of them decide with the version in the worker's state store, not the
one an instance holds. So the worker being replaced neither creates a cache
again that its successor deleted, nor deletes the cache of the version its
successor applied, nor drops the pages its successor recorded.

During development, `server.reload()` and the file watcher make the worker drop
its caches and the pages reload. The file watcher coalesces changes that
follow each other within 200 ms, so a bundler that writes several files reloads
the pages once, and it reloads nothing when the changes leave the app hash as
it was, such as a file rewritten with its content. With `injectReload` the injected live reload
client reloads the pages, after development changes and after restarts alike;
the worker then only renews its caches and never asks pages to reload, so
`autoReload` has no effect. A page that carries the live reload client also
ignores the messages of a worker that was installed without it, for example
from a server that ran without `injectReload` before. Live reload is a
development feature: leave `injectReload` off in production.
`UtilityWebsiteServer` turns `injectReload` and `watch` on only while it runs
under `@git.zone/tswatch` (see UtilityWebsiteServer).

### Configuration

The server puts the worker's configuration in front of the worker script, so a
changed configuration is a new worker version to the browser:

```typescript
const server = new TypedServer({
  serveDir: './dist',
  cors: true,
  appVersion: '2.3.0',
  serviceWorker: {
    // Other origins whose static assets are served cache-first (exact hostnames).
    cacheFirstHostnames: ['fonts.googleapis.com', 'fonts.gstatic.com'],
    // Least time between two version checks on navigations (default: 100000 ms).
    updateCheckIntervalMs: 100_000,
    // Longest wait of a navigation before the stored page answers (default: 10000 ms; 0 waits).
    navigationTimeoutMs: 10_000,
  },
});
```

The worker keeps its state in the IndexedDB databases `typedserver-sw` and
`typedserver-sw-diagnostics`. On its first activation it removes what the worker
of typedserver 11 and earlier stored: the `losslessServiceworker` and
`losslessServiceworkerPersistent` databases and, on origins that had them, the
`runtime` cache.

### TypedRequest Diagnostics

The service worker dashboard records TypedRequest metadata only: `method`, `correlationId`, `direction`, `phase`, `timestamp`, optional `durationMs`, `hasError`, and `payloadRedacted: true`. Request and response payloads and error text are discarded before BroadcastChannel transport and are never stored, searched, displayed, or copied by the dashboard.

Dashboard contract version 2 also normalizes entries received from cached 8.10 clients or workers into the metadata-only shape. Existing 8.10 page and service-worker code can still expose raw traffic until the controlling worker is replaced and open pages are reloaded, so deployments should complete normal service-worker activation and client refresh before treating the old capture path as retired.

## 📦 Bundled Content

Serve pre-bundled content directly from memory — useful for single-binary deployments or embedding assets in server-side code:

```typescript
import { TypedServer } from '@api.global/typedserver';

const server = new TypedServer({
  cors: true,
  bundledContent: [
    {
      path: '/index.html',
      contentBase64: Buffer.from('<html><body>Hello!</body></html>').toString('base64'),
    },
    {
      path: '/app.js',
      contentBase64: Buffer.from('console.log("loaded")').toString('base64'),
    },
  ],
  spaFallback: true,
});

await server.start();
```

Bundled content takes priority over filesystem serving and supports ETag-based conditional requests. Like the files of `serveDir`, every bundled file is served `Cache-Control: no-cache` and revalidated against its ETag (see the caching policy above).

## 🛡️ Security Headers

Configure comprehensive security headers including CSP, HSTS, and more:

```typescript
import { TypedServer } from '@api.global/typedserver';

const server = new TypedServer({
  serveDir: './dist',
  cors: true,

  securityHeaders: {
    // Content Security Policy
    csp: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "'unsafe-inline'", 'https://cdn.example.com'],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", 'data:', 'https:'],
      connectSrc: ["'self'", 'wss:', 'https://api.example.com'],
      fontSrc: ["'self'", 'https://fonts.gstatic.com'],
      frameAncestors: ["'none'"],
      upgradeInsecureRequests: true,
    },

    // HSTS (HTTP Strict Transport Security)
    hstsMaxAge: 31536000,         // 1 year
    hstsIncludeSubDomains: true,
    hstsPreload: true,

    // Other security headers
    xFrameOptions: 'DENY',
    xContentTypeOptions: true,
    xXssProtection: true,
    referrerPolicy: 'strict-origin-when-cross-origin',

    // Cross-Origin policies
    crossOriginOpenerPolicy: 'same-origin',
    crossOriginEmbedderPolicy: 'require-corp',
    crossOriginResourcePolicy: 'same-origin',

    // Permissions Policy
    permissionsPolicy: {
      camera: [],
      microphone: [],
      geolocation: ['self'],
    },
  },
});

await server.start();
```

### Security Headers Reference

| Header | Option | Description |
|--------|--------|-------------|
| `Content-Security-Policy` | `csp` | Controls resources the browser can load |
| `Strict-Transport-Security` | `hstsMaxAge`, `hstsIncludeSubDomains`, `hstsPreload` | Forces HTTPS connections |
| `X-Frame-Options` | `xFrameOptions` | Prevents clickjacking attacks |
| `X-Content-Type-Options` | `xContentTypeOptions` | Prevents MIME-sniffing |
| `X-XSS-Protection` | `xXssProtection` | Legacy XSS filter |
| `Referrer-Policy` | `referrerPolicy` | Controls referrer information |
| `Permissions-Policy` | `permissionsPolicy` | Controls browser features |
| `Cross-Origin-Opener-Policy` | `crossOriginOpenerPolicy` | Isolates browsing context |
| `Cross-Origin-Embedder-Policy` | `crossOriginEmbedderPolicy` | Controls cross-origin embedding |
| `Cross-Origin-Resource-Policy` | `crossOriginResourcePolicy` | Controls cross-origin resource sharing |

## 🗜️ Compression

TypedServer supports automatic response compression using Brotli and Gzip. Compression is powered by smartserve and enabled by default.

### Configuration

```typescript
import { TypedServer } from '@api.global/typedserver';

const server = new TypedServer({
  serveDir: './dist',
  cors: true,

  // Enable with defaults (brotli + gzip, threshold: 1024 bytes)
  compression: true,

  // Or disable completely
  // compression: false,

  // Or configure in detail
  // compression: {
  //   enabled: true,
  //   algorithms: ['br', 'gzip'],    // Preferred order
  //   threshold: 1024,               // Min size to compress (bytes)
  //   level: 4,                      // Compression level (1-11 for brotli, 1-9 for gzip)
  //   exclude: ['/api/stream/*'],    // Skip these paths
  // },
});
```

### Compression Options Reference

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `enabled` | `boolean` | `true` | Enable/disable compression |
| `algorithms` | `string[]` | `['br', 'gzip']` | Preferred algorithms in order |
| `threshold` | `number` | `1024` | Minimum response size (bytes) to compress |
| `level` | `number` | `4` | Compression level (1-11 for brotli, 1-9 for gzip) |
| `compressibleTypes` | `string[]` | auto | MIME types to compress |
| `exclude` | `string[]` | `[]` | Path patterns to skip |

### Per-Route Compression

A controller route's smartserve `@Compress` and `@NoCompress` decorators override these settings for
its responses, as in smartserve's own dispatch: a `@NoCompress` route answers uncompressed, and a
`@Compress({ level })` route is compressed at its level, also when `compression` is `false`. Every
other response (static files, bundled content, built-in endpoints, errors) follows the server's
settings, and a response that already carries a `Content-Encoding` is never compressed again.

```typescript
import * as smartserve from '@push.rocks/smartserve';

@smartserve.Route('/exports')
class ExportController {
  @smartserve.Get('/report')
  @smartserve.Compress({ level: 9 })
  async getReport(): Promise<string> {
    return buildLargeReport();
  }

  @smartserve.Get('/archive')
  @smartserve.NoCompress()
  async getArchive(): Promise<Response> {
    return new Response(await readZipArchive(), {
      headers: { 'Content-Type': 'application/zip' },
    });
  }
}

server.registerController(new ExportController());
```

## 📋 Configuration Reference

### IServerOptions

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `surfaces` | `ITypedServerSurface[]` | — | Enable isolated named host/path surfaces on one listener |
| `authorityValidation` | `'legacy' \| 'strict'` | `'legacy'` | Validate one canonical Host authority; surface mode is always strict |
| `serveDir` | `string` | — | Directory to serve static files from |
| `bundledContent` | `IBundledContentItem[]` | — | Base64-encoded files to serve from memory |
| `port` | `number \| string` | `3000` | Port to listen on; numeric `0` requests an OS-assigned port |
| `cors` | `boolean` | `true` | Enable CORS headers |
| `watch` | `boolean` | `false` | Development option: watch `serveDir`; changes that follow each other within 200 ms update the app hash once and reload the pages when it changed; a watcher that cannot start fails `start()`; rejected in surface mode, where each surface carries its own `watch` |
| `injectReload` | `boolean` | `false` | Development option: inject live reload script into HTML; rejected in surface mode, where each surface carries its own `injectReload` |
| `noCache` | `boolean` | `false` | `no-store` for every response except files of `serveDir`/`bundledContent` (always `no-cache`) and an `addRoute()` Response that explicitly owns `Cache-Control` |
| `forceSsl` | `boolean` | `false` | Redirect HTTP to HTTPS |
| `spaFallback` | `boolean` | `false` | Serve index.html for non-file routes |
| `sitemap` | `boolean` | `false` | Generate sitemap at `/sitemap` |
| `feed` | `boolean` | `false` | Generate RSS feed at `/feed` |
| `robots` | `boolean` | `false` | Serve robots.txt |
| `domain` | `string` | — | Domain name for sitemap/feeds |
| `appVersion` | `string` | — | Application version: served at `/appversion`, reported to service workers and part of the app hash |
| `serviceWorker` | `IServiceWorkerOptions` | — | Service worker configuration: `cacheFirstHostnames`, `updateCheckIntervalMs`, `navigationTimeoutMs`; rejected in surface mode |
| `manifest` | `object` | — | Web App Manifest configuration |
| `publicKey` | `string` | — | PEM encoded TLS certificate chain; requires `privateKey` |
| `privateKey` | `string` | — | PEM encoded TLS private key; requires `publicKey` |
| `clientTagPolicy` | `ITypedSocketClientTagPolicy` | deny all | Exact policy for client-owned TypedSocket tags |
| `virtualStreamAuthorizationAdapter` | `IVirtualStreamAuthorizationAdapter` | — | Bind and revalidate application authority for server-created VirtualStreams |
| `receiverAcceptanceTimeoutMs` | `number` | `30000` | Local incoming-stream receipt deadline after delivery, in milliseconds (`1..3,600,000`); sender acceptance and byte-transfer progress deadlines are independent |
| `listenHostname` | `string` | `'0.0.0.0'` | Interface address the listener binds to; pass `'127.0.0.1'` for a loopback-only server |
| `defaultAnswer` | `function` | — | Custom default response handler |
| `feedMetadata` | `object` | — | RSS feed metadata options |
| `blockWaybackMachine` | `boolean` | `false` | Block Wayback Machine archiving |
| `securityHeaders` | `ISecurityHeaders` | — | Security headers configuration |
| `compression` | `ICompressionConfig \| boolean` | `true` | Response compression configuration |
| `connectionTimeout` | `number` | — | Node.js socket inactivity timeout in milliseconds; must be an integer in `1..2147483647` |
| `headersTimeout` | `number` | — | Node.js deadline for receiving complete HTTP headers in milliseconds; must be an integer in `1..2147483647` |
| `requestTimeout` | `number` | — | Node.js deadline for receiving a complete HTTP request in milliseconds; must be an integer in `1..2147483647` |
| `cleanupTimeoutMs` | `number` | `5000` | Maximum time allowed for each owned cleanup attempt during `stop()` in milliseconds; must be an integer in `1..2147483647` |
| `typedRequestMaxBodyBytes` | `number` | — | Reject native HTTP TypedRequest POST bodies larger than this byte count before admission or decoding; must be an integer in `1..2147483647` |
| `requestMaxBodyBytes` | `number \| TRequestBodyLimitSelector` | — | Bound all native HTTP bodies before admission/parsing, or select a byte limit from URL/method/copied headers; stricter than any larger TypedRequest limit, with integers in `1..2147483647` and invalid selectors failing closed |
| `requestAdmission` | `(context) => boolean \| Response \| void` | — | Admit or reject an HTTP request before CORS, routing, health checks, and static content |
| `websocketAdmission` | `(context) => boolean \| Response \| void` | — | Admit or reject a WebSocket request before the protocol upgrade |
| `websocketMaxPayloadBytes` | `number` | — | Reject oversized WebSocket messages before TypedRouter parsing or handler dispatch; valid values are `1..2147483647` |
| `typedRequestAdmission` | `(request, context) => boolean \| Response \| void` | — | Admit or reject a decoded HTTP TypedRequest before routing |

HTTP timeout options are supported by the Node.js adapter. SmartServe rejects
configured HTTP timeouts at startup under Bun and Deno instead of silently
running without the requested deadlines.

After `start()` completes, `server.listeningPort` exposes the actual bound port.
It is `undefined` before start, while stopping, after stop, and after failed
startup. This is useful for isolated tests that configure numeric `port: 0`.

When `publicKey` and `privateKey` are configured, TypedServer terminates HTTPS
and WSS directly on its single listener. Both values are required and parsed
during construction, before controllers, transports, watchers, or a listener
are created.

### Request Admission

Admission callbacks guard a legacy single-router server by host, path, origin,
or TypedRequest method:

```typescript
const server = new TypedServer({
  websocketMaxPayloadBytes: 64 * 1024,
  typedRequestMaxBodyBytes: 256 * 1024,
  requestAdmission: (context) => {
    const host = new URL(context.request.url).hostname;
    return host === 'api.example.com';
  },
  websocketAdmission: (context) => {
    return context.request.headers.get('origin') === 'https://app.example.com';
  },
  typedRequestAdmission: (request, context) => {
    const host = new URL(context.request.url).hostname;
    return host !== 'login.example.com' || request.method === 'login';
  },
});
```

Return `false` for a `403` rejection, a `Response` for a custom rejection, or
`true`/`void` to continue. Rejected requests retain configured security headers
without receiving CORS headers. HTTP TypedRequest handlers receive the
server-owned request context as `tools.localData.requestContext`; caller
`localData` cannot replace it.

When `typedRequestMaxBodyBytes` is configured, TypedServer counts the actual
streamed bytes for the native `POST /typedrequest` endpoint before request or
TypedRequest admission runs. Oversized bodies are drained and answered with a
JSON `413` without invoking admission callbacks, custom handlers, or the
TypedRouter. Unreadable bodies receive the same JSON `400` class as invalid
request decoding. Configure `requestTimeout` separately when uploads also need
a duration deadline; the byte ceiling is not a time limit.

### Named Host and Path Surfaces

Use `surfaces` when different hostnames or path trees must expose different
backend routers. Surface selection uses exact canonical hostnames and
boundary-aware path prefixes. The longest matching prefix wins. HTTP and
WebSocket routers remain separate, the selected surface is bound once per
request or connection, and unknown hosts and paths return `404` before CORS.

```typescript
import { TypedServer } from '@api.global/typedserver';
import { TypedRouter } from '@api.global/typedrequest';

const publicHttp = new TypedRouter();
const publicSocket = new TypedRouter();
const adminHttp = new TypedRouter();
const adminSocket = new TypedRouter();
const publicBundle = [];

const server = new TypedServer({
  cors: false,
  surfaces: [
    {
      name: 'public',
      match: {
        hostnames: ['freelance.club', 'localhost'],
        websocketPathPrefixes: ['/socket'],
      },
      httpTypedRouter: publicHttp,
      websocketTypedRouter: publicSocket,
      cors: true,
      bundledContent: publicBundle,
      spaFallback: true,
    },
    {
      name: 'superadmin',
      match: {
        hostnames: ['superadmin.freelance.club', 'superadmin.localhost'],
        websocketPathPrefixes: ['/socket'],
      },
      httpTypedRouter: adminHttp,
      websocketTypedRouter: adminSocket,
      typedRequestMaxBodyBytes: 128 * 1024,
      cors: false,
      securityHeaders: { xFrameOptions: 'DENY' },
      requestAdmission: async (context) => {
        return await admitAdminOriginAndSession(context);
      },
    },
  ],
});

await server.start();
```

Each surface may configure its own `typedRequestPath`,
`typedRequestMaxBodyBytes`, `httpHandler`,
admission callbacks, response policy, static/bundled content, SPA fallback,
development live reload, and health endpoint. `IRequestContext.state` is shared across global
admission, surface admission, TypedRequest admission, and the selected
handler. An omitted surface `typedRequestMaxBodyBytes` inherits the top-level
value; a surface value overrides it. Exact host/path surface selection happens
first; an unknown match returns `404` before admission callbacks and CORS. For
HTTP, top-level
`requestAdmission` runs before surface `requestAdmission`. For decoded HTTP
RPC, top-level `typedRequestAdmission` runs before surface
`typedRequestAdmission`. For WebSockets, top-level `websocketAdmission` runs
before surface `websocketAdmission`, then the selected router is bound for the
peer's lifetime. Built-in development RPC methods are excluded by default; set
`includeBuiltinTypedHandlers: true` only on a surface that needs them.

After a surface WebSocket completes TypedSocket's exact-major handshake, the
connection receives the protected server-owned tag
`typedserver_surface:<surface-name>`. The tag is not discoverable before
TypedSocket publishes connection readiness, and clients cannot assign, replace,
or remove it.

Surface mode offers neither `registerController()` nor `addRoute()`. Supply an
instance-local `httpHandler` instead. Top-level legacy content options are
rejected in surface mode so content cannot be exposed on the wrong hostname
accidentally. Omitting `surfaces` preserves the legacy single-router,
decorated-controller, and `addRoute()` behavior.

#### Per-surface development live reload

`injectReload` and `watch` are per-surface development options. The top-level
options of the same name stay rejected in surface mode and their error points
at the surface fields. Both default to off, both must be configured explicitly,
and neither may be enabled in production.

```typescript
const server = new TypedServer({
  cors: false,
  surfaces: [
    {
      name: 'app',
      match: { hostnames: ['localhost', '127.0.0.1'] },
      httpTypedRouter: appHttp,
      websocketTypedRouter: appSocket,
      serveDir: './dist_bundle',
      spaFallback: true,
      // development only, e.g. driven by an explicit CLI flag or app option
      injectReload: developmentMode,
      watch: developmentMode,
    },
  ],
});
```

| Surface option | Type | Default | Description |
|--------|------|---------|-------------|
| `injectReload` | `boolean` | `false` | Inject this surface's live reload client into its served HTML and expose the surface-local dev tools endpoints. Requires `serveDir` or `bundledContent` |
| `watch` | `boolean` | `false` | Watch this surface's `serveDir` and notify this surface's reload clients after a debounced change. Requires `serveDir` |

What an opted-in surface does:

- Injected HTML receives one external module script below `<head>`:
  `<script async defer type="module" src="<prefix>/typedserver/devtools"
  data-typedserver-last-reload="…" data-typedserver-reload-check="…"
  data-typedserver-socket-path="…"></script>`. No inline script is injected,
  and TypedServer never edits a surface's security headers or CSP.
- The dev tools endpoints `GET <prefix>/typedserver/devtools` and
  `POST <prefix>/typedserver/reloadcheck` are served by the surface itself,
  below the surface's own most general path prefix, so they always resolve back
  to that surface, pass its hostname match, and run after its admission
  callbacks. A configuration in which another surface would shadow those exact
  paths is rejected during construction.
- `injectReload` implies the built-in TypedRequest handlers for that surface
  (as `includeBuiltinTypedHandlers` does), because the reload client registers
  itself over the surface's own WebSocket router. Concretely, the surface's
  `httpTypedRouter` and `websocketTypedRouter` additionally answer
  `registerTypedServerConnection`, `getLatestServerChangeTime`, and
  `serviceworker_speedtest` (whose chunk size is bounded to 1024 KB) while the
  option is on — a development-only
  exposure on exactly that surface, nowhere else. `getLatestServerChangeTime`
  answers with the requesting surface's own reload time, so a reconnect never
  reports another surface's or the server's construction time. Without a
  `websocketTypedRouter` the client falls back to polling the reload check
  endpoint.
- `watch` starts one watcher for the surface's `serveDir` after the listener is
  bound; a watcher that cannot start fails `start()`. Files and directories
  that are added, changed or deleted are coalesced for 200 ms, so a bundler
  that rewrites several files produces exactly one notification. Only connections tagged for
  that exact surface are notified; other surfaces are untouched. Call
  `server.reloadSurface('<surface-name>')` to trigger the same notification
  manually; it throws for an unknown surface and for a surface that did not set
  `injectReload`.
- A surface without `injectReload` serves identical HTML to before, answers
  `404` for the dev tools paths, cannot be reloaded, and is never notified.
- Watchers, dev tools controllers, debounce timers, and in-flight notifications
  are owned by the server and released by `stop()`, which also drops every
  watch runtime whose clean-up succeeded; an entry whose clean-up failed is
  kept for the next `stop()`.

Content Security Policy: the injected script is same-origin and
attribute-configured, so `script-src 'self'` is sufficient and no
`'unsafe-inline'` is required. `connect-src 'self'` covers the reload check
request and, under CSP level 3, the same-origin WebSocket; a policy that lists
explicit hosts instead must also list the same-origin `ws:`/`wss:` endpoint,
otherwise the client falls back to polling the reload check endpoint. The dev
tools status pill renders in shadow DOM, so a browser without constructable
stylesheets additionally needs `style-src 'unsafe-inline'`. TypedServer never
edits a surface's CSP or any other security header to make live reload work.

`TypedServer` instances are single-use. `start()` rejects concurrent starts
and any restart after stop or failed startup. Successful stop and repeated
stops after completed cleanup are idempotent. `stop()` can cancel an in-progress
start while cleaning partially initialized transports, watchers, and owned
router composition. Owned cleanup attempts are initiated independently, so one
stalled component does not prevent transport cleanup from starting. If a
cleanup rejects or exceeds `cleanupTimeoutMs`, `stop()` rejects with an
`AggregateError`. Timed-out owner promises remain retained, so later `stop()`
calls await the same unresolved operations without starting duplicates;
rejected cleanups are retried.

## 🏗️ Package Exports

```
@api.global/typedserver
├── .                           — Main server (TypedServer)
├── /backend                    — Alias for main server
├── /infohtml                   — Info HTML page generator
├── /edgeworker                 — Cloudflare Workers edge computing
├── /web_inject                 — Live reload script injection
├── /web_serviceworker          — Service Worker implementation
└── /web_serviceworker_client   — Service Worker client utilities
```

## 🔄 Utility Servers

Pre-configured server templates with best practices built-in.

### UtilityWebsiteServer

Optimized for modern web applications with SPA support, the service worker and
live reload during development:

```typescript
import { utilityservers } from '@api.global/typedserver';

const websiteServer = new utilityservers.UtilityWebsiteServer({
  serveDir: './dist',
  domain: 'example.com',
  // Validate one canonical Host authority without restricting valid hostnames.
  authorityValidation: 'strict',
  // Optional strict exact-host allowlist. superadmin.localhost works in dev.
  exactHostnames: ['example.com', 'localhost'],

  // Optional direct HTTPS/WSS listener. The certificate must cover every host.
  publicKey: certificatePem,
  privateKey: privateKeyPem,

  // SPA fallback enabled by default
  spaFallback: true,   // default: true

  // Security headers
  securityHeaders: {
    csp: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "'unsafe-inline'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
    },
    xFrameOptions: 'SAMEORIGIN',
    xContentTypeOptions: true,
  },

  // Compression (enabled by default)
  compression: true,

  // Development: default on under tswatch, off otherwise (see below)
  injectReload: false, // inject the live reload client
  watch: false,        // watch serveDir, update the app hash and reload on changes

  // Other options
  cors: true,          // default: true
  noCache: true,       // default: true; dynamic responses are sent no-store
  forceSsl: false,     // default: false
  appSemVer: '1.0.0',  // reported to service workers as the app version
  port: 3000,          // default: 3000

  // Service worker configuration (see Service Worker)
  serviceWorker: { cacheFirstHostnames: ['fonts.gstatic.com'] },

  // Optional ads.txt entries (only served if configured)
  adsTxt: [
    'google.com, pub-1234567890, DIRECT, f08c47fec0942fa0',
  ],

  // RSS feed metadata
  feedMetadata: {
    title: 'My Blog',
    description: 'A cool blog',
    link: 'https://example.com',
  },

  // Add custom routes
  addCustomRoutes: async (typedserver) => {
    typedserver.addRoute('/api/custom', 'GET', async () => {
      return new Response('Custom route!');
    });
  },
});

await websiteServer.start();
```

Live reload and file watching are on by default while the server runs under
[`@git.zone/tswatch`](https://code.foss.global/git.zone/tswatch), which sets
`TSWATCH=true` in the environment of every process it starts. Everywhere else,
in production (`node cli.js`, containers) as in a development setup without
tswatch, both are off: the service worker then tells the pages about a new app
version, and the client's `autoReload` or `update-available` handle it (see
Update model). The app hash is computed on start, so a deploy is detected once
the server restarts. Outside tswatch, pass `injectReload: true` and
`watch: true` for live reload, or `watch: true` alone when the files of
`serveDir` are replaced while the server keeps running. An explicit option
always wins over the default.

`authorityValidation` forwards the underlying TypedServer authority contract.
Use `'strict'` when application admission needs to inspect any syntactically
valid hostname or IP authority while rejecting missing, duplicate, or malformed
Host authorities before application routing. `exactHostnames` accepts canonical
hostname-only values without ports. When present, it always enables strict
authority validation for both HTTP and WebSocket admission and requests for
non-allowlisted hosts return `404`. `publicKey`, `privateKey`, and
`clientTagPolicy` are forwarded unchanged to the underlying TypedServer, so
`localhost` and `superadmin.localhost` can share one direct TLS listener without
a reverse proxy. The exact-host allowlist remains the first admission gate when
`addCustomRoutes` installs application-specific HTTP or WebSocket admission
callbacks; those callbacks run only for an allowed host.

### UtilityServiceServer

Optimized for API services with auto-generated info page:

```typescript
import { utilityservers } from '@api.global/typedserver';

const serviceServer = new utilityservers.UtilityServiceServer({
  serviceName: 'My API',
  serviceVersion: '1.0.0',
  serviceDomain: 'api.example.com',
  port: 8080,

  // Add custom routes
  addCustomRoutes: async (typedserver) => {
    typedserver.addRoute('/api/status', 'GET', async () => {
      return new Response(JSON.stringify({ status: 'healthy' }), {
        headers: { 'Content-Type': 'application/json' },
      });
    });
  },
});

await serviceServer.start();
```

## 🧩 Architecture Overview

```
┌─────────────────────────────────────────────────────────────┐
│                      TypedServer                            │
├─────────────────────────────────────────────────────────────┤
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐ │
│  │ SmartServe  │  │ TypedRouter │  │    TypedSocket      │ │
│  │ (Routing)   │  │ (RPC)       │  │    (WebSocket)      │ │
│  └─────────────┘  └─────────────┘  └─────────────────────┘ │
│         │                │                    │             │
│         ▼                ▼                    ▼             │
│  ┌─────────────────────────────────────────────────────┐   │
│  │              Request Handler Pipeline               │   │
│  │  1. Routes & Controllers of this server             │   │
│  │  2. Bundled Content (in-memory)                     │   │
│  │  3. HTML Injection (live reload)                    │   │
│  │  4. Static File Serving (filesystem)                │   │
│  │  5. SPA Fallback                                    │   │
│  └─────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘
```

## License and Legal Information

This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.

**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

### Trademarks

This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.

Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.

### Company Information

Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany

For any legal inquiries or further information, please contact us via email at hello@task.vc.

By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

---
_Source: https://npm.io/package/@api.global/typedserver · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
