Skip to content

τjs Configuration

Complete reference for configuring τjs applications.

τjs uses a declarative configuration file (taujs.config.ts) where you define:

  • Server settings (host, port, HMR)
  • Applications and their entry points
  • Routes with rendering strategies
  • Security policies (CSP, authentication)
  • Data loading patterns

All configuration is validated at startup with helpful error messages.

taujs.config.ts
import { defineConfig } from "@taujs/server/config";
export default defineConfig({
server: {
host: "localhost",
port: 5173,
hmrPort: 5174,
},
apps: [
{
appId: "web",
entryPoint: "",
routes: [
{
path: "/",
attr: {
render: "ssr",
data: async () => ({ message: "Hello World" }),
},
},
],
},
],
});
type TaujsConfig = {
server?: ServerConfig;
security?: SecurityConfig;
introspection?: IntrospectionConfig; // Development only
apps: AppConfig[];
alias?: Record<string, string>;
vite?: TaujsViteOverride;
};
type ServerConfig = {
host?: string; // Default: 'localhost'
port?: number; // Default: 5173
hmrPort?: number; // Default: 5174
mountPrefix?: string; // Default: '' (root)
publicBasePath?: string; // Default: mountPrefix
hmrTransport?: 'fixed-port' | 'attached' | 'mediated'; // Default: 'fixed-port'
};
// Development only - the surface is structurally absent from a production build,
// so there is no `enabled` flag to turn it off.
type IntrospectionConfig = {
allowNonLoopback?: boolean; // Relaxes ONLY the remote-address check
allowedHosts?: string[]; // Extends ONLY the Host admission; exact DNS hostnames
redaction?: {
denyKeys?: string[]; // Extends the default denylist
replaceDefaultDenyKeys?: boolean;
};
};
type AppConfig = {
appId: string;
entryPoint: string;
plugins?: PluginOption[];
routes?: readonly { path: string; attr?: RouteAttributes }[];
};

The vite and alias fields are the declared Vite customisation surface - see Vite Configuration below for their types and merge behaviour.

Control where and how τjs runs.

export default defineConfig({
server: {
host: "localhost",
port: 5173,
hmrPort: 5174,
},
});

Values are resolved in this order (highest precedence first):

  1. CLI flags: --host, --port, --hmr-port
Terminal window
npm run dev -- --host 0.0.0.0 --port 3000
  1. Environment variables:

    • HOST or FASTIFY_ADDRESS
    • PORT or FASTIFY_PORT
    • HMR_PORT
  2. createServer({ port }): the programmatic option overrides config.server.port, and 0 requests an ephemeral port.

  3. Config object: server.* properties

  4. Defaults: { host: 'localhost', port: 5173, hmrPort: 5174 }

server: {
host: "localhost"; // Loopback only (not accessible from network)
host: "0.0.0.0"; // All interfaces (accessible from network)
}

CLI shorthand:

Terminal window
npm run dev -- --host # Automatically becomes 0.0.0.0

Non-root mounting: mountPrefix and publicBasePath

Section titled “Non-root mounting: mountPrefix and publicBasePath”

Two installation-level coordinates make a τjs installation deployable under a non-root prefix. They describe external addressing, not project layout:

  • mountPrefix - where Fastify RECEIVES the installation. One scope prefix under which every declared route (all apps), τjs static assets and the development introspection surface register. Default '' (root).
  • publicBasePath - what τjs EMITS in front of every URL it generates: asset, preload and CSS links, the bootstrap module URL and the development beacon. It also derives each app’s Vite base. Defaults to mountPrefix.

The two differ exactly when a reverse proxy STRIPS the public prefix before forwarding:

// Root (default) - no configuration needed; existing deployments are unchanged.
server: {}
// Prefix-preserving proxy: /app/x arrives as /app/x.
server: { mountPrefix: "/app" }
// Stripping proxy: browsers use /app/x, the proxy forwards /x.
server: { mountPrefix: "", publicBasePath: "/app" }

Validation. A coordinate is either '' or /segment(/segment)* - leading /, no trailing /, segments of URI-unreserved characters only ([A-Za-z0-9._~-]). Anything else is rejected at startup with a message naming the rule; values are never silently normalised. Declaring publicBasePath: "" alongside a non-empty mountPrefix (emit root-absolute while mounted) is rejected as unsupported.

Separation from entryPoint. entryPoint keeps its layout meaning: '' remains the canonical single-application root layout, and a named entry point remains a subordinate application directory. The deployment coordinates compose AROUND it - publicBasePath: "/app" with entryPoint: "admin" emits /app/admin/assets/.... Never substitute entryPoint: "app" for publicBasePath: "/app"; deployment does not change layout.

Routing boundary. /app and /app/ both reach the mounted root route. Deeper trailing-slash matching remains the Fastify owner’s policy - a caller-owned host keeps its own ignoreTrailingSlash choice, and the τjs-created default stays strict. On a τjs-created host the SPA fallback is confined to the mounted subtree; outside it the server answers an ordinary 404. Application routers and hard-coded links need their own base configuration, because τjs does not rewrite them.

