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
- Branch Conventions
- PR Process
Local Setup
Prerequisites
- Node.js
>= 22.0.0 - 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)├── play/ # Core protocol (PlayEvent, PlayError)├── play-signals/ # TC39 Signals polyfill wrapper├── play-actor/ # Abstract actor base (AbstractActor, Routable, Viewable)├── 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 v7 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 | TypeScript composite build — all packages in dependency order |
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 | Type-check test files without running them (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 |
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.
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 | play-signals, play, docs | External libs only |
| 1 | play-actor | Layer 0 |
| 2 | play-router, play-dom-router, play-sveltekit-router, play-xstate, play-react, play-vue, play-solid, play-svelte, play-dom, play-tanstack-react-router, play-vue-router, play-solid-router, play-svelte-spa-router, play-tanstack-solid-router | Layers 0–1 |
| 3 | play-react-router, example demo apps | Layer 2 |
Mermaid Diagram
flowchart LR
subgraph L0["Layer 0 — no internal deps"]
play-signals
play
docs
end
subgraph L1["Layer 1"]
play-actor
end
subgraph L2["Layer 2 — view renderers & router adapters"]
play-router
play-dom-router
play-sveltekit-router
play-xstate
play-react
play-vue
play-solid
play-svelte
play-dom
play-tanstack-react-router
play-vue-router
play-solid-router
play-svelte-spa-router
play-tanstack-solid-router
end
subgraph L3["Layer 3 — application layer"]
play-react-router
examples["example demo apps"]
end
L0 --> L1
L1 --> L2
L2 --> L3
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}}If the package depends on other monorepo packages, add
references:{"extends": "@xmachines/shared/tsconfig","compilerOptions": { "skipLibCheck": true },"references": [{ "path": "../play" }, { "path": "../play-actor" }]} -
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 _) |
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.
Branch Conventions
Use a <type>/ prefix that matches the conventional commit type for the work being done:
feat/vue-router-adapterfix/signal-watcher-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!:) or add BREAKING CHANGE: in the footer.
Use the package short-name as scope when the change is isolated to one package:
fix(play-actor): correct signal 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
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.