Architecture overview
xoong is a stack of small packages. Each owns one job and imports only packages below it. The ones that run in the browser import nothing from outside xoong.
The layers
The runtime is seven packages, plus @xoong/xoong, one package that re-exports the common ones. They are plain ES modules with no build step: the browser loads the files you installed.
The runtime packages. An arrow means "imports"; a package may also import packages further down (ui imports data and core directly). Nothing imports upward.
| Package | Owns |
|---|---|
@xoong/core | signal, computed, effect, batch, untracked; the html template tag; XoElement and XoControl; define; the text() hook that packages use to ask for words; the list of xo-* names (syntax.js) that the tools check against. |
@xoong/data | The client (configure, getClient, createClient), the query cache, the entity store, derived, the live-update stream, XoongError, and <xo-view> (the @xoong/data/view entry). |
@xoong/forms | The request-schema model, the form model (createForm) and the DOM binder (attach). |
@xoong/ui | <xo-table>, <xo-form>, <xo-dialog>, <xo-toast>, <xo-router> with the router object, the confirm dialog, and xoong.css. |
@xoong/i18n | xo-t, t(), configureI18n, the message format, locale-aware formats. |
@xoong/controls | <xo-tabs>, <xo-menu>, <xo-combobox>, <xo-tags>, <xo-carousel>, the xo-tooltip attribute. |
@xoong/richtext | <xo-richtext> and the HTML sanitiser it uses. |
@xoong/xoong | One name to install: re-exports core, data, data/view and ui, and carries the xoong command. |
The dependency rule
A runtime package may import only the packages in its row below, and nothing else: no npm package, no Node built-in. This is checked on every test run by test/architecture.test.mjs, which reads each package.json and each source file.
| Package | May import |
|---|---|
core | nothing |
data, controls, richtext | core |
forms, i18n | core, data |
ui | core, data, forms |
The same test also fails when data imports something from core that is not one of its public entries (@xoong/core, /signals, /closest, /syntax), when a runtime package imports tooling (cli, server, mcp, test), and when a pure-logic module touches document or window while it loads. That last rule keeps signals.js and everything in data except view.js importable from plain Node, which is how the tools and the prerenderer reuse them.
How does a lower package use a higher one, then? It does not: the higher package registers itself into a seam the lower one provides. Importing @xoong/forms calls setFormEngine(); importing @xoong/i18n calls setTranslator(), setTextProvider() and setLocaleSource(); the router calls setParamSource() and setUrlSync(); @xoong/ui calls setConfirm(). Without the higher package the lower one still works, with a plain default (English text, the browser's confirm(), the History API directly).
The higher package registers itself when it is imported; the lower package calls it back later. Nothing imports upward. Dashed: the answer. In motion, a dot travels along each arrow in the order of the steps, while the line of code that causes the step is lit: the registration when forms is imported, then, after a pause at the note, the call back into forms and its answer. Use the slider, or press a number, to look at one step.
// packages/forms/src/index.js: forms import data, so data cannot import forms.
// forms registers itself, and every <form xo-submit> then uses the form model.
import { setFormEngine } from '@xoong/data/view';
import { attach as attachForm } from './form-dom.js';
setFormEngine({ attach: attachForm });The tooling
Tooling runs in Node (or a test runner) and is allowed to have dependencies: @xoong/cli uses parse5 and yaml, @xoong/server uses amaro to strip TypeScript types, @xoong/ssr uses happy-dom. None of it ships to the browser.
The tooling packages. Dashed: loaded only when needed. They run in Node or in a test runner, never in the browser.
| Package | Owns |
|---|---|
@xoong/cli | The xoong command: sync, doctor, new, check, render, build, dev, i18n extract, i18n check. Also the OpenAPI analysis and the xoong check rule engine. |
@xoong/server | One web-standard handler, (Request) => Promise<Response>: static files, API proxy, import map, TypeScript stripping. @xoong/server/node adds the Node file reader and HTTP server. It imports no other xoong package. |
@xoong/ssr | renderPage(): prerender a page with its data, using the real <xo-view> on a happy-dom document. |
@xoong/test | mockApi() and mount() for screen tests. |
@xoong/lsp, @xoong/mcp | The same checks as xoong check, for editors and for AI assistants. |
The stream gateway described in older design notes is not built yet. The server package today does static files, proxy, import map and type stripping.
Three contracts
Light DOM. Elements draw into the ordinary DOM, so your CSS, your tests, axe and the browser's own tools see exactly what the person sees. There is no Shadow DOM (decision record 0002).
Plain CSS. xoong ships design tokens as custom properties (--xo-color-*, --xo-radius, ...) and a few class names. You write CSS. See Styling.
OpenAPI. Operation names, URLs, query parameters, entity identity, request schemas and error shapes come from your OpenAPI document. If the document cannot say something, xoong doctor tells you what to add, or a hints file says it for you without editing the backend's document. See Data flow.
What runs when
| When | Where | What |
|---|---|---|
| Development, once per API change | Node | xoong sync reads the OpenAPI document (a file or a URL; YAML, JSON, Swagger 2.0) and writes .xoong/operations.json, api.d.ts and xoong.html-data.json. |
| Development | Node | xoong dev serves the files, proxies the API and strips types; xoong check and the language server lint screens. |
| Release | Node | xoong build writes static, strict-CSP output; xoong render prerenders public pages. |
| Always | Browser | All runtime packages. operations.json is plain JSON that your main.js imports. Nothing in the browser reads OpenAPI. |
A screen at runtime: the HTML arrives; the browser upgrades each xo-* element whose package you imported; <xo-view> waits until the page is parsed, then asks the client for its operation; the answer lands in the store; effects write exactly the DOM nodes that read it. There is no hydration step and no tree re-render. The next pages follow each part: data, forms, rendering, routing, build, tooling.
Where to look in the code
test/architecture.test.mjs: the dependency rule, import direction, size budgets, licence,llms.txtlimitspackages/*/package.json: the declared dependencies of each packagepackages/forms/src/index.js, packages/i18n/src/index.js: the registration seams in actionpackages/core/src/syntax.js: the names of everyxo-*element and attributedocs/decisions/: the decision records (0001 to 0010)