What τjs does not do. Application-authored links (href values in your components) are never rewritten - τjs prefixes only the URLs it generates itself. Proxy rewrite configuration stays proxy-owned; τjs reads no forwarding headers (X-Forwarded-Prefix included).

Development. The shared dev Vite base follows publicBasePath, so dev module URLs and the HMR socket pathname compose with the prefix, and dev delegation is confined to the mounted subtree. Two adjacent concerns have their own declared surfaces: server.hmrTransport carries the HMR socket where a second port cannot be reached, and introspection.allowedHosts admits a proxy’s hostname for the /__taujs/* introspection endpoints.

How the development HMR WebSocket is carried. Development only - it has no effect on a build.

ValueBehaviour
'fixed-port' (default)HMR listens on its own dedicated port (hmrPort, default 5174).
'attached'HMR rides the application’s own HTTP server, so it flows wherever that channel flows.
'mediated'Your own listener offers τjs first refusal on each upgrade, so HMR rides whatever channel that listener is on.

The default is unchanged behaviour. Choose 'attached' when a second fixed port cannot be reached - a supervisor that virtualises worker binds, a firewall, or a proxy that forwards only one channel. The served client then derives its socket from the origin that served it, rather than from a hard-coded port.

export default defineConfig({
server: {
hmrTransport: 'attached',
},
});

Two rules to know:

  • It is never inferred. τjs does not detect its host or read the environment to decide; an attached transport is requested explicitly.
  • It requires a τjs-created host. If you pass your own Fastify instance to createServer, 'attached' is rejected at configuration time rather than τjs attaching to, or reordering listeners on, a server it does not own. hmrPort, HMR_PORT and --hmr-port stay accepted so an existing configuration can switch transport without being rewritten, but they do not affect the attached channel.

Running an attached channel behind a proxy

Section titled “Running an attached channel behind a proxy”

τjs adds no proxy machinery; carrying the channel through one - a real TCP path, prefix preservation and restart-watcher scope - is host configuration, held in one place at Running τjs Under a Supervisor or Behind a Gateway.

Requires a trusted development network. Proxies commonly drop Origin and rewrite Host, and Vite’s WebSocket admission depends on those headers - its host and token checks do not survive that rewriting. The protections that apply to a direct connection do not project through such a proxy. Use this on development networks you trust.

'mediated' is for a Fastify instance you supply to createServer (mode B). Rather than τjs attaching to a host it does not own, your own upgrade listener offers τjs first refusal on each upgrade through a returned capability, dev.hmr.tryHandleUpgrade. The whole developer experience is this:

import type { IncomingMessage } from 'node:http';
import type { Duplex } from 'node:stream';
const tau = await createServer({ fastify: app, config }); // config.server.hmrTransport = 'mediated'
const onUpgrade = (req: IncomingMessage, socket: Duplex, head: Buffer): void => {
if (tau.dev.hmr.tryHandleUpgrade(req, socket, head)) return;
socket.destroy(); // the application decides what happens to upgrades that are not τjs's
};
app.server.on('upgrade', onUpgrade);
app.addHook('onClose', async () => { app.server.off('upgrade', onUpgrade); });

One explicit transport choice, one typed capability, one caller-owned listener, one matching removal. tryHandleUpgrade returns true when the socket is τjs’s HMR channel - handed to Vite, do nothing more with it - and false when it is not, so your fallback branch stays authoritative. It never throws.

Ownership is the opposite of 'attached'.

hmrTransportRequiresRejected on
'attached'a τjs-created hosta host you supplied
'mediated'a host you supplieda τjs-created host - it needs no mediation, use 'attached'

Both rejections happen at configuration time, before Vite installs anything or τjs touches your root. An unknown value is rejected in every mode. Like the other transports, 'mediated' is accepted and inert in production - dev.hmr is always present so a shared configuration file boots cleanly there, and tryHandleUpgrade simply returns false.

If nothing ever offers τjs an upgrade, the page still serves and HMR silently does nothing - indistinguishable from a real failure unless τjs says so. The boot log names the transport and the obligation, and if the served client has loaded but no upgrade has reached τjs within a few seconds, a one-shot development warning names the exact fix:

hmr: mediated transport selected but no HMR upgrade has reached τjs - offer upgrades to
dev.hmr.tryHandleUpgrade from the server's 'upgrade' listener

It fires once, only in development, and is cancelled by a successful claim - an unrelated upgrade passing through does not silence it.

The WebSocket path itself must be prefix-preserved, exactly as for 'attached' - see Running τjs Under a Supervisor or Behind a Gateway for the contract. If your topology strips the prefix, the never-wired warning above will fire, correctly: no upgrade reached τjs because none could match.

Security is unchanged from 'attached'. τjs performs no admission checks of its own; Vite’s own host and token checks run exactly as they do for the attached transport, after the hand-off. The same trusted-development-network requirement applies - see the note above.

See also: combining HMR with another upgrade consumer if your application already has a WebSocket consumer of its own.

The development introspection surface (the /__taujs/* overlay endpoints and the hydration beacon) is guarded independently of page serving: requests must arrive from a loopback address, present a local Host and carry the per-boot token. Each opt-in below relaxes exactly the guard it names, and neither implies the other.

Extends the introspection Host admission with exact DNS hostnames, for development behind a reverse proxy that presents a non-localhost Host. Development only; without a declaration the behaviour stays localhost-only.

export default defineConfig({
introspection: {
allowedHosts: ['web.plt.local'],
},
});

Rules:

  • Exact hostnames only. No wildcards, IP literals, schemes, ports or paths - invalid entries are rejected at startup, in production too, so a shared configuration cannot hide a typo. Matching is case-insensitive and ignores the request port; subdomains are never implied. localhost, *.localhost and IP-literal hosts stay admitted without any declaration.
  • Declare the hostname as seen at the τjs hop. A rewriting proxy substitutes its own upstream name for the browser-facing one - behind a Platformatic gateway, for example, the arriving Host is the internal service name (web.plt.local), not the public host.
  • A rewriting proxy owns browser-facing host validation. Exact matching preserves the DNS-rebinding guard for direct connections, but τjs never sees the original hostname behind a rewriting proxy and reads no forwarding headers (Forwarded, X-Forwarded-Host). Ensure the proxy validates the browser-facing host, or use a trusted development network only - the boot summary repeats this whenever the list is non-empty.
  • Independent of allowNonLoopback. allowedHosts relaxes only the Host check; introspection.allowNonLoopback: true relaxes only the remote-address check. A same-host gateway needs only allowedHosts; a proxy on another machine needs both. The per-boot token is always required.

Undeclared hostnames keep answering 403 invalid_host; the first such refusal logs a warning naming this field, so a proxied topology fails visibly rather than silently.

What τjs withholds from the development introspection record written under node_modules/.taujs/ and served to the overlay. Redaction here is structural: τjs never scans values looking for things that resemble secrets, and each rule acts on the one record type that carries the data it filters. Episodes get URL hygiene; the log annex gets metadata-key dropping; observations (service edges) are structural records - service and method names, counts, timestamps and capped sample request ids - that carry neither query values nor metadata, so these two redaction rules have no observation fields to act on.

Two rules apply, and both are unconditional for the records they act on:

  • Query values are never captured, for any key. A URL is stored as its pathname, the surviving query key names, and a flag - never as a raw string. For /reset?token=abc&ref=x the record is { pathname: "/reset", queryKeys: ["ref"], queryValuesRedacted: true }. The value abc never enters the buffer, and token is absent from queryKeys because its key name matched the denylist.
  • Metadata fields are dropped by key name, along with the whole subtree beneath them. A matched key is not partially serialised; it is absent.
introspection: {
redaction: {
denyKeys: ['internalRef'], // extends the defaults
replaceDefaultDenyKeys: false, // true discards the defaults entirely
},
}

The matching rule, stated exactly. A key is dropped when its name, lowercased, contains any denylist entry as a substring. Default entries: password, token, secret, ssn, auth, cookie, session, key.

This deliberately over-redacts. Because key, auth and session are short, innocent fields disappear too - monkeyId and keyboardLayout match key, authorName matches auth, sessionCount matches session. If a legitimate field is missing from the overlay, this rule is why.

It is a conservative heuristic on key names, not secret detection. Substring matching intentionally favours over-redaction, but the default list is not exhaustive and values are never inspected. The denylist does not remove an unmatched metadata field, or secrets embedded directly in log or error messages. Avoid putting secrets in introspection-visible metadata or messages, and add project-specific deny keys where needed.

If the defaults do not suit you, take ownership of the list. denyKeys adds entries; replaceDefaultDenyKeys: true discards τjs’s defaults so only your entries apply - including the defaults that protect, so choose the replacement list deliberately.

The whole surface is development-only. In production the introspection module is never loaded, nothing is written, and there is nothing to redact.

Define frontend applications with their entry points and routes.

apps: [
{
appId: "web",
entryPoint: "", // canonical root layout: the app lives at the client root
routes: [
/* ... */
],
plugins: [
/* optional Vite plugins */
],
},
{
appId: "admin",
entryPoint: "admin", // subordinate application directory and build namespace
routes: [
/* ... */
],
},
];
PropertyTypeRequiredDescription
appIdstringYesUnique identifier for this app
entryPointstringYes'' for the canonical root layout, or a directory under the client root
routesreadonly route definitionsNoSee Route Configuration
pluginsPluginOption[]NoVite plugins for this app

