Build and delivery
There are three ways to run a xoong app: serve it with xoong dev while you work, ship the static files that xoong build writes, or prerender public pages. The runtime needs no bundler in any of them.
Development: xoong dev
xoong dev is @xoong/server wired to your folder. One web-standard handler does all of it, and file access is injected, so the handler imports no Node module.
{
"port": 5173,
"proxy": { "/api": "http://localhost:8080" },
"spa": false,
"allowedHosts": [],
"packages": ["rainlet"]
}npx xoong dev # development: static, proxy, TypeScript stripping
npx xoong build --out dist # strict-CSP static output
npx xoong build --out dist --base /admin --include data --include img
npx xoong render index.html --api http://localhost:8080 --out dist/index.html- Static files and packages. It serves the project folder and finds the installed
@xoong/*packages (and any listed inpackages), serving them under/@xoong/core,/@xoong/dataand so on. It injects an import map into each HTML page that has none, soimport "@xoong/data"works in the browser without a bundler. Your own packages resolve through theirexportsmap (conditions, subpaths, wildcards); entries under./serverare never served. - TypeScript. A
.tsfile is stripped of its types when it is requested, byamaro. Only erasable syntax works: noenum,namespaceor parameter properties, and the error message says so. Import local TypeScript with the.tsextension. - Proxy. A prefix in
proxy(or--proxy /api=http://localhost:8080) is forwarded to its upstream, so there is no CORS in development. It matches whole segments (/apidoes not match/apix), longest prefix first. Bodies stream both ways, so server-sent events pass through unbuffered and a closed tab stops the upstream read. An unreachable upstream answers 502 with the reason. - Safety. Files come only from under the root; paths that escape it are refused. Dot folders are refused too, except
.xoongand.well-known, so.envand.gitare never served. TheHostheader must belocalhost,127.0.0.1,::1or inallowedHosts, which blocks DNS-rebinding pages. --spaanswers any path without a file extension withindex.html, for an app with a router. Files are sent withcache-control: no-cache.
Not yet: live reload and SCSS compilation.
Release: xoong build
No bundling and no minifying: each file in the output is the file you wrote, with its imports rewritten. In motion, a dot goes down the steps in order, while the command is lit: the pages and the installed packages are followed together, then the files are rewritten, checked, and written to dist. Use the slider, or press a number, to look at one step.
- Input. Every
.htmlfile under--root(notnode_modules, not dot folders, not the output folder). From each page it follows<script src>,<link href>,<img src>and the like, then the imports of each script, then theurl()and@importof each stylesheet. Links to other pages are left as they are. - Rewrite. A bare import such as
"@xoong/data"becomes a URL (the same one the dev server uses), so the page needs no import map; the import map in your HTML is dropped..tsis stripped and written as.js.--base /adminprefixes every root-absolute URL, for an app served under a path. Imports are found by scanning forimportandexport ... fromwith a string, and dynamicimport("..."); what cannot be seen that way (afetch()of a data file, an image named only in JavaScript) is added with--include <file-or-folder>. - Warnings, not failures. The build tells you about an inline
<script>, astyle=""attribute and an inline<style>(the policy blocks them), an import it cannot resolve, and a file that is referenced but missing. It prints the total size, with gzip, and the policy the output is meant to run under. - Preload. The build knows the whole module graph, so each page gets a
<link rel="modulepreload">for every module its scripts import (static imports only; a dynamicimport()stays lazy). The browser then fetches the chain at once instead of one import level at a time.--no-preloadturns it off. - Output. Nothing is bundled and nothing is minified: every file is the one you wrote, readable in the browser's tools.
build-report.jsonlists each file's bytes and gzip size, the warnings and the policy. Any static host works; send the policy as a header.
The CSP model
The runtime runs under this policy, with no unsafe-eval and no unsafe-inline:
Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; font-src 'self'; base-uri 'none'; form-action 'self'
- No code from strings. The runtime never calls
eval,new FunctionorsetTimeoutwith a string. Interpolation,xo-if,xo-each, formats and validation rules are plain code walking data. - No inline style. It never creates a
<style>element and never writes astyle=""attribute. Where a value must reach the style (a custom property fromstyle:--x=${...}, the position of a popup), it uses the CSS object model, which the policy allows.xoong.cssis an external file and the generated markup uses classes only. - No inline handlers. Event listeners are added with
addEventListener. Interpolating data into anon*attribute is refused. - Data is not code.
<script type="application/json">, the way to hand the page its first data (client.seed), is not executed, so the policy allows it. - One exception, in development. The dev server injects an inline import map, which
script-src 'self'blocks. So never ship the dev server's HTML. The output ofxoong buildhas no import map. Nonce-based policies work as well: nothing in the runtime needs a nonce.
It is tested, not only claimed. packages/cli/test/build.test.mjs builds the admin example and serves it in real Chromium with exactly this policy, and fails on any securitypolicyviolation; a second test shows the policy really is enforced there (an inline script is blocked). The documentation site you are reading is built and tested the same way, under /xoong.
Prerendering with @xoong/ssr
There is no hydration step: the browser simply draws again from the saved markup, so there is nothing to mismatch.
- The same runtime.
renderPage(html, { operations, baseUrl })runs the real<xo-view>(and@xoong/ui) on a happy-dom document, so the syntax has one implementation and the output matches what the browser draws. The page's own scripts and stylesheets are neither run nor fetched. - Output. HTML that already shows the data, for crawlers, for people without JavaScript, and for the first paint. In the browser the view draws again from the saved template. A router page rendered with
--urlhas that route's content,<title>and description; the router replaces it with the same page when it starts. - Public pages only. The data is as fresh as the last render, and personalised pages would need the visitor's credentials. Use
headersonly for service credentials. Personalised pages stay drawn in the browser. - Things to know. Pages render one after another, because the DOM globals are process-wide, and the first render installs
windowanddocumentonglobalThisfor the rest of the process: prerender in a build script or a separate process, not inside a server that detects browsers bytypeof window. A page whose API calls fail still renders its$errorbranches; passfailOnError: trueto make that an error, oronErrorto observe it. A page whose data does not finish loading withintimeoutMs(10 s) fails instead of rendering half. - Not yet: request-time rendering inside
xoong devor@xoong/server, caching and revalidation policy, and rendering the first state ofliveviews.xoong builddoes not call the prerenderer: runxoong renderfor each public page.
Size budgets and what the tests guard
The size of a runtime package is a tested promise. test/architecture.test.mjs measures the gzip size of each package's source with comments removed and fails when it passes its budget. It is a regression guard, not the shipped size (that is the files themselves, which are not minified). A budget sits 5 to 10 percent above the measured value, and raising one needs a reason in the commit. The roadmap's target of single-digit kilobytes applies to the minified size, which has not been measured yet.
const budgetKb = { core: 5.6, data: 17.2, forms: 9.5, i18n: 5, ui: 10, controls: 9.5, richtext: 8.5 };The tests that keep the architecture honest:
- Architecture test: zero runtime dependencies and the import direction (see Overview), the size budgets, MIT licence everywhere, and that the generated documentation pages equal what the generator writes.
llms.txtcannot drift: it stays within about 3,000 tokens (estimated as length divided by 3.8), mentions only commands the CLI has, and documents exactly thexo-*names xoong defines. The runtime must handle each of them.- Build test: the strict-CSP run above. Coverage: the suite runs with thresholds (statements 88, branches 79, functions 88, lines 92 overall;
corestricter) that sit just under the measured values, so a drop fails the build. - Pack test: the tarballs that projects install contain what a package needs and nothing else.
Where to look in the code
packages/cli/src/dev.js:xoong devandxoong.config.jsonpackages/server/src/handler.js, node.js: the handler, the proxy, the import map,findPackages, type stripping, the Host checkpackages/cli/src/build.js:xoong buildand theCSPconstantpackages/ssr/src/render.js:renderPagedocs/security.md: the policy and what the runtime refuses or escapesdocs/server.md: the server design, including what is not builttest/architecture.test.mjs: the budgets and the dependency rules