npm.io
13.0.2 • Published 9h ago

@api.global/typedserver

Licence
MIT
Version
13.0.2
Deps
31
Size
24.6 MB
Vulns
0
Weekly
0

@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/. 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/ 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

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

# Using npm
npm install @api.global/typedserver

Quick Start

Basic Server
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
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:

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:

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.

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:

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
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:

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:

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:

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.

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:

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:

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:

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:

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:

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:

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:

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
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.

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:

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.

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.

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:

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, 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:

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                                    │   │
│  └─────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘

This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the 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.

Keywords