Skip to content

Configuration

This document describes every configuration file, environment variable, and per-package override used by the xmachines-js monorepo.


Environment Variables

The monorepo itself has no application runtime that reads environment variables. The only variables are for the dev container tooling, the test runs, and the CI/CD release pipeline.

Dev Container Variables

The dev container currently defines no workspace-specific environment variables; .devcontainer/.env (loaded via env_file in .devcontainer/compose.yaml) is available for local overrides if a future tool needs one. Copy .devcontainer/.env.sample to .devcontainer/.env to get started.

Test Run Variable

VariableRequiredDefaultDescription
XM_TEST_TARGETOptionalsourceThe code that a test run loads for each published package: source (the src/ tree) or dist (the build output). Any other value stops the run with an error.

Defined in: the test:dist and test:browser:dist scripts of the root package.json. Read by: xmTestTarget of @xmachines/shared/vite-aliases, which every config helper of that package calls. Do not set it by hand: run pnpm run test:dist. See testing.md.

CI / Release Pipeline Variable

VariableRequiredDefaultDescription
SEMREL_SKIP_STEPSOptional"" (empty — run all steps)A single RegExp string matched against every semantic-release plugin step ID. Use | for alternation to skip multiple steps. Set via the GitLab “Run pipeline” UI or API trigger.

Defined in: .gitlab-ci.yml, consumed by release.config.mjs.

Examples:

A step ID is the name of the plugin, and a name that the entry declares extends it: @semantic-release/exec:build.

Terminal window
# Skip one prepare step
SEMREL_SKIP_STEPS="@semantic-release/exec:build$"
# Skip every prepare step
SEMREL_SKIP_STEPS="@semantic-release/exec"
# Leave CHANGELOG.md alone
SEMREL_SKIP_STEPS="@semantic-release/changelog"

A pattern that removes @semantic-release/git throws instead of running: semantic-release would still tag and push, and the tag would then name a commit that carries no version bump.

The variable reaches the publish never. scripts/publish-release.mjs runs on the tag pipeline, outside semantic-release — see deployment.md.


Configuration Files

TypeScript — tsconfig.json (root)

Location: /tsconfig.json

The root TypeScript config is a build coordinator only — it compiles nothing itself. It lists every package and example via references, and that array is the whole build order: TypeScript does not inherit references through extends, so a package declares none of its own. tests/tsconfig-reference-graph.test.ts fails when the array misses a project or lists a package before something it imports.

{
"files": [],
"references": [
// Layer 0 — no internal deps
{ "path": "./packages/core" },
{ "path": "./packages/docs" },
{ "path": "./packages/play-atom" },
{ "path": "./packages/shared" },
// Layer 1 — depends on Layer 0
{ "path": "./packages/play" },
{ "path": "./packages/play-actor" },
// Layer 2
{ "path": "./packages/play-view" },
// Layer 3
{ "path": "./packages/play-router" }
// ...etc.
]
}

Every package must be listed here and must have composite: true in its own tsconfig.json.

Test build coordinator: /tsconfig.test.json — identical structure but references each package’s tsconfig.test.json for type-checking tests without emitting output.

TypeScript — Shared Base Config (@xmachines/shared/tsconfig)

Location: packages/shared/config/tsconfig.json Exported as: @xmachines/shared/tsconfig

All packages extend this base. Key compiler settings:

