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 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.
CI / Release Pipeline Variable
| Variable | Required | Default | Description |
|---|---|---|---|
SEMREL_SKIP_STEPS | Optional | "" (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.
# Skip one prepare stepSEMREL_SKIP_STEPS="@semantic-release/exec:build$"
# Skip every prepare stepSEMREL_SKIP_STEPS="@semantic-release/exec"
# Leave CHANGELOG.md aloneSEMREL_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/docs" }, { "path": "./packages/play" }, { "path": "./packages/shared" }, // Layer 1 — depends on Layer 0 { "path": "./packages/play-signals" }, // Layer 2 { "path": "./packages/play-actor" }, // 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:
| Setting | Value | Notes |
|---|---|---|
target | ESNext | Latest ECMAScript output. See the two passes below |
module | NodeNext | ESM with .js extension imports required |
moduleResolution | NodeNext | Matches module setting |
customConditions | ["source"] | Resolves @xmachines/* to TypeScript source in dev/test |
strict | true | Full strict mode |
noUnusedLocals | true | Error on unused variables |
noUnusedParameters | true | Error on unused parameters |
noFallthroughCasesInSwitch | true | |
noImplicitReturns | true | |
noImplicitOverride | true | |
exactOptionalPropertyTypes | true | |
declaration | true | Emits .d.ts files |
declarationMap | true | Emits .d.ts.map for IDE go-to-source |
sourceMap | true | |
verbatimModuleSyntax | true | Preserves import type |
isolatedModules | true | |
removeComments | false | Preserves JSDoc in output |
Two passes emit, and emitDeclarationOnly decides which one ships. build is
vite build && tsc --build, so tsc runs last.
- Fourteen publishable packages let tsc emit JavaScript. tsc overwrites the Vite pass,
and
compilerOptions.targetabove is what governs the publisheddist/*.js. - Five — play-vue, play-svelte, play-solid, play-solid-router,
play-tanstack-solid-router — set
emitDeclarationOnly, so tsc emits only.d.tsand the Vite pass is the published JavaScript.build.targetgoverns theirs.
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 for those five.
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:
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):
| Plugin | Category | Severity |
|---|---|---|
typescript | correctness | error |
unicorn | suspicious | warn |
import | perf | warn |
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.tsimport 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:
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):
| Setting | Value |
|---|---|
printWidth | 100 |
tabWidth | 4 |
useTabs | true |
semi | true |
singleQuote | false |
trailingComma | "all" |
insertFinalNewline | true |
JSON / YAML override (applied via overrides):
| Setting | Value |
|---|---|
useTabs | false |
tabWidth | 2 |
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:
| Integration | Panel |
|---|---|
@vitejs/devtools-oxc | oxlint diagnostics, the resolved rules, and the oxfmt version |
@vitejs/devtools-vite | the plugin pipeline of the dev server, and each module transform |
@vitejs/devtools-rolldown | the rolldown build that Vite 8 runs, and the modules it bundles |
@vitejs/devtools-vitest | the 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:
pnpm --filter @xmachines/play-vue-router-demo run dev# then press Shift+Alt+D in the browserThe 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:
pnpm --filter @xmachines/docs exec npx @vitejs/devtools-oxcdefineXmDemoConfig 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_style | tab | space | — |
indent_size | 4 | 2 | — |
end_of_line | lf | — | — |
charset | utf-8 | — | — |
trim_trailing_whitespace | true | — | false |
insert_final_newline | true | — | — |
Testing — vitest.config.ts (root)
Location: /vitest.config.ts
Tool: Vitest ^4.1.5
Run: pnpm test / pnpm run test:coverage
Root Vitest config is a workspace coordinator that lists all per-package configs under test.projects. It sets conservative monorepo-wide defaults:
| Setting | Value | Notes |
|---|---|---|
pool | "forks" | Process-isolated workers; defineXmVitestConfig gives a jsdom project "vmThreads" |
maxWorkers | 4 | Root default; per-project configs may override |
isolate | true | |
fileParallelism | false | Conservative default; safe packages opt in with true |
teardownTimeout | 30000 ms | |
hookTimeout | 30000 ms | |
testTimeout | 10000 ms |
Coverage thresholds (monorepo aggregate — vitest run --coverage):
| Type | Threshold |
|---|---|
| Lines | 80% |
| Functions | 80% |
| Branches | 75% |
| Statements | 80% |
Coverage provider: v8. Individual packages may declare higher per-package thresholds in their own vitest.config.ts.
Browser test config: /vitest.browser.config.ts
Run with pnpm run test:browser. 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.aliasviaxmAliases()so@xmachines/*imports resolve to TypeScript source without a prior build.vitest.node.setup.tsfor non-browser projects (if not already present).vitest.setup.tsfor all projects (if not already present).
Typical per-package usage:
// packages/<name>/vitest.config.tsimport { 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 two helpers used in all vitest.config.ts and vite.config.ts files:
xmAliases(import.meta.url)— returns aRecord<string, string>mapping every@xmachines/*package to its TypeScript source entry. Enables tests and dev servers to run without a priorpnpm run build.xmResolve(import.meta.url)— returns a full Viteresolveconfig withxmAliasespluspreserveSymlinks: trueandconditions: ["source"].xmCacheDir(import.meta.url, name)— returns a shared Vite cache path undernode_modules/.vite/<name>to prevent redundant dep optimizer runs across projects.xmOptimizeDeps(include?)— returns anoptimizeDeps.includelist 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 ownnode_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:
| Branch | Channel | Pre-release |
|---|---|---|
main | (default) | No |
rc | rc | rc |
beta | beta | true |
alpha | alpha | alpha |
pre | pre | pre |
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:
@semantic-release/commit-analyzer— determines version bump from conventional commits@semantic-release/release-notes-generator— builds release notes@semantic-release/changelog— writesCHANGELOG.md@semantic-release/execsteps (in order):corepack—corepack enableinstall—pnpm install --frozen-lockfileset-workspace-versions— updates allpackage.jsonfiles to the new versionbuild—pnpm run buildtypedoc—pnpm --filter @xmachines/docs run typedocformat-docs—pnpm --filter @xmachines/docs run formatstage-generated-docs—git add -A packages/docs/api, so a page that TypeDoc deleted leaves git too
@semantic-release/git— commits the changelog, the manifests, the READMEs and the generated API docs, 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.
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
| Config | Required to Work | Optional / Has Default |
|---|---|---|
SEMREL_SKIP_STEPS | No | Default: "" (run all steps) |
Root tsconfig.json references | Yes — the whole build order | — |
Per-package references | No — TypeScript drops them on extends | — |
Per-package composite: true | Yes — required for project references | — |
@xmachines/shared/tsconfig extends | Yes — all packages must extend it | — |
@xmachines/shared/oxlint extends | Yes — all packages must extend it | — |
@xmachines/shared/oxfmt extends | Yes — all packages must extend it | — |
Adding a New Package
When adding a new package, update these configuration files:
/tsconfig.json— add a{ "path": "./packages/<name>" }entry in the correct dependency layer./tsconfig.test.json— add a{ "path": "./packages/<name>/tsconfig.test.json" }entry./vitest.config.ts— add"packages/<name>/vitest.config.ts"totest.projects./vitest.browser.config.ts(if the package has browser tests) — add the browser config path totest.projects./release.config.mjs— add an entry toNPM_PACKAGESarray with the package’spkgRoot.
See AGENTS.md for the full new-package checklist.