Any standard Vite plugin is accepted in plugins - the τjs renderer plugins are the scaffolded defaults, not a closed set. Scope differs by mode: at build time each app is built with exactly its own list; in development τjs runs one shared Vite dev server, so all apps’ plugin lists are composed into one list and duplicate plugin names are dropped (first occurrence wins, and every collision is reported at warn level - see Plugin composition).

apps[].plugins is one of three declared plugin channels; the top-level vite field and the taujsBuild({ vite }) escape hatch are the other two. τjs never reads a vite.config.* - if one sits where Vite used to probe for it, τjs emits a migration warning naming the file and pointing at these channels (see Vite Configuration).

Every app route path is registered as a real Fastify GET route. Fastify selects the route and decodes its parameters; τjs then applies the declared auth, CSP, data, render and episode contract. There is no second τjs route matcher.

Configure HTTP routing on the Fastify instance passed to createServer:

const fastify = Fastify({
routerOptions: {
caseSensitive: false,
ignoreTrailingSlash: true,
},
});
await createServer({ config, serviceRegistry, fastify });

Those native options govern τjs routes and host-owned Fastify routes alike. Use Fastify for case sensitivity, trailing or duplicate slash handling, bad-URL policy, parameter limits and other router behaviour; τjs does not mirror those settings in taujs.config.ts. See the Fastify server router options and route reference.

