Development
This guide covers everything you need to set up a local development environment, understand the build system, and contribute code to the XMachines JS monorepo.
Table of Contents
- Local Setup
- Monorepo Structure
- Build Commands
- Updating Dependencies
- TypeScript Composite Build System
- Code Style
- Writing Style
- Link a Package That You Name
- Branch Conventions
- PR Process
Local Setup
Prerequisites
- Node.js
>= 24.0.0(the suite writesusing, which needs V8 13.4) - pnpm via corepack (
corepack enable; version pinned by thepackageManagerfield — the project uses pnpm workspaces) - Git
No global tool installs are required beyond Node.js. All build, lint, and format tooling is installed locally via pnpm install --frozen-lockfile.
Clone and Install
git clone git@gitlab.com:xmachin-es/xmachines-js.gitcd xmachines-jspnpm install --frozen-lockfileUse
pnpm install --frozen-lockfile(notpnpm install) — it installs the exact versions from the lockfile.
Build
pnpm run buildThe root build is two commands: vite build emits every package’s JavaScript, then tsc --build emits the declarations through TypeScript project references, in dependency order. You never need to sequence builds manually.
To build a single package:
pnpm --filter @xmachines/<package-name> run buildThat runs the same two commands scoped to one package, so it produces the same dist/ the root build would — JavaScript and declarations. It needs no other package’s dist/ to exist first: cross-package imports resolve to src/ through the source export condition.
Dev Container (Optional)
A fully configured dev container is provided at .devcontainer/. It includes Docker-outside-of-Docker, Claude Code, and OpenCode.
pnpm run devcontainer:upOr open the repository in VS Code and choose Reopen in Container when prompted.
Verify Your Setup
pnpm test # Run the full test suitepnpm run lint # Check for lint errorspnpm run format:check # Check formatting without modifying filesAll three should pass without errors on a freshly cloned repository.
Monorepo Structure
The repository is organized as a pnpm workspaces monorepo with all packages under packages/:
packages/├── shared/ # Shared configs (tsconfig, oxlint, oxfmt, vitest)├── core/ # BaseError, the release protocol, the cache, small helpers├── play/ # Core protocol (PlayEvent, PlayError)├── play-atom/ # The atom primitives over @xstate/store├── play-pattern/ # The route pattern language: one parse, and the path it builds back├── play-url/ # The URL layer: the URLPattern API, a base path, framework params├── play-actor/ # The actor contract (PlayActor). It names no engine├── play-view/ # The shared view half (Viewable, PlaySpec, the view store lifecycle)├── play-xstate/ # XState v5 adapter (definePlayer, PlayerActor)├── play-router/ # Route extraction and RouterBridgeBase├── play-dom/ # Vanilla DOM renderer├── play-dom-router/ # DOM router adapter├── play-react/ # React renderer (PlayRenderer)├── play-react-router/ # React Router 7/8 adapter├── play-vue/ # Vue 3 renderer├── play-vue-router/ # Vue Router adapter├── play-solid/ # SolidJS renderer├── play-solid-router/ # SolidJS Router adapter├── play-svelte/ # Svelte renderer├── play-svelte-spa-router/ # Svelte SPA Router adapter├── play-sveltekit-router/ # SvelteKit Router adapter├── play-tanstack-react-router/ # TanStack Router (React)├── play-tanstack-solid-router/ # TanStack Router (SolidJS)└── docs/ # API docs, guides, RFCs (@xmachines/docs)Many packages also contain an examples/ subdirectory with runnable demo apps.
Standard Package Layout
packages/<name>/├── src/ # TypeScript source files├── dist/ # Build output (gitignored)├── test/ # Test files (*.spec.ts or *.test.ts)├── examples/ # Runnable demo apps (optional)├── package.json # Must have "type": "module"├── tsconfig.json # Composite build config (extends tsconfig.base.json)├── tsconfig.base.json # Local base (extends @xmachines/shared/tsconfig)├── vitest.config.ts # Package test config└── README.mdBuild Commands
All commands are run from the repository root unless otherwise noted.
| Command | Description |
|---|---|
pnpm run build | Incremental build — all packages in dependency order (not a type-check) |
pnpm --filter @xmachines/<pkg> run build | Build a single package (and its deps) |
pnpm run clean | Remove coverage, Vite caches, and all dist/ directories in packages |
pnpm test | Run all unit/integration tests once |
pnpm run test:watch | Re-run tests on file changes (interactive) |
pnpm run test:browser | Run Playwright browser tests |
pnpm run test:coverage | Run tests with V8 coverage reporting |
pnpm run test:browser:coverage | Run browser tests with coverage reporting |
pnpm run test:build | Complete type-check of every package and its tests (tsconfig.test.json) |
pnpm run lint | Lint all packages with oxlint |
pnpm run lint:fix | Auto-fix lint issues |
pnpm run format | Format all files with oxfmt |
pnpm run format:check | Check formatting (CI mode — no writes) |
pnpm run docs | Build packages, generate TypeDoc API docs, then format |
pnpm run test:docs | Check every link of the generated docs (run pnpm run docs first) |
Updating Dependencies
Every dependency is declared with the same range in every manifest that uses it, and a test fails if two disagree — pnpm update should move a package everywhere at once, not leave one package a minor behind.
pnpm update -r # move within the declared rangespnpm update -r --latest # move the ranges themselvespnpm update -r alone will not move a 0.x dependency past its current minor: a caret on a 0.x version means >=0.58.0 <0.59.0, because minors are where a pre-1.0 package is allowed to break. oxfmt sat six minors behind that way while pnpm outdated reported it on every pipeline. Use --latest for those, and read what changed.
Two dependencies are pinned on purpose and must not be swept up:
| Dependency | Constraint | Why |
|---|---|---|
playwright | exact, equal to PLAYWRIGHT_VERSION | The browsers are baked into the CI image; the browser job fails on any mismatch |
typescript | ^5.9.3 || ^6.0.3 | TypeScript 7 ships no programmatic compiler API, which TypeDoc and @vue/compiler-sfc both require |
New versions also wait out pnpm’s minimumReleaseAge (24 hours) before they can be installed at all.
Peer ranges are wider than what CI installs
peerDependencies and devDependencies say different things about the same package, so the one-range rule does not apply across them and the test exempts peers.
A peer range is a contract with the consumer: bring your own React, anything in this range works. It is deliberately wide, because the application owns that dependency — narrowing it forces consumers to upgrade in lockstep, or leaves two copies of a framework in their bundle. A dev range is what this repository installs to compile and test, and is concrete because CI needs one resolution.
So react: ^18.0.0 || ^19.0.0 as a peer alongside react: ^19.2.5 as a dev dependency is the correct pairing, not drift.
What that costs is worth stating plainly: CI only ever exercises the dev version. Two peers currently claim support across a major boundary that nothing here tests:
| Package | Peer range | Tested against |
|---|---|---|
react / react-dom | ^18.0.0 || ^19.0.0 | 19.x |
vue-router | ^4.0.0 || ^5.0.0 | 5.x |
React 18 and vue-router 4 are supported by intent — nothing known depends on 19-only or 5-only behaviour — but neither is installed by any job, so a regression there would surface as a consumer’s bug report rather than a red pipeline. Narrowing either range to what is tested would be a breaking change for consumers and needs a major release; adding a floor-install job would close the gap instead. Until one of those happens, treat the lower major as untested.
The packages that must exist one time
@xmachines/core, @xmachines/play and @xmachines/play-atom are a peer of every package that reads them, and a dependency of none.
@xmachines/play-atom re-exports the atom of @xstate/store, and that library holds the active subscriber in a variable of its own module. Two copies build two graphs. A computed atom of the first copy and a subscription of the second copy therefore never meet: the subscriber stays silent, and no error reports the fault. Every propagation of state in this architecture goes through an atom, so the second copy stops the reactivity of a whole application.
@xmachines/play holds PlayError, and the documentation sends a consumer to err instanceof PlayError. That check answers false for an error of the second copy. @xmachines/core holds BaseError, the base of PlayError, and err instanceof BaseError fails in the same way.
A dependency invites that second copy. scripts/lib/workspace-deps.mjs pins a workspace:* range to an EXACT version, so two packages of two releases name two versions, and an installer resolves both. A peer moves the choice to the consumer, who brings one copy. The @xmachines/json-render-* packages carry the same rule for the same reason.
Each of the three needs a devDependency beside the peer, because the workspace installs no peer. Without that entry a package resolves the specifier through the hoisting of another package, and it compiles until that other package stops declaring it.
tests/singleton-peers.test.ts holds the three rules true: no dependency, a dev entry beside each peer, and a peer for each one that src/ imports. A package that reads one of the three in its TESTS alone needs the dev entry alone — @xmachines/play-tanstack-router is that case.
The rule reads in both directions. A peer that the src/ directory never imports is a peer that a consumer installs for nothing, and four router adapters carried one. A package declares the peer when it reads the package, and a devDependency alone when its tests read it.
TypeScript Composite Build System
The monorepo uses TypeScript project references for correct build-order management. No manual sequencing or separate build scripts are needed.
How It Works
- The root
tsconfig.jsoncoordinates all packages via areferencesarray — it compiles nothing itself - Each package has
composite: truein its owntsconfig.json, enabling incremental and referenced builds - The root
referencesarray is what orders the build; a package’s owntsc --buildneeds no reference graph, because cross-package imports resolve tosrc/through thesourceexport condition - With
declarationMap: truein the base config, Go to Definition in your IDE navigates to.tssource files rather than compiled.d.tsfiles
Build Layers
Packages are grouped into dependency layers as defined in the root tsconfig.json:
| Layer | Packages | Depends on |
|---|---|---|
| 0 | core, docs, play-atom, shared | External libs only |
| 1 | play, play-actor, play-pattern | Layer 0 |
| 2 | play-url, play-view | Layers 0–1 |
| 3 | play-dom, play-react, play-router, play-solid, play-svelte, play-vue | Layers 0–2 |
| 4 | play-tanstack-router, play-xstate | Layers 0–3 |
| 5 | play-actor/examples/shared | Layers 0–4 |
| 6 | renderer demo apps | Layers 0–5 |
| 7 | the eight router adapters | Layers 0–6 |
| 8 | router demo apps | Layers 0–7 |
The layer of an entry is its longest path to a package with no workspace dependency, and tests/tsconfig-reference-graph.test.ts computes it from the manifests.
Mermaid Diagram
flowchart LR
subgraph L0["Layer 0 — no workspace deps"]
core
play-atom
shared
docs
end
subgraph L1["Layer 1"]
play
play-actor
play-pattern
end
subgraph L2["Layer 2"]
play-url
play-view
end
subgraph L3["Layer 3 — view renderers, and the router core"]
play-dom
play-react
play-router
play-solid
play-svelte
play-vue
end
subgraph L4["Layer 4"]
play-tanstack-router
play-xstate
end
subgraph L5["Layer 5"]
actorshared["play-actor/examples/shared"]
end
subgraph L6["Layer 6"]
renderdemos["renderer demo apps"]
end
subgraph L7["Layer 7 — the eight router adapters"]
adapters["play-dom-router, play-react-router, play-solid-router,
play-svelte-spa-router, play-sveltekit-router,
play-tanstack-react-router, play-tanstack-solid-router,
play-vue-router"]
end
subgraph L8["Layer 8"]
routerdemos["router demo apps"]
end
L0 --> L1 --> L2 --> L3 --> L4 --> L5 --> L6 --> L7 --> L8
Adding a New Package
-
Create the package directory following the standard structure:
packages/<your-package>/├── src/│ └── index.ts├── test/├── package.json # must have "type": "module"├── tsconfig.json├── tsconfig.base.json├── vitest.config.ts└── README.md -
tsconfig.base.json— extend the shared config:{"$schema": "https://json.schemastore.org/tsconfig","extends": "@xmachines/shared/tsconfig","compilerOptions": {"skipLibCheck": true}}Declare no
referenceshere. TypeScript does not inherit areferencesarray throughextends, so an array in this file orders nothing:--showConfigon the package reportsreferences: None. A package declares its dependencies inpackage.json, with aworkspace:*range, and the roottsconfig.jsonholds the one build order. Step 4 below adds the entry. -
tsconfig.json— enable composite build:{"$schema": "https://json.schemastore.org/tsconfig","extends": "./tsconfig.base.json","compilerOptions": {"composite": true,"rootDir": "./src","outDir": "./dist"},"include": ["src/**/*"]} -
Register in root
tsconfig.json— add a reference in the correct layer:{"references": [{ "path": "./packages/your-package" }]} -
Register in root
tsconfig.test.jsonand rootvitest.config.ts(projectsarray).
Code Style
All style rules are mandatory — CI enforces them on every merge request.
Formatter — oxfmt
oxfmt (Biome-based) is configured via oxfmt.config.ts at the root (extends @xmachines/shared/oxfmt).
pnpm run format # Format all filespnpm run format:check # Check without writing (CI mode)Key settings (from packages/shared/config/oxfmt.config.ts):
| Setting | Value |
|---|---|
| Print width | 100 characters |
| Indentation | Tabs (useTabs: true, tabWidth: 4) |
| Semicolons | Always (semi: true) |
| Quotes | Double (singleQuote: false) |
| Trailing commas | All |
| JSON / YAML | 2-space indent (override) |
Linter — oxlint
oxlint is configured via oxlint.config.ts at the root (extends @xmachines/shared/oxlint).
pnpm run lint # Lint all packagespnpm run lint:fix # Auto-fix lint issuesActive plugins: typescript, unicorn, import. Key rules:
| Rule | Severity |
|---|---|
typescript/no-explicit-any | error |
import/no-cycle | error |
typescript/no-unused-vars | error (prefix unused with _) |
unicorn/no-array-sort | error (use toSorted()) |
no-underscore-dangle | off (clashes with ^_ unused) |
correctness category | error |
suspicious category | warn |
Editor Config
.editorconfig is present at the root and enforces:
- Tabs for indentation in all source files
- 2-space indent for JSON/YAML
- LF line endings, UTF-8, insert final newline
TypeScript Strict Mode
All packages extend @xmachines/shared/tsconfig which enables full strict mode. Mandatory constraints:
| Option | Value |
|---|---|
strict | true |
noUnusedLocals | true |
noUnusedParameters | true |
noImplicitReturns | true |
noImplicitOverride | true |
exactOptionalPropertyTypes | true |
verbatimModuleSyntax | true |
isolatedModules | true |
Use unknown with type guards instead of any. Prefix unused locals/parameters with _ to suppress errors.
Import Rules
-
Always use
.jsextensions in imports, even for TypeScript source files:// ✅ Correctimport { PlayError } from "./errors.js";// ❌ Wrong — will not resolve at runtime with NodeNext module resolutionimport { PlayError } from "./errors"; -
Use
import typefor type-only imports (required byverbatimModuleSyntax):import type { RouteNode, RouteTree } from "../src/types.js"; -
Import order: Node.js built-ins (
node:prefix) → external packages → internal@xmachines/*packages → relative imports
Writing Style: ASD-STE100
Every text that a human reads follows ASD-STE100 Simplified Technical English: commit messages, merge request titles and descriptions, documentation under packages/docs/, README files, changelog entries, and code comments. The specification is at https://asd-ste100.org/.
Simplified Technical English keeps the documentation unambiguous for readers whose first language is not English, and it keeps the localized docs site consistent between versions.
The rules that apply most often:
- One idea per sentence. Keep instruction sentences to 20 words, descriptive sentences to 25.
- Active voice, present tense, named actor. Write “the bridge drops the event”, not “the event is dropped”.
- Imperative for instructions. Write “run
pnpm test”, not “you should run the tests”. This also gives the Conventional Commits subject line its correct form. - One word, one meaning. Choose one term for each concept and repeat it. Do not alternate between synonyms such as “delete”, “remove”, and “purge”.
- Simple words. Write “use”, not “utilize”. Write “start”, not “commence”. Write “about”, not “regarding”.
- Keep the articles. Write “the actor emits the event”, not “actor emits event”. Do not use contractions: write “do not”, not “don’t”.
- No
-ingclusters. Write “the actor that emits the event”, not “the emitting actor”. - No idioms, metaphors, marketing words, jokes, or emojis.
- One topic for each paragraph, six sentences at most. Put a warning or a caution before the step that it applies to.
The rules apply to prose. They do not apply to identifiers, code, test names, log output, or text that you quote from an external source.
The root AGENTS.md carries the same rules for AI coding agents.
Link a Package That You Name
A document that names a package of this workspace links to the page of that package. A reader who meets a package name in the middle of a paragraph needs the page of that package, and a code span gives no way to reach it.
Write the link against the place that holds the document:
| Document | Link |
|---|---|
packages/<name>/README.md | ../<target>/README.md |
packages/<name>/examples/<demo>/README.md | ../../../<target>/README.md |
packages/docs/{guides,contributing,rfc,examples}/<file>.md | ../api/@xmachines/<target>/README.md |
A subpath names the package that holds it, so @xmachines/play-router/xstate links to the README of @xmachines/play-router.
Three places keep a plain code span:
- A code block. The text is code, and an import specifier is not a reference to follow.
- A heading. The heading names the section, and the body links the package.
- The README of the package itself. A page does not link to itself.
tests/package-mentions-are-linked.test.ts holds the rule true. It reads every README and every hand-written document of packages/docs/, and it fails when a mention carries no link, or when the link names a file that is absent.
Branch Conventions
Use a <type>/ prefix that matches the conventional commit type for the work being done:
feat/vue-router-adapterfix/atom-subscription-cleanupdocs/play-actor-jsdocchore/update-vitest-4refactor/route-map-extractionRelease branches are managed by the project maintainers:
| Branch | Purpose |
|---|---|
main | Stable releases |
rc | Release candidate channel |
beta | Pre-release beta channel |
alpha | Pre-release alpha channel |
pre | Pre-release channel |
Do not manually version packages or create release branches — semantic-release owns the version, and a release is cut by starting its manual CI job.
PR Process
Commit Messages
All prose in a commit follows ASD-STE100 — the subject line and the body.
This project uses Conventional Commits — changelogs and version bumps are generated automatically from commit history.
<type>[(<scope>)][!]: <short description>
[optional body]
[optional footer]| Type | Triggers version bump | When to use |
|---|---|---|
feat | Minor | New user-facing feature |
fix | Patch | Bug fix |
docs | No bump | Documentation only |
refactor | No bump | Code restructure without behavior change |
test | No bump | Adding or updating tests |
chore | No bump | Build, tooling, or dependency changes |
perf | No bump (unless breaking) | Performance improvements |
ci | No bump | CI/CD pipeline changes |
Breaking changes: append ! after the type (feat!:) AND write a BREAKING CHANGE:
footer. The two do different work, and neither one replaces the other:
- The
!decides the BUMP.release.config.mjsgives both semantic-release plugins abreakingHeaderPattern, because the angular preset reads!not at all: without that pattern,feat!: …parses with no type, and a release made of nothing but such commits is no release. - The footer writes the NOTE. The changelog lists a breaking change from the footer alone,
so a
!commit with no footer bumps the major and tells a reader nothing about what broke. Seven commits shipped that way once, and none of them appears inCHANGELOG.md.
Write the footer as one paragraph that names the old form and the new one:
refactor(play-actor)!: make the actor contract an interface
BREAKING CHANGE: `AbstractActor` is removed. Use the `PlayActor` interface, and extendthe actor class of your engine directly.Keep every other line of the body from starting with a word: prefix. The parser reads
such a line as a footer token, and a line that opens with “BREAKING CHANGE” inside a
wrapped paragraph therefore announces a major that nobody intended.
Use the package short-name as scope when the change is isolated to one package:
fix(play-actor): correct atom cleanup on disposefeat(play-react): add PlayRenderer suspense boundaryBefore Submitting
-
Read the relevant RFC in
packages/docs/rfc/— ensure your change conforms to the spec -
Run the full check suite from the repo root:
Terminal window pnpm run buildpnpm testpnpm run lintpnpm run format:check -
Write tests — new code must meet coverage thresholds (80% lines/functions/statements and 75% branches at the monorepo level; core packages enforce higher per-package thresholds)
-
Add JSDoc — all new public exports require JSDoc with
@param,@returns, and@seeRFC links -
Never edit
packages/docs/api/— API docs are auto-generated; edit source JSDoc and regenerate withpnpm run docs. Keep the result out of your branch: the release regenerates and commits it (see deployment.md), so a branch that carries its own regeneration churns hundreds of files and conflicts with the next one.
Merge Request Checklist
- Branch is up to date with
main - All tests pass (
pnpm test) - Lint passes (
pnpm run lint) - Formatting is correct (
pnpm run format:check) - Build succeeds (
pnpm run build) - New code has tests at or above coverage thresholds
- All new public exports have JSDoc
- Conventional commit format used on all commits
- Commits, MR description, and docs written in ASD-STE100
- RFC read and implementation conforms to spec
CI Pipeline
The GitLab CI pipeline runs automatically on merge requests, pushes to main, and git tags.
| Job | Command | Artifacts |
|---|---|---|
| Build | pnpm run build | — |
| Test with coverage | vitest run --coverage | JUnit XML, Cobertura coverage report |
| Lint | oxlint . | — |
| Audit | pnpm audit --audit-level=high | — |
| Semantic release | (manual job on main/rc/beta/alpha/pre) | CHANGELOG, npm publish, GitLab release |
See Architecture for the package layering and data flow details, and Configuration for tooling configuration reference.