SettingValueNotes
targetESNextLatest ECMAScript output. See the two passes below
moduleNodeNextESM with .js extension imports required
moduleResolutionNodeNextMatches module setting
customConditions["source"]Resolves @xmachines/* to TypeScript source in dev/test
stricttrueFull strict mode
noUnusedLocalstrueError on unused variables
noUnusedParameterstrueError on unused parameters
noFallthroughCasesInSwitchtrue
noImplicitReturnstrue
noImplicitOverridetrue
exactOptionalPropertyTypestrue
declarationtrueEmits .d.ts files
declarationMaptrueEmits .d.ts.map for IDE go-to-source
emitDeclarationOnlytruetsc emits no JavaScript: the Vite pass ships it
sourceMaptrue
verbatimModuleSyntaxtruePreserves import type
isolatedModulestrue
removeCommentsfalsePreserves JSDoc in output

Vite writes the JavaScript, and tsc writes the declarations. build is vite build && tsc --build. The shared tsconfig sets emitDeclarationOnly, so tsc emits only .d.ts and .d.ts.map, and the Vite pass is the published dist/*.js of every package. build.target governs it.

Vite emits a module that holds only types as no file. Its .d.ts still ships. A test that imports such a module by path, or an internal re-export module that no entry reaches, fails in the dist run: import the module that holds the code.

Vite inherits no target from the tsconfig. It reads compilerOptions.target for one purpose: to pick the useDefineForClassFields default that it gives the transform. The syntax level of the Vite pass comes from build.target, which packages/shared/config/vite.ts sets — "esnext" for a publishable package, and an explicit browser list for a demo app.

Leave both stated, and keep both at ESNext for a library. A package that inherits the Vite default builds against a browser baseline, which is a second and lower ceiling than the one this table declares — and it is the ceiling that ships.

A demo states an explicit browser list, and not baseline-widely-available. A target controls syntax and adds no method. baseline-widely-available of Vite 8 names firefox114, the demos bundle play-router, and play-router calls toSorted(), which shipped in Firefox 115. The list in vite.ts therefore raises that one entry, so the declared floor is the floor the bundle runs on.

Per-package tsconfig.json pattern:

{
"extends": "@xmachines/shared/tsconfig",
"compilerOptions": {
"composite": true,
"rootDir": "./src",
"outDir": "./dist"
}
}

A package declares no references. Its dependencies live in package.json, and the root array orders the build.

Test tsconfig (@xmachines/shared/tsconfig-test):

Location: packages/shared/config/tsconfig.test.json

Test builds add noEmit: true, allowImportingTsExtensions: true, and vitest/globals types. Used by pnpm run test:build to type-check test files without emitting output. That script passes --force, so the check covers every project of the solution on every run.

Linting — oxlint.config.ts (root)

Location: /oxlint.config.ts Tool: oxlint ^1.62.0 Run: pnpm run lint / pnpm run lint:fix

The root config extends the shared base (@xmachines/shared/oxlint) and adds workspace-level ignore patterns:

/oxlint.config.ts
import sharedConfig from "@xmachines/shared/oxlint";
import { defineConfig } from "oxlint";
export default defineConfig({
extends: [sharedConfig],
ignorePatterns: [
".opencode",
"node_modules",
"dist",
"build",
"coverage",
"docs/api/_media",
"*.min.js",
"CHANGELOG.md",
],
});

Shared base rules (packages/shared/config/oxlint.config.ts):

PluginCategorySeverity
typescriptcorrectnesserror
unicornsuspiciouswarn
importperfwarn
import/no-cycle—error
typescript/no-explicit-any—error
typescript/no-unused-vars—error (ignores _ prefixed names)
unicorn/filename-case—off
unicorn/no-array-sort—error
no-underscore-dangle—off

unicorn/no-array-sort is an error and not the warning of its category. The workspace targets ESNext, and Node 24 — the floor of every package — has toSorted(), so the method is always available. As a warning the rule kept the lint job green while the Code Quality report showed the degradation, and sort() stayed behind a suppression for a ceiling that had already gone.

no-underscore-dangle is off because it contradicts typescript/no-unused-vars, which declares ^_ as the mark of a binding that exists on purpose and that nothing reads. A using declaration is the case that makes the contradiction concrete: the scope reads the binding through Symbol.dispose, the linter sees no read, and the _ that answers one rule trips the other.

Per-package override pattern:

// packages/<name>/oxlint.config.ts
import sharedConfig from "@xmachines/shared/oxlint";
import { defineConfig } from "oxlint";
export default defineConfig({
extends: [sharedConfig],
// add package-specific rules here
});

Formatting — oxfmt.config.ts (root)

Location: /oxfmt.config.ts Tool: oxfmt ^0.47.0 Run: pnpm run format / pnpm run format:check

The root config extends the shared base and adds workspace-level ignore patterns:

/oxfmt.config.ts
import sharedConfig from "@xmachines/shared/oxfmt";
import { defineConfig } from "oxfmt";
export default defineConfig({
...sharedConfig,
ignorePatterns: [
...(sharedConfig.ignorePatterns ?? []),
".opencode",
".agents",
".githuman",
".devcontainer",
"CHANGELOG.md",
"Thumbs.db",
],
});

Shared base format settings (packages/shared/config/oxfmt.config.ts):

SettingValue
printWidth100
tabWidth4
useTabstrue
semitrue
singleQuotefalse
trailingComma"all"
insertFinalNewlinetrue

JSON / YAML override (applied via overrides):

SettingValue
useTabsfalse
tabWidth2

Developer Tools — Vite DevTools

Tool: Vite DevTools ^0.7.1 Run: Shift+Alt+D in any demo dev server

@vitejs/devtools hosts four integrations, and each one reads a tool this repository already uses:

IntegrationPanel
@vitejs/devtools-oxcoxlint diagnostics, the resolved rules, and the oxfmt version
@vitejs/devtools-vitethe plugin pipeline of the dev server, and each module transform
@vitejs/devtools-rolldownthe rolldown build that Vite 8 runs, and the modules it bundles
@vitejs/devtools-vitestthe Vitest UI, inside the dock

defineXmDemoConfig adds the dock to every demo dev server, therefore the four panels need no script and no separate command:

Terminal window
pnpm --filter @xmachines/play-vue-router-demo run dev
# then press Shift+Alt+D in the browser

The dock starts hidden (embeddedVisibility: "passive"), and the browser remembers the choice after the first Shift+Alt+D. The demos are the subjects of the browser suites and of the screenshots of the documentation, and a panel above them helps neither.

The root manifest declares @vitest/ui, because the Vitest panel embeds it. Without it the panel shows an “Install @vitest/ui & start” button, and that button writes the dependency into the root manifest and installs it while the dev server runs. It installs the newest version, which is 5.x, and vitest names @vitest/ui as a peer at its own exact version. The declared ^4.1.11 keeps the whole vitest family on one range, which tests/vitest-version-lockstep.test.ts holds true.

Only the 13 demo apps declare the five packages, because only they run a dev server. The Oxc panel reads the same shared configuration from any of them: all 23 per-package oxlint.config.ts files hold extends: [sharedConfig] and add nothing, and the packages under examples/ hold no config of their own. To read the configuration of a package that has no demo, run the inspector without installing it:

Terminal window
pnpm --filter @xmachines/docs exec npx @vitejs/devtools-oxc

defineXmDemoConfig hands DevTools() the demo directory as cwd. Without it the lookup starts at process.cwd(), and the set of panels then depends on the directory a person typed the command in.

Every DevTools plugin carries apply: "serve". A demo is a release asset, therefore pnpm --filter <demo> run build emits the same files as before the dock existed, and no devtools code reaches a published demo.

A Vitest run gets no dock. apply: "serve" does not cover that case, because Vitest browser mode serves the app and therefore counts as serve. defineXmDemoConfig reads the VITEST variable that the Vitest CLI sets, and it adds no plugin when that variable is present. This gate is a guard, and it is not a fix for a failure that reproduces: the browser suite also passes and exits with the gate removed. A dock belongs to a dev server that a person opened, therefore the gate stays.

Editor — .editorconfig

Location: /.editorconfig

Setting[*][*.{json,yml,yaml}][*.md]
indent_styletabspace—
indent_size42—
end_of_linelf——
charsetutf-8——
trim_trailing_whitespacetrue—false
insert_final_newlinetrue——

Testing — vitest.config.ts (root)

Location: /vitest.config.ts Tool: Vitest ^4.1.5 Run: pnpm test / pnpm run test:coverage / pnpm run test:dist

Root Vitest config is a workspace coordinator that lists all per-package configs under test.projects. It sets conservative monorepo-wide defaults:

SettingValueNotes
pool"forks"Process-isolated workers; defineXmVitestConfig gives a jsdom project "vmThreads"
maxWorkers4Root default; per-project configs may override
isolatetrue
fileParallelismfalseConservative default; safe packages opt in with true
teardownTimeout30000 ms
hookTimeout30000 ms
testTimeout10000 ms

Coverage thresholds (monorepo aggregate — vitest run --coverage):

TypeThreshold
Lines80%
Functions80%
Branches75%
Statements80%

Coverage provider: v8. Individual packages may declare higher per-package thresholds in their own vitest.config.ts.

The JUnit file is test-results.xml. CI runs the suites on the build output alone (XM_TEST_TARGET=dist), so each test counts one time in the merge request widget. In the dist run, coverage.include also names packages/*/dist/**/*.js, and excludeAfterRemap filters again after the source maps name the files of src/. The dist run leaves out the doc-examples project: pnpm run test:docs:examples runs it.