This is a deliberate product boundary: the server package is Node/Fastify-native. The renderer packages retain their standalone uses, but the integrated server does not carry a second runtime-neutral HTTP or router abstraction.

Route paths therefore use Fastify route syntax, not regular expressions. An exact path may be declared by only one τjs app. Ordinary Fastify routes can coexist beside τjs page routes.

Who answers an unmatched URL depends on who owns the host, including in the example above, since supplying an instance is itself an ownership decision:

  • τjs created the Fastify instance - unmatched document URLs continue through the τjs SPA fallback document.
  • You supplied the Fastify instance - your own not-found policy owns unmatched URLs, unless you declare an explicit τjs terminal wildcard page (path: '/*').

See Host Ownership for the full split and the embedded-host migration notes.

When migrating an existing config, replace path-to-regexp-only forms such as /app{/:feature}{/:id}, /app/:page* and /docs/*slug. τjs rejects these known stale forms at startup rather than allowing Fastify to register a literal or differently behaving route. Use a terminal Fastify wildcard such as /app/*, a terminal optional parameter such as /products/:id?, or explicit routes. /app/* owns paths below /app/; declare /app separately when the bare prefix must render too.

Route ownership is now exact. Route-level auth and CSP apply only after Fastify selects that τjs route; host-owned routes and unmatched case variants do not inherit policy merely because their URL resembles a τjs declaration. Conversely, once a declared parameter route is selected, dotted values such as /products/logo.png are valid parameter values. Asset-like URLs still 404 when no page or static route owns them.

Each entryPoint directory must contain:

client/{entryPoint}/
├── index.html # HTML template
├── entry-client.tsx # Client hydration entry
└── entry-server.tsx # SSR render entry

With entryPoint: "" (the canonical root layout) these files live directly in the client root.

τjs owns the Vite topology - roots, inputs, output directories, manifests, aliases, and the single shared development server - and exposes the fields it does not own through two declared channels in taujs.config.ts:

  • alias - the declarative home for path aliases, applied identically in dev and build.
  • vite - an allowlisted Vite override (TaujsViteOverride), applied symmetrically to the shared dev server and to every per-app build.

A third channel, the taujsBuild({ vite }) option, remains as a build-only escape hatch (see the build guide).

τjs never reads a vite.config.*. Both the dev server and every build pin configFile: false, so Vite never probes for one. If a vite.config.* sits where Vite used to discover it (the shared client base root in dev, each per-app entry root in build), τjs emits a migration warning naming the file, stating that it is not loaded, and pointing at the vite / alias fields. A project-root vite.config.* was never read and is not warned about. This is not a limitation of the ecosystem - the vite field is your Vite configuration, with a topology-aware home (see Reusing Vite fragments).

type TaujsViteOverride = TaujsViteConfig | ((ctx: TaujsViteContext) => TaujsViteConfig);
type TaujsConfig = CoreTaujsConfig & {
// ...
alias?: Record<string, string>;
vite?: TaujsViteOverride;
};

vite is either a static TaujsViteConfig object or a function of a discriminated serve/build context. The type is an explicit allowlist - only the supported fields appear, so the editor refuses a protected field up front rather than the merge dropping it silently.

type TaujsViteConfig = {
// Appended to the framework plugin list (append + dedupe by name).
plugins?: PluginOption[];
// Shallow-merged with the framework defines.
define?: Record<string, unknown>;
// Per-engine deep merge; only preprocessorOptions is admitted.
css?: {
preprocessorOptions?: CSSOptions["preprocessorOptions"];
};
// Dev-only (see below); never reaches build configs.
optimizeDeps?: TaujsOptimizeDeps;
esbuild?: ESBuildOptions | false;
logLevel?: LogLevel;
// resolve subset - alias is intentionally excluded (use the top-level alias field).
resolve?: ResolveOptions;
// Dev-only. An allowlist of one: everything else under Vite's `server` is either
// framework-owned or meaningless in middleware mode. See the matrix below.
server?: Pick<ServerOptions, "allowedHosts">;
// Build-tuning subset - the framework owns everything else under build.
build?: {
sourcemap?: BuildOptions["sourcemap"];
minify?: BuildOptions["minify"];
terserOptions?: BuildOptions["terserOptions"];
rollupOptions?: {
external?: Rollup.ExternalOption;
output?: {
/** @deprecated Vite 8/Rolldown does not support the object form - FUNCTION form only. */
manualChunks?: Rollup.OutputOptions["manualChunks"];
};
};
// Canonical Vite 8 chunking, replacing the deprecated manualChunks above. Declaring both
// is rejected rather than silently resolved.
rolldownOptions?: {
output?: {
codeSplitting?: Rolldown.OutputOptions["codeSplitting"];
};
};
};
};

The function form receives a discriminated context. Dev invokes it once with the serve arm (there is no appId - the shared dev server is not per-app); build invokes it per app with the build arm:

type TaujsViteContext =
| {
command: "serve";
mode: string;
isSSRBuild: false;
appId?: never;
entryPoint?: never;
clientRoot: string;
}
| {
command: "build";
mode: string;
isSSRBuild: boolean;
appId: string;
entryPoint: string;
clientRoot: string;
};
taujs.config.ts
export default defineConfig({
vite: {
define: { __APP_VERSION__: JSON.stringify(version) },
plugins: [visualizer()],
},
apps: [{ appId: "main", entryPoint: "", plugins: [pluginVue()] }],
});
// Function form - branch on the serve/build context.
export default defineConfig({
vite: (ctx) => ({
// A visualiser only makes sense for client builds.
plugins: ctx.command === "build" && !ctx.isSSRBuild ? [visualizer()] : [],
}),
apps: [/* ... */],
});

optimizeDeps tunes Vite’s dependency pre-bundling on the shared dev server. It is development-only - nothing from it reaches a client or SSR build. τjs admits a subset:

type TaujsOptimizeDeps = Pick<
DepOptimizationOptions,
"include" | "exclude" | "esbuildOptions"
>;
  • include forces a dependency into pre-bundling, exclude keeps an incompatible one out, and esbuildOptions accommodates dependency transforms, loaders, and esbuild plugins.
  • include and exclude are deduplicated. The same package appearing in both is a config-validation error - it cannot be force-included and excluded at once.
  • The remaining Vite optimiser fields (entries, noDiscovery, force, and the experimental set) are deliberately withheld: τjs retains authority over how the shared development application graph is discovered.

alias is the declarative home for path aliases - the field the previous docs described but that did not exist. It is sourced by both dev and build and merged over the framework defaults (@client / @server / @shared), user values winning on conflict:

export default defineConfig({
alias: {
// Relative values resolve against the project root before the map is handed to Vite.
"@components": "./src/client/shared/components",
// Absolute values pass through untouched.
"@icons": "/opt/shared/icons",
},
apps: [/* ... */],
});

Normalisation rule: Vite does not resolve relative alias replacements - it expects absolute paths. τjs therefore normalises declarative values before the alias map is handed to Vite (during dev-server setup and in taujsBuild, once the project root is known - not in defineConfig itself): a relative replacement resolves against the project root, an absolute one passes through untouched. This keeps the config file free of path.resolve(...) boilerplate without shipping strings Vite would misread.

The project root is taujsBuild({ projectRoot }) at build time and the projectRoot option on createServer in development (default process.cwd()). Pass the same directory to both - the scaffold already does - so relative aliases resolve identically in dev and build.

The programmatic alias options on createServer (dev) and taujsBuild (build) remain as escape hatches, layered above the declarative field (see the build guide). Programmatic values are passed through untouched (callers already hold real paths); a per-key override of a differing declarative value is logged at debug level, never warned.

The matrix is the supported set. Dev is the shared development server; Client build and SSR build are the per-app production builds.

SurfaceDevClient buildSSR buildMerge behaviour
pluginsYesYesYesAppend + dedupe by name (first wins)
defineYesYesYesShallow merge
css.preprocessorOptionsYesYesYesPer-engine deep merge
optimizeDeps (include/exclude/esbuildOptions)YesN/AN/ADev-only subset; stripped from builds
esbuild, logLevelYesYesYesOverride
resolve.* (not alias)YesYesYesMerge per key
build.sourcemap / minify / terserOptionsN/AYesYesOverride
build.rollupOptions.externalN/AYesYesOverride
build.rollupOptions.output.manualChunksN/AYesYesDeprecated, FUNCTION form only
build.rolldownOptions.output.codeSplittingN/AYesYesCanonical chunking (replaces the above)
aliasesYesYesYesVia top-level alias only
server.allowedHostsYesN/AN/ADev-only; stripped from builds
server.* other than allowedHostsProtectedN/AN/ADev: warned and dropped. Builds: stripped silently with the whole dev-only server object
root, base, publicDir, configFile, appType, build.outDir, build.ssr / ssrManifest, build.format / target / manifest, build.rollupOptions.input, resolve.aliasProtectedProtectedProtectedRejected; logged at warn

Protected fields are absent from TaujsViteConfig, so they cannot be supplied through the typed surface at all. If one reaches the merge engine anyway (a JavaScript config, or an as any cast), it is rejected and logged at warn rather than silently applied - including build.manifest, which warns like its siblings. In dev the whole build key is rejected (builds are a per-app concern).

