@api.global/typedserver
@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/typedrequestand@api.global/typedsocket - Decorator Routing — Clean, expressive routing with
@Route,@Get,@Postdecorators 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,520to526,530), or when the server has not answered withinserviceWorker.navigationTimeoutMs(default: 10 s); the late answer then refreshes the stored copy. Without a stored copy, and forfetch()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 a503maintenance 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
serveDirandbundledContent, and route output passed throughmarkStaticResponse(), 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
RangeorAuthorizationheaders,/api/and socket.io paths, and requests to other origins. Hosts you list inserviceWorker.cacheFirstHostnamesare 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
200responses are stored, as the server sent them: nothing partial, redirected, opaque,Vary: *,no-storeorprivate. 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 answers503with 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/devtoolsandPOST <prefix>/typedserver/reloadcheckare 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. injectReloadimplies the built-in TypedRequest handlers for that surface (asincludeBuiltinTypedHandlersdoes), because the reload client registers itself over the surface's own WebSocket router. Concretely, the surface'shttpTypedRouterandwebsocketTypedRouteradditionally answerregisterTypedServerConnection,getLatestServerChangeTime, andserviceworker_speedtest(whose chunk size is bounded to 1024 KB) while the option is on — a development-only exposure on exactly that surface, nowhere else.getLatestServerChangeTimeanswers with the requesting surface's own reload time, so a reconnect never reports another surface's or the server's construction time. Without awebsocketTypedRouterthe client falls back to polling the reload check endpoint.watchstarts one watcher for the surface'sserveDirafter the listener is bound; a watcher that cannot start failsstart(). 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. Callserver.reloadSurface('<surface-name>')to trigger the same notification manually; it throws for an unknown surface and for a surface that did not setinjectReload.- A surface without
injectReloadserves identical HTML to before, answers404for 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 nextstop().
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 │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
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 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.