Browser test config: /vitest.browser.config.ts Run with pnpm run test:browser, or with pnpm run test:browser:dist against the build output. Uses Playwright Chromium via Vitest browser mode. testTimeout is 20000 ms; maxWorkers is 2 (default; demo integration projects override with pool: "threads" + maxWorkers: 2).

Per-Package Vitest Config — @xmachines/shared/vitest

Location: packages/shared/config/vitest.ts Exported as: @xmachines/shared/vitest

Packages use defineXmVitestConfig(import.meta.url, overrides) to create their config. It automatically injects:

  • resolve.alias via xmAliases() so @xmachines/* imports resolve to TypeScript source without a prior build.
  • In the dist run, the xmBuildOutput plugin. The alias map then holds no published package, so each @xmachines/* import resolves through its exports map to dist/. The plugin moves each src/ file that a test imports by path to its counterpart in dist/.
  • test.provide.xmTestTarget, which a test reads with inject("xmTestTarget").
  • vitest.node.setup.ts for non-browser projects (if not already present).
  • vitest.setup.ts for all projects (if not already present).

Typical per-package usage:

// packages/<name>/vitest.config.ts
import { defineXmVitestConfig } from "@xmachines/shared/vitest";
export default defineXmVitestConfig(import.meta.url, {
test: {
name: "play-example",
include: ["test/**/*.test.ts"],
},
});