Development-only fields are the exception to “always warned”. optimizeDeps and server.allowedHosts configure the shared development server, and the same config.vite declaration also reaches every app build - so a build strips them silently. Warning there would report ordinary configuration as misuse once per app on every build.

Under server, only allowedHosts is admitted. ws is withheld because ws: false disables the WebSocket connection HMR runs on, which the framework owns through server.ws (the deprecated server.hmr remains protected as legacy input, but is no longer the active facility); host, port, strictPort, https and open configure Vite’s own HTTP listener, which does not exist in middleware mode because Fastify owns the listener; and proxy overlaps caller-route ownership. Supplying any of them in development warns and is not applied. In a build the whole server object is stripped silently, so nothing under it warns there.

One precedence chain runs through one merge engine, in both dev and build:

framework invariants -> config.vite -> taujsBuild({ vite })
  • Each layer merges over the previous with the per-field rules in the matrix. A later layer wins field conflicts while unrelated fields from earlier layers survive - so a CI wrapper passing taujsBuild({ vite: { build: { sourcemap: true } } }) tunes only that field and keeps every plugins, define, and CSS setting declared in taujs.config.ts.
  • Both layers coexisting is normal operation and is silent. A genuine per-field conflict between the two user layers is reported at warn, naming the field, both sources, and the winner (the programmatic layer). A framework default being overridden by a user layer is never warned.
  • The dev server reads config.vite only; taujsBuild({ vite }) is build-only and is not consulted in development.

Plugins from every channel are composed by one rule, in declared order, deduped by plugin name with the first occurrence winning across all sources. The order is:

  • Dev (shared server): every app’s plugins in config order, then config.vite.plugins, then the internal framework plugin(s).
  • Build (per app): the app’s plugins, then config.vite.plugins, then taujsBuild.vite.plugins, then the internal framework plugin(s).

Every cross-source name collision is reported at warn with the plugin name, each declaring source, and the winner. Plugin options are never serialised or compared - identity is by name alone; a nameless plugin passes through undeduped. Internal framework plugins are appended last and are exempt from the user dedupe. The τjs- name prefix (Greek tau, U+03C4) is reserved: a user plugin carrying it is dropped with a warning, so it can neither displace nor impersonate a framework plugin. The renderer wrappers use ordinary Latin names (@taujs/react’s taujs:react-refresh-preamble-fix, @taujs/vue’s vite:vue) and are not affected.

Not auto-loading vite.config.ts does not close the Vite ecosystem. Reusable configuration lives in an ordinary module, shareable with tools that genuinely are Vite-hosted:

vite.shared.ts
import type { TaujsViteConfig } from "@taujs/server/config";
export const sharedVite = {
define: { __VERSION__: JSON.stringify(version) },
plugins: [ecosystemPlugin()],
} satisfies TaujsViteConfig;
taujs.config.ts
import { defineConfig } from "@taujs/server/config";
import { sharedVite } from "./vite.shared";
export default defineConfig({ vite: sharedVite, apps: [/* ... */] });

Vitest, Storybook, or a standalone Vite app import the same sharedVite pieces into their own config files; τjs simply never discovers those files implicitly. The satisfies TaujsViteConfig check keeps the shared fragment within the supported surface.

Routes define URL patterns, rendering strategies, and data requirements.

{
path: '/about',
attr: {
render: 'ssr'
}
}
{
path: '/users/:id',
attr: {
render: 'ssr',
data: async (params) => ({
userId: params.id
})
}
}
PropertyTypeRequiredDescription
pathstringYesFastify route path
attrRouteAttributesNoRendering and data config
PropertyTypeDefaultDescription
render'ssr' | 'streaming'RequiredRendering strategy
hydratebooleantrueHydrate the client renderer
metaRecord<string, unknown>{}Static metadata passed to headContent
middlewareMiddlewareundefinedAuth and CSP
dataDataHandlerundefinedData loader
deferredDeferredDataAttributesundefinedstreaming only. A flat record of named route-owned loaders whose values may arrive after rendering begins. See Deferred Route Data
headHeadAttributesundefinedDynamic head data loader: { data, timeoutMs?, optional? }, resolved before the render starts on both strategies and passed to headContent as headData. timeoutMs must be positive finite (default 3000 ms); optional: true degrades loader failures to headData: undefined instead of failing the request

Complete HTML rendered before sending:

{
path: '/products',
attr: {
render: 'ssr',
data: async () => {
const products = await db.products.findAll();
return { products };
}
}
}

Characteristics:

  • Data fully loaded before rendering
  • Complete HTML in single response
  • Guaranteed data in headContent
  • attr.head (if declared) resolves before the render and arrives as headData

React renderer semantics (@taujs/react): the ssr strategy renders complete HTML with React’s prerenderToNodeStream, so React.lazy and use() content is included in the response. Earlier versions used renderToString, which silently replaced any suspending subtree with its Suspense fallback. The render is bounded by the renderer’s ssrOptions.prerenderTimeoutMs (default 10000 ms). On expiry, a page whose shell completed is served with its unfinished Suspense boundaries in their fallback state - the client completes them after hydration - while a page whose shell never completed fails the request instead of serving a blank page. Set prerenderTimeoutMs: 0 to wait indefinitely.

