Skip to content

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

Prerequisites

  • Node.js >= 22.0.0
  • pnpm via corepack (corepack enable; version pinned by the packageManager field — 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

Terminal window
git clone git@gitlab.com:xmachin-es/xmachines-js.git
cd xmachines-js
pnpm install --frozen-lockfile

Use pnpm install --frozen-lockfile (not pnpm install) — it installs the exact versions from the lockfile.

Build

Terminal window
pnpm run build

The 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:

Terminal window
pnpm --filter @xmachines/<package-name> run build

That 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.

Terminal window
pnpm run devcontainer:up

Or open the repository in VS Code and choose Reopen in Container when prompted.

Verify Your Setup

Terminal window
pnpm test # Run the full test suite
pnpm run lint # Check for lint errors
pnpm run format:check # Check formatting without modifying files

All 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.md

Build Commands

All commands are run from the repository root unless otherwise noted.

CommandDescription
pnpm run buildTypeScript composite build — all packages in dependency order
pnpm --filter @xmachines/<pkg> run buildBuild a single package (and its deps)
pnpm run cleanRemove coverage, Vite caches, and all dist/ directories in packages
pnpm testRun all unit/integration tests once
pnpm run test:watchRe-run tests on file changes (interactive)
pnpm run test:browserRun Playwright browser tests
pnpm run test:coverageRun tests with V8 coverage reporting
pnpm run test:browser:coverageRun browser tests with coverage reporting
pnpm run test:buildType-check test files without running them (tsconfig.test.json)
pnpm run lintLint all packages with oxlint
pnpm run lint:fixAuto-fix lint issues
pnpm run formatFormat all files with oxfmt
pnpm run format:checkCheck formatting (CI mode — no writes)
pnpm run docsBuild 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.

Terminal window
pnpm update -r # move within the declared ranges
pnpm update -r --latest # move the ranges themselves

pnpm 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:

DependencyConstraintWhy
playwrightexact, equal to PLAYWRIGHT_VERSIONThe browsers are baked into the CI image; the browser job fails on any mismatch
typescript^5.9.3 || ^6.0.3TypeScript 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:

PackagePeer rangeTested against
react / react-dom^18.0.0 || ^19.0.019.x
vue-router^4.0.0 || ^5.0.05.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.json coordinates all packages via a references array — it compiles nothing itself
  • Each package has composite: true in its own tsconfig.json, enabling incremental and referenced builds
  • The root references array is what orders the build; a package’s own tsc --build needs no reference graph, because cross-package imports resolve to src/ through the source export condition
  • With declarationMap: true in the base config, Go to Definition in your IDE navigates to .ts source files rather than compiled .d.ts files

Build Layers

Packages are grouped into dependency layers as defined in the root tsconfig.json:

LayerPackagesDepends on
0play-signals, play, docsExternal libs only
1play-actorLayer 0
2play-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-routerLayers 0–1
3play-react-router, example demo appsLayer 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

  1. 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
  2. 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" }]
    }
  3. tsconfig.json — enable composite build:

    {
    "$schema": "https://json.schemastore.org/tsconfig",
    "extends": "./tsconfig.base.json",
    "compilerOptions": {
    "composite": true,
    "rootDir": "./src",
    "outDir": "./dist"
    },
    "include": ["src/**/*"]
    }
  4. Register in root tsconfig.json — add a reference in the correct layer:

    {
    "references": [{ "path": "./packages/your-package" }]
    }
  5. Register in root tsconfig.test.json and root vitest.config.ts (projects array).


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).

Terminal window
pnpm run format # Format all files
pnpm run format:check # Check without writing (CI mode)

Key settings (from packages/shared/config/oxfmt.config.ts):

SettingValue
Print width100 characters
IndentationTabs (useTabs: true, tabWidth: 4)
SemicolonsAlways (semi: true)
QuotesDouble (singleQuote: false)
Trailing commasAll
JSON / YAML2-space indent (override)

Linter — oxlint

oxlint is configured via oxlint.config.ts at the root (extends @xmachines/shared/oxlint).

Terminal window
pnpm run lint # Lint all packages
pnpm run lint:fix # Auto-fix lint issues

Active plugins: typescript, unicorn, import. Key rules:

RuleSeverity
typescript/no-explicit-anyerror
import/no-cycleerror
typescript/no-unused-varserror (prefix unused with _)
correctness categoryerror
suspicious categorywarn

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:

OptionValue
stricttrue
noUnusedLocalstrue
noUnusedParameterstrue
noImplicitReturnstrue
noImplicitOverridetrue
exactOptionalPropertyTypestrue
verbatimModuleSyntaxtrue
isolatedModulestrue

Use unknown with type guards instead of any. Prefix unused locals/parameters with _ to suppress errors.

Import Rules

  1. Always use .js extensions in imports, even for TypeScript source files:

    // ✅ Correct
    import { PlayError } from "./errors.js";
    // ❌ Wrong — will not resolve at runtime with NodeNext module resolution
    import { PlayError } from "./errors";
  2. Use import type for type-only imports (required by verbatimModuleSyntax):

    import type { RouteNode, RouteTree } from "../src/types.js";
  3. 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 -ing clusters. 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-adapter
fix/signal-watcher-cleanup
docs/play-actor-jsdoc
chore/update-vitest-4
refactor/route-map-extraction

Release branches are managed by the project maintainers:

BranchPurpose
mainStable releases
rcRelease candidate channel
betaPre-release beta channel
alphaPre-release alpha channel
prePre-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]
TypeTriggers version bumpWhen to use
featMinorNew user-facing feature
fixPatchBug fix
docsNo bumpDocumentation only
refactorNo bumpCode restructure without behavior change
testNo bumpAdding or updating tests
choreNo bumpBuild, tooling, or dependency changes
perfNo bump (unless breaking)Performance improvements
ciNo bumpCI/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 dispose
feat(play-react): add PlayRenderer suspense boundary

Before Submitting

  1. Read the relevant RFC in packages/docs/rfc/ — ensure your change conforms to the spec

  2. Run the full check suite from the repo root:

    Terminal window
    pnpm run build
    pnpm test
    pnpm run lint
    pnpm run format:check
  3. 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)

  4. Add JSDoc — all new public exports require JSDoc with @param, @returns, and @see RFC links

  5. Never edit packages/docs/api/ — API docs are auto-generated; edit source JSDoc and regenerate with pnpm 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.

JobCommandArtifacts
Buildpnpm run build
Test with coveragevitest run --coverageJUnit XML, Cobertura coverage report
Lintoxlint .
Auditpnpm 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.