Vite Source Aliases — @xmachines/shared/vite-aliases

Location: packages/shared/config/vite-aliases.ts Exported as: @xmachines/shared/vite-aliases

Provides the helpers used in all vitest.config.ts and vite.config.ts files:

  • xmAliases(import.meta.url) — returns a Record<string, string> mapping every @xmachines/* package to its TypeScript source entry. Enables tests and dev servers to run without a prior pnpm run build. In the dist run, the map holds no entry for a published package except <name>/src.
  • xmResolve(import.meta.url) — returns a full Vite resolve config with xmAliases plus preserveSymlinks: false. It sets no conditions: the aliases do the work that the source condition does for TypeScript.
  • xmTestTarget() — reads XM_TEST_TARGET, and returns source or dist.
  • xmBuildOutput(import.meta.url) — the Vite plugin of the dist run. It moves each src/ file of a published package to its counterpart in dist/, and it fails the import when the build output holds no such file. It also fails the run when anything loads a src/ file of a published package.
  • xmCacheDir(import.meta.url, name) — returns a shared Vite cache path under node_modules/.vite/<name> to prevent redundant dep optimizer runs across projects.
  • xmOptimizeDeps(include?) — returns an optimizeDeps.include list holding exactly the specifiers passed in. Nothing is pre-bundled by default, and every entry has to be a package the calling project declares: pnpm gives each package its own node_modules, so Vite cannot resolve an undeclared specifier — it warns on stderr and carries on, which reads as a failure in a passing job.

Release — release.config.mjs

Location: /release.config.mjs Tool: semantic-release via @semantic-release/* plugins

Defines the release branches and plugin chain used by the GitLab CI semantic-release job.

Release branches:

BranchChannelPre-release
main(default)No
rcrcrc
betabetatrue
alphaalphaalpha
preprepre

Only main is a protected branch today. That works because GITLAB_TOKEN — which the release needs, since CI_JOB_TOKEN can push neither the release commit nor the tag — is an unprotected group variable and so reaches every one of these refs. If it is ever made protected, each of these branches has to be protected too, or the release fails in verify-conditions with EGITNOPERMISSION rather than at the registry.

Tag format: v${version}

Plugin order:

  1. @semantic-release/commit-analyzer — determines version bump from conventional commits
  2. @semantic-release/release-notes-generator — builds release notes
  3. @semantic-release/changelog — writes CHANGELOG.md
  4. @semantic-release/exec steps (in order):
    • corepack — corepack enable
    • install — pnpm install --frozen-lockfile
    • set-workspace-versions — updates all package.json files to the new version
    • build — pnpm run build
    • typedoc — pnpm --filter @xmachines/docs run typedoc
    • format-docs — pnpm --filter @xmachines/docs run format
    • stage-generated-docs — git add -A packages/docs/api packages/docs/examples/@xmachines packages/docs/examples/README.md, so a page that TypeDoc deleted leaves git too
  5. @semantic-release/git — commits the changelog, the manifests, the READMEs and the generated docs (api/, examples/@xmachines/ and the demo lists of examples/README.md), then pushes the commit and the tag

The list ends there. semantic-release publishes nothing and creates no GitLab release: scripts/publish-release.mjs does both, on the pipeline of the tag. deployment.md states the whole sequence and the reason.

Any step above can be skipped with the SEMREL_SKIP_STEPS CI variable, which the CI / Release Pipeline Variable section at the top of this page states.


Dependency Updates — renovate.json5

Location: /renovate.json5 Tool: Renovate, from the xmachin-es/renovate project

Renovate updates the npm dependencies of every package of the workspace, the demos under packages/*/examples/* too. It also updates the to-be-continuous components that .gitlab-ci.yml includes. The bot runs one time each week. For each new version, it opens a merge request, and the pipeline of that merge request tests the new version. The Dependency Dashboard issue lists every update that Renovate knows.

The rules of the file:

  • Three days of cooldown. The renovate component waits 3 days for a new version, which also covers the cooldown of one day in pnpm. The @xmachines/* packages have no cooldown, as in pnpm-workspace.yaml.
  • One merge request for json-render. The @xmachines/json-render-* packages move together. The merge request moves every range, the peerDependencies too, as a feat(deps): commit. Renovate proposes the prereleases of the fork (0.22.0-xm.1), which it skips by default. Update the ranges that the README files name on the branch of that merge request.
  • No new major for an override of one major. An override with a major in its key, for example zod@4, gets no update to the next major.
  • Approval for a patched package. pnpm-workspace.yaml patches @testing-library/jest-dom at one version. An update of that package needs a new patch. Approve it in the Dependency Dashboard, then move the patch on its branch.
  • Approval for a TypeScript major. A new major of typescript waits in the Dependency Dashboard. typedoc reads the TypeScript API, and it declares the majors that it supports. The docs job is manual in a merge request, so a green pipeline does not show the fault. Approve the update when typedoc supports the new major.
  • Commit types. A component update is a ci: commit, so semantic-release makes no release from it. An npm update is a fix(deps): commit for a dependencies entry, and a chore(deps): commit for any other type.

A new version of a component can rename an input and not say so. A pipeline that fails with no job is then an error in the inputs. Compare the inputs in .gitlab-ci.yml with the spec of the new version before you merge.


Per-Environment Overrides

This monorepo has no application-level config with per-environment overrides (no .env.development / .env.production files). All configuration is tool-level (TypeScript, linter, formatter, test runner) and is environment-agnostic.

The only environment-specific distinction is between local development (dev container with .devcontainer/.env) and CI (GitLab CI with pipeline variables), described in the sections above.


Required vs Optional Settings Summary

ConfigRequired to WorkOptional / Has Default
SEMREL_SKIP_STEPSNoDefault: "" (run all steps)
Root tsconfig.json referencesYes — the whole build order—
Per-package referencesNo — TypeScript drops them on extends—
Per-package composite: trueYes — required for project references—
@xmachines/shared/tsconfig extendsYes — all packages must extend it—
@xmachines/shared/oxlint extendsYes — all packages must extend it—
@xmachines/shared/oxfmt extendsYes — all packages must extend it—

Adding a New Package

When adding a new package, update these configuration files:

  1. /tsconfig.json — add a { "path": "./packages/<name>" } entry in the correct dependency layer.
  2. /tsconfig.test.json — add a { "path": "./packages/<name>/tsconfig.test.json" } entry.
  3. /vitest.config.ts — add "packages/<name>/vitest.config.ts" to test.projects.
  4. /vitest.browser.config.ts (if the package has browser tests) — add the browser config path to test.projects.
  5. /release.config.mjs — add an entry to NPM_PACKAGES array with the package’s pkgRoot.

See AGENTS.md for the full new-package checklist.