Progressive HTML delivery:

{
path: '/dashboard',
attr: {
render: 'streaming',
meta: { // Required for streaming
title: 'Dashboard',
description: 'User dashboard'
},
data: async () => {
const metrics = await fetchMetrics();
return { metrics };
}
}
}

Characteristics:

  • Renderer output streams after required pre-shell work
  • Content streams according to the selected renderer semantics
  • React and Vue may build the streamed head before route data settles; Solid waits for critical data. Declare attr.head for portable dynamic head data, resolved before rendering and delivered as headData; meta remains available as static input for the application fallback.
  • Requires meta property

SSR without client-side JavaScript:

{
path: '/terms',
attr: {
render: 'ssr',
hydrate: false
}
}
{
path: '/about',
attr: {
render: 'ssr',
data: async (params, ctx) => {
const res = await fetch('https://api.example.com/about');
return await res.json();
}
}
}
{
path: '/users/:id',
attr: {
render: 'ssr',
data: async (params) => ({
serviceName: 'UserService',
serviceMethod: 'getUser',
args: { id: params.id }
})
}
}

Data handlers receive context:

data: async (params, ctx) => {
// ctx.requestId: canonical request identity, always String(req.id)
// ctx.logger: Scoped logger
// ctx.headers: Request headers
ctx.logger.info({ userId: params.id }, "Loading user");
return { user: await getUser(params.id) };
};

A streaming route may additionally declare attr.deferred: a flat record of named loaders whose values are allowed to arrive after rendering has begun. attr.data remains the critical snapshot the response cannot start without; deferred declares response-owned work that is started but not awaited. τjs starts deferred work without awaiting it before invoking the renderer, and subsequent byte ordering follows the renderer’s native streaming semantics.

{
path: '/products/:id',
attr: {
render: 'streaming',
meta: { title: 'Product' },
// critical: the response waits for this
data: serviceData('catalogue', 'product', ({ id }) => ({ id })),
// response-owned, started without being awaited
deferred: {
reviews: serviceData('reviews', 'forProduct', ({ id }) => ({ id })),
},
},
}

Entries are the ordinary DataHandler shape, serviceData() sugar included - there is no new helper and no wrapper. τjs starts each named loader exactly once per request, outside the component tree and before the head resolves, and the selected renderer projects the named promise onto its own Suspense primitive. Because the work is declared, it appears in the request graph (contributing to a service’s usedBy) and each entry records one outcome on the request episode.

Deferred entries are not HTTP-status-bearing. Their outcome may arrive after the response has committed - the status line and headers are long gone by the time a deferred loader settles. Any condition that must prevent the response, redirect it or determine its status belongs in critical route resolution before rendering (attr.data, attr.middleware, attr.head), never in attr.deferred. complete, failed and aborted are deferred-data outcomes recorded on the episode and delivered to the hydration seed - they are not HTTP response statuses.

Component-facing accessors live in the renderer packages: React, Vue and Solid.

Rules, all enforced at boot:

  • deferred is valid on render: 'streaming' only. On an ssr route it is both a type error and a boot error: data a non-streamed response needs belongs in attr.data.
  • the value must be a plain object whose members are functions, and each key must match ^[A-Za-z][A-Za-z0-9_]*$.
  • there is no per-entry timeout, retry, optionality or dependency between entries. deferred describes timing, not policy - an optional business outcome is expressed by the loader’s own result (for example { available: false }).

Outcomes. Every entry settles as exactly one of complete, failed or aborted, recorded once on the request episode and readable through the existing MCP reader. complete means deliverable: a resolved value that cannot cross the hydration boundary is classified failed on every surface at once, with a single payload-free warning (Deferred data could not cross the hydration boundary key=<key>) explaining why. An entry that no component read, whose loader rejects after the response terminal, reads as aborted rather than failed - the response had already finished, so nothing was waiting on the value. An unread rejection never turns a completed document into a 500; a failure of critical attr.data (or of a non-optional attr.head) aborts and detaches deferred work that has already started. No deferred work outlives the response as τjs-owned background work.

Hydration. Outcomes for the boundaries a component actually read travel in a private, internal carrier written beside window.__INITIAL_DATA__ at end of stream, read once by the client bootstrap and then deleted. The public __INITIAL_DATA__ shape is unchanged, no loader re-executes in the browser, and no client refetch is issued. Under hydrate: false no carrier is emitted at all - the value still streams into the server-rendered HTML natively.

Types. DeferredDataOf<typeof route> (exported from @taujs/server/config) infers the payload of every declared entry from the route configuration alone, following the same three arms as HeadDataOf: the selected method’s result for serviceData() sugar, the resolved return type for a closure handler, and undefined when a route declares no deferred. Applications therefore never re-declare a deferred payload shape. The inferred type describes the loader’s declared result; what arrives is that value’s JSON snapshot, the same caveat that already applies to attr.data crossing __INITIAL_DATA__.

export default defineConfig({
security: {
csp: {
directives: {
"default-src": ["'self'"],
"script-src": ["'self'"],
"style-src": ["'self'", "'unsafe-inline'"],
"img-src": ["'self'", "data:", "https:"],
},
},
},
});
security: {
csp: {
directives: {
'default-src': ["'self'"],
'script-src': ["'self'"]
},
reporting: {
endpoint: '/api/csp-violations',
reportOnly: false,
onViolation: (report, req) => {
console.log('CSP violation:', report);
}
}
}
}
{
path: '/embed',
attr: {
render: 'ssr',
middleware: {
csp: {
mode: 'merge', // or 'replace'
directives: {
'frame-ancestors': ["'self'", 'https://trusted.com']
}
}
}
}
}
{
path: '/user/:id',
attr: {
render: 'ssr',
middleware: {
csp: {
directives: ({ params }) => ({
'img-src': [
"'self'",
`https://cdn.example.com/users/${params.id}/`
]
})
}
}
}
}
// Hard disable - no header
{
path: '/legacy',
attr: {
middleware: {
csp: false
}
}
}
// Soft disable - use global only
{
path: '/report',
attr: {
middleware: {
csp: {
disabled: true
}
}
}
}
{
path: '/dashboard',
attr: {
render: 'ssr',
middleware: {
auth: {}
}
}
}
{
path: '/admin',
attr: {
render: 'ssr',
middleware: {
auth: {
roles: ['admin', 'superadmin']
}
}
}
}
{
path: '/api/data',
attr: {
render: 'ssr',
middleware: {
auth: {
strategy: 'api-key',
redirect: '/login'
}
}
}
}

Note: τjs doesn’t interpret roles, strategy, or redirect. These are metadata for your authenticate decorator to read.

export default defineConfig({
server: {
port: 3000,
},
apps: [
{
appId: "web",
entryPoint: "client",
routes: [
{
path: "/",
attr: {
render: "ssr",
data: async () => ({
title: "Home",
content: "Welcome",
}),
},
},
],
},
],
});
export default defineConfig({
server: {
host: "localhost",
port: 5173,
},
apps: [
{
appId: "customer",
entryPoint: "app",
routes: [
{
path: "/app/*",
attr: {
render: "streaming",
meta: { title: "App" },
middleware: { auth: { strategy: "jwt" } },
},
},
],
},
{
appId: "admin",
entryPoint: "admin",
routes: [
{
path: "/admin/*",
attr: {
render: "ssr",
middleware: {
auth: {
strategy: "session",
roles: ["admin"],
},
},
},
},
],
},
],
security: {
csp: {
directives: {
"default-src": ["'self'"],
"script-src": ["'self'"],
"style-src": ["'self'", "'unsafe-inline'"],
},
},
},
});

τjs validates configuration at startup:

[τjs] [config] Loaded 2 app(s), 15 route(s) in 2.3ms
[τjs] [security] CSP configured (15/15 routes) in 0.8ms
[τjs] [auth] ✓ 5 route(s) require auth
ErrorCauseSolution
”At least one app must be configured”Empty apps arrayAdd at least one app
”Routes require auth but authenticate() missing”Auth routes without decoratorAdd authenticate() to Fastify
”Route path declared in multiple apps”Duplicate pathsUse unique paths per app
”Entry client file not found”Missing build artifactsRun npm run build
”meta required for streaming routes”Streaming without metaAdd meta: {} to route
// type checking
export default defineConfig({
apps: [
/* ... */
],
});
// less ideal - no type checking
export default {
apps: [
/* ... */
],
};
// testable, reusable
data: async (params) => ({
serviceName: "UserService",
serviceMethod: "getUser",
args: { id: params.id },
});
// less ideal - mixed concerns
data: async (params) => {
const res = await fetch(`/api/users/${params.id}`);
return await res.json();
};
// reliable SEO
{
path: '/blog/:slug',
attr: {
render: 'streaming',
meta: {
title: 'Blog Post',
description: 'Read our latest blog',
ogType: 'article'
}
}
}
data: async (params, ctx) => {
ctx.logger.info({ userId: params.id }, "Loading user");
try {
const user = await getUser(params.id);
return { user };
} catch (err) {
ctx.logger.error({ userId: params.id, error: err }, "Load failed");
throw err;
}
};
export default defineConfig({
server: {
host: process.env.HOST || "localhost",
port: parseInt(process.env.PORT || "5173"),
},
apps: [
{
appId: "web",
entryPoint: "client",
routes: [
/* ... */
],
},
],
});
// Development is requested explicitly. Every other value - `production`, `test`, `staging`,
// unset - is production, which is how τjs derives its own runtime mode.
const isDev = process.env.NODE_ENV === "development";
export default defineConfig({
server: {
port: isDev ? 5173 : 3000,
},
security: {
csp: {
directives: {
"script-src": isDev
? ["'self'", "'unsafe-inline'"] // Dev only
: ["'self'"], // Production
},
},
},
});