Standards for anyone (human or coding agent) making changes in this repo. Read this before you start. These are conventions the project already follows; keep them consistent.
actor-ts is a pre-1.0 actor-model framework for TypeScript that
runs on Bun, Node.js (≥ 24), and Deno. ESM throughout; Bun is
the primary toolchain (bun test, bunx tsc). Runtime dependencies are
deliberately tiny — fastify + ts-pattern — and everything else
(Express, Hono, ws, brokers, SQL/Cassandra drivers, S3, …) is an
optional peer dependency, lazy-loaded on demand.
-
Conventional Commits:
type(scope): subject. Types in use:feat,fix,refactor,chore,docs,test,ci,build. Scope is the module/area, e.g.http,http/websocket,io,persistence/postgres,testkit,cluster,deps,deps-dev,readme,changelog,roadmap,integration. -
Small, focused commits. Each commit should keep
bun run typecheck+bun testgreen — so a bisect never lands on a broken tree. -
The body explains what + why (and the mechanics for non-trivial changes). Reference issues as
#NNN; close them withCloses #NNN(see Issues & workflow). -
Commits that only touch CI-maintained artifacts (e.g. the README test/coverage badges) use
[skip ci]. -
Commit as the private identity
~/.gitconfigdeclares — the one the whole history already uses. This is a personal project; a work address does not belong in it. The config is correct, but something in the tooling has been observed substituting a work address at commit time, and a wrong author only surfaces afterwards in the log. So pin the identity explicitly instead of trusting that the config is honoured — environment variables outrank both--localconfig and-c user.email=…:name=$(git config user.name); mail=$(git config user.email) GIT_AUTHOR_NAME="$name" GIT_AUTHOR_EMAIL="$mail" \ GIT_COMMITTER_NAME="$name" GIT_COMMITTER_EMAIL="$mail" \ git commit -F <message-file>
Reading the values back out of
git configis deliberate: it keeps the address itself out of this file, and it makes the recipe work unchanged in a fork, where the right author is whoever is doing the work.Applies to merge commits too. Verify afterwards with
git log --format='%an <%ae> | %cn <%ce>' -1— checkinggit config user.emailproves nothing, since the override does not live there. Nothing is pushed by the agent, so a wrong author is always still fixable: rewind the branch with a mixedgit reset <base>, re-commit the same file sets with the identity pinned, and confirm the rewrite changed nothing but authorship by comparinggit rev-parse HEAD^{tree}against the old tip's tree.
developis the integration branch — all ongoing development lands there.mainholds releases only: it moves only when a release is cut (a--no-ffmerge fromdevelop, see Release strategy), never via direct feature work.- All work happens on a feature branch under
features/…— one branch per unit of work, branched offdevelop(e.g.features/ws-backpressure,features/fix-mqtt-reconnect; even fixes and chores use thefeatures/prefix). The sole exception is cutting a release, which uses arelease/vX.Y.Zbranch (see Release strategy). No direct commits todevelop, not even small fixes or follow-ups — everything lands through a branch. Delete the branch after it merges. - Always integrate with a merge commit (
git merge --no-ff) — never rebase, never fast-forward. This holds in both directions:features/…→developand, at release time,develop→main. History stays a true graph; it is never rewritten or flattened. - Do not push. The agent commits locally only — on its
features/…branch and when merging intodevelop; the human pushesdevelop. The single exception is cutting a release (below) — mergingdevelop→mainand creating the tag/GitHub Release is explicitly authorized. mainis branch-protected — merges require a pull request and theteststatus check; the maintainer (admin) may bypass for the release merge.
SemVer, and the project is pre-1.0:
- patch
0.x.Y— bug fixes only, no breaking changes. - minor
0.X.0— new features; may include breaking changes. 1.0.0— the API-stability commitment.
Tags are vX.Y.Z; GitHub Releases are cut as normal Latest releases
(not flagged pre-release) — gh release create without --prerelease.
CHANGELOG (CHANGELOG.md) follows Keep a Changelog: an
[Unreleased] section with Added / Changed / Fixed / Removed /
Security subsections. Breaking changes are flagged prominently
(a BREAKING marker + a short migration note). Reference issues as
#NNN.
Cutting a release (only when explicitly asked) — promotes develop to main:
- On a
release/vX.Y.Zbranch offdevelop: bumpversioninpackage.jsonand move[Unreleased]→[X.Y.Z](dated) inCHANGELOG.md; commit (chore(release): vX.Y.Z). Merge it intodevelop(--no-ff) and pushdevelop. - Merge
develop→mainwithgit merge --no-ff, then pushmain. gh release create vX.Y.Z --target main(a normal Latest release, no--prerelease) with emoji-sectioned notes (## 🚀 New features,## ⚠️ Breaking changes,## 🔒 Security,## 🐛 Fixed, …) matching the style of prior releases.
Publishing the release triggers .github/workflows/publish.yml, which
runs typecheck + test + build and then npm publish --provenance via
npm Trusted Publishing (OIDC) — no long-lived token. It is
version-guarded, so re-running is safe. Locally, prepublishOnly runs
clean + build + typecheck + test.
Pre-1.0, a hard cut is fine. Remove or replace an API directly — no
deprecation cycle is required. Flag it as BREAKING in the CHANGELOG
with a one-line migration note, and update every in-repo caller
(examples, tests, docs) in the same change. (Post-1.0 this tightens to
conservative SemVer.) See docs/.../reference/version-policy.mdx.
- Docs are Starlight MDX under
docs/src/content/docs/(English), mirrored 1:1 underdocs/src/content/docs/de/(German). Every content change updates BOTH languages — code samples stay identical, prose is translated. Thei18nlabel tracks translation work. - Feature or behavior changes also update
README.mdandCHANGELOG.md. - The README test-count / coverage badges are bot-maintained — a CI
workflow pushes
chore(readme): update test count + coverage stats [skip ci]commits directly todevelopafter test runs. Do NOT edit those numbers by hand (the bot overwrites them, with CI-measured values that skip the quarantined multi-node suites viaACTOR_TS_SKIP_FLAKY_MNS, so they differ slightly from a local full run). After pushingdevelop, fetch again before branching — a bot commit may already have landed on top. - Adding a page: keep
docs/scripts/scaffold.mjsand the Astro sidebar (docs/astro.config.mjs) in sync — same path and label.
bun run typecheck(build tsconfig — excludesexamples/,tests/andbenchmarks/) passes.bun run typecheck:devadditionally checks those.bun testis green. Line coverage floor is ≥ 80 % —bun run test:coverage:gate.- Cross-runtime:
bun run smokerunstests/smoke/cases/*.mjson Bun, Node, and Deno. Add a smoke case for anything runtime-sensitive. - Benchmarks: a change to a
src/API thatbenchmarks/calls also needsbun run typecheck:bench(benchmarks-only compile) and, for anything that could break at runtime,bun run bench:smoke(~30 s — every suite, one unwarmed iteration each). The build tsconfig excludesbenchmarks/, so nothing else catches an orphaned benchmark; thebenchmarksworkflow gates both. The benchmarks are part of the adoption sweep for a breaking change, exactly like tests and examples. - DevTools UI: a change under
devtools-ui/needsbun run build:uiin the same commit —src/devtools/generated/uiAssets.tsis generated but committed, and a stale one is valid TypeScript, so nothing else notices.bun run check:uiasserts it (and gates thebuildworkflow) by comparing asource-hashover the UI sources, the build script and the bundled dependencies. It deliberately does not compare the bundle's bytes: those vary with the OS and the Bun release that produced them, so a byte diff is not a staleness signal. - Don't hand-edit the README test/coverage badges — CI updates them
on push to
develop.
- Code must run on Bun, Node ≥ 24, and Deno. Runtime-specific
primitives (HTTP serve, sockets, workers, SQLite, …) live behind small
abstractions in
src/runtime/and auto-detect at startup. - Optional peer dependencies:
import()them lazily with a clear "install it withbun add …" error on failure. Declare them inpeerDependenciesandpeerDependenciesMeta.<pkg>.optional = true, and add a matchingdevDependencyso the test suite can exercise them.
- Strict TypeScript. ESM with the
.jsimport suffix on relative imports (required by the build's module resolution). - Discriminated-union handling via
ts-pattern(match(x).with(…).exhaustive()). - Every
matcharm delegates to a privateonXxxhandler. Wherever amatch(…)dispatches an incoming message, event, or command — an actor'sonReceive/onCommand/onEvent(or a router it calls), a cluster-event subscription (cluster.subscribe(evt => match(evt)…)), or a wire/system-command dispatcher — every arm (each.with(…)and any.otherwise(…)) is a thin call into a private method (.with({ kind: 'data' }, (m) => this.onData(m)),.otherwise((m) => this.onUnhandled(m))), never an inline body, even a one-liner — no exceptions. Name iton+ the PascalCase discriminant (onData,onMemberUp,onCreate); type the parameter as the named variant type (see next bullet), or omit it for payload-free kinds. Keeps the matcher a scannable dispatch table. Exempt: matches on internal state (a state machine / behavior / directive reducer) or that compute a value in a helper (config, codec, route, priority) stay inline. interfacefor contracts and heritage,typefor everything else. A declaration is aninterfacewhen it prescribes function heads — any method, call or construct signature — or when itextendsanother shape. Everything else is atype X = { … }: plain data shapes, unions, mapped and conditional types. The split follows what the declaration is for. An interface states a contract someone implements, andextendsreads as a hierarchy where an intersection only reads as conjunction; a data shape states a value's layout, and theretypecomposes with the union aliases the project already uses (type XOptions,type Command). A function-typed property (onLost?: () => void) is not a function head — that shape stays atype. An interface may extend a type alias, so a contract built on a plain data base is writteninterface X extends XBase { … }withXBasestaying atype; the mixture is intended.- Discriminated unions are defined as named variant types. Declare each
tagged union as a union of named members
(
type Command = DepositCommand | WithdrawCommand | BalanceCommand), never an inline object-literal union — including the union alias itself (type Command, nottype Cmd). Name a variantPascalCase(kind)+ a role suffix matching the union (Command/Event/Message) — collision-safe (Set,Get,Publishnever bare); keep variant types module-local where the union is. Handlers take the named variant type (onDeposit(c: DepositCommand)), notExtract<Union, { kind }>. - The discriminant field is always
kind(nevertypeortag) — including the WebSocket/wire protocols of the examples.typecollides with thetypekeyword;kindis the single project-wide convention. - Pass the actor class, not a closure around it. Every slot typed
ActorClassOrFactory—spawn/spawnAnonymous,withEntityActor/withActor/withSingletonActor, theentityActor/singletonActor/actor/childfields, theRouter.*routee — takesMyActordirectly;actorFactoryOfdoes the wrapping.spawn(() => new MyActor(), 'x')is a leftover from thePropsera and reads as noise. The factory form is for constructor arguments (() => new Worker(database)) and for anything the class form cannot express — nothing else. Per-actor configuration is the third argument,ActorOptions, never a closure. - Spell out abbreviations in identifiers — types, classes, files, aliases,
generic type parameters, methods, fields, and locals/params, plus the
kindstring-literal values. Full words:Command/Message/Acknowledgment/NegativeAcknowledgment/Terminate/Increment/DirectMessage/Request/Response/Function/Context/Connection/Arguments/Directory/Repository/Deduplication/PersistenceId/Implementation/Constructor(notCmd/Msg/Ack/Nak/Nack/Term/Inc/Dm/Req/Res/Fn/Ctx/Conn/Args/Dir/Repo/Dedup/Pid/Impl/Ctor). Two exceptions only: (1) single-letter loop/lambda/catch vars (m,e,i) may stay; (2) names mirroring an external API or established domain acronyms stay verbatim — nats.js (.ack()/.nak(),max_msgs), prom-client (inc()/dec()/set()), amqplib (noAck), DOM (AudioContext),MsgPack(MessagePack), andPubSub,K8s,AMQP,MQTT,SQL,S3,DNS,CBOR. - HOCON config keys go through
src/config/ConfigKeys.ts(typed, single source of truth). Options resolve with precedence: explicit options > HOCON > built-in defaults — layered withmergeOptionsfromsrc/util/OptionsMerge.ts, whereundefinedon a higher layer means "not set" and falls through rather than shadowing. A key inreference.confmust be reachable fromConfigKeysand read by something insrc/—tests/unit/config/NoDeadConfigKeys.test.tsfails otherwise. A knowingly-unimplemented key goes in that test'sKNOWN_DEAD_KEYSwith the issue that will remove it; adding a key nothing reads is not an option. - JSDoc explains the why — constraints, rationale, non-obvious trade-offs — not a restatement of the code. Match the surrounding comment density; no narration or noise.
-
Every configurable thing has one
XOptions.tsfile with three exports, all in the "Options" family — there is no separate "Settings" concept:XOptionsType— the plain options-object shape (a bare{ … }you can pass directly).XOptionsBuilder— the fluent builder,extends OptionsBuilder<XOptionsType>(broker actors viaBrokerOptionsBuilder<XOptionsType>).XOptions— bothtype XOptions = XOptionsBuilder | XOptionsType(the accepted-input union used in every consumer signature) andconst XOptions = XOptionsBuilder(value alias, soXOptions.create()/new XOptions()resolve to the builder).
Naming lockstep with no divergence: builder method
withX⇔ fieldx⇔ HOCON leafx(e.g.withQos⇔qos, neverdefaultQos). Multi-arg sugar is fine when the field still matches the stem (withCredentials(u, p)→ fieldcredentials;withCircuitBreaker(f, r)→ fieldcircuitBreaker). -
An optional fourth export,
XOptionsValidator, when the options have fields with real constraints (ports, positive durations/counts, byte sizes, enums, non-empty strings/arrays, URLs, cross-field rules). Itextends OptionsValidator<XOptionsType>(broker actors viaBrokerOptionsValidator<XOptionsType>) and implementsrules(s)with the protected check helpers (port,positiveNumber,positiveInt,nonNegativeInt,oneOf,nonEmptyString,url, …) plusfail(field, reason, value)for cross-field/bespoke rules. Helpers take only the field name (typo-checked againstXOptionsType) and are a no-op onundefined— an unset optional always passes; required-ness stays where it was (BrokerActor.requiredOptions()/ an explicit guard). Options that are all booleans / strings / callbacks get no validator. Rejections throwOptionsError(source-agnostic — distinct fromBrokerOptionsErrorfor missing required fields andConfigErrorfor malformed HOCON).- Validation runs once, at consume time, on the merged settings, so the
builder, a plain object, and HOCON are all covered and cross-field rules see
the final values. Broker actors return
new XOptionsValidator()from theoptionsValidator()hook (run inpreStartafter the required-field check); non-broker consumers callnew XOptionsValidator().validate(settings)once in their constructor, right after the defaults spread. This is not aresolvehelper — the merge stays a plain spread; validation is a separate void assertion.OptionsBuilderhas no set-time validation.
- Validation runs once, at consume time, on the merged settings, so the
builder, a plain object, and HOCON are all covered and cross-field rules see
the final values. Broker actors return
-
All option-relevant types are co-located in
XOptions.ts— including theXOptionsTypedeclaration (the config contract read byreadOptionsFromConfig) and, when present, theXOptionsValidatorclass. The functional file (actor/store/factory) imports the type contracts (XOptions+XOptionsType) type-only from./XOptions.js, and — when it validates — additionally value-importsXOptionsValidator. There is no runtime cycle:XOptions.tsnever imports the functional file, so the value edge only runs one way. -
A builder is its settings.
OptionsBuilder.setwrites each field as an own enumerable property, so a builder instance is structurally a bag of the fields you set (thewithX/buildmethods stay on the prototype and never surface when it's spread or serialized). Consumers take theXOptionsunion and read the argument directly — there is noresolvehelper:const s = options as XOptionsType(or, to snapshot / merge,{ ...defaults, ...(options as Partial<XOptionsType>) }). A plain object and a builder are fully interchangeable. Keep the union (XOptions) in the signature — a methods-only builder is not assignable to a bareXOptionsType(TS weak-type check). Broker actors need nothing:BrokerActor's constructor takes the union and snapshots it, so subclasses justsuper(options). A subclass/consumer that chains builder methods on its parameter must type that parameterXOptionsBuilder(the union has no methods). -
Builder-first is the documented/primary style — docs and examples show the builder; the plain object is the shorthand alternative (mention it once per page, don't lead with it).
-
Never nest a builder into a call — always assign it to its own contextual local variable first (
const mqttOptions = MqttOptions .create()…; new MqttActor(mqttOptions)), then pass the variable. -
Write builder chains multi-line — one
.withX()per line — when there are two or more. A chain with a single.withX()stays on one line (const mqttOptions = MqttOptions.create().withClientId('x')) — forcing a lone call onto its own line reads worse. Two or more calls always go one-per-line (never a single-line multi-call chain). -
HOCON precedence is unchanged — the builder / plain object feeds only the highest-precedence explicit layer; unset fields fall through to HOCON, then built-in defaults.
-
Issue-first. Before starting work, check for an existing issue (
gh issue list, or search the tracker). If one exists, work against it and take its discussion into account. If none exists, open one first — for traceability — using the matching template in.github/ISSUE_TEMPLATE/(bug / feature / documentation / security). -
Close via the commit body: when the work lands, close the issue with a
Closes #NNN(orFixes #NNN) line in the commit body. GitHub resolves it once the commit reaches the repository's default branch — heredevelop, notmain— so the issue closes on the nextdeveloppush rather than at release time. There is no release-window in which to reconsider: only add the line when the issue is genuinely finished. -
Open an issue before non-trivial work to align on the approach first.
-
Comment on the issue whenever the work changes course. If something you find while working changes the diagnosis, the approach, the scope, or your confidence in any of them, say so on the issue as you find it — a new comment, not an edit to the body, so the sequence stays readable.
The commit message records what was done and why; it is a poor place for what turned out to be wrong on the way there, and it is invisible to anyone reading the issue later. What is worth a comment:
- The report is inaccurate or stale. The defect is already fixed, half-fixed, differently caused than described, or reproduces only under a precondition the report omits. Say which part still stands.
- The obvious fix does not work. Record the attempt and why it
failed, so the next person does not spend the same hour. (
Object.assignreintroducing a prototype-pollution bug verbatim, because it is[[Set]]too, is exactly this.) - A chosen bound, default or name changed after measuring. Give the numbers that moved it.
- The scope moved. The fix turns out to need a different layer, a new seam, or an API change the issue never mentioned — or part of it belongs in another issue. Note the split and where the rest went.
- A verification step proved nothing. If a check you relied on was invalid, that matters more than the result it produced.
This is the same reasoning as Issue-first: the value is traceability for whoever picks the thread up next, including you in six months. A duplicate, a wrong severity, or a fix that was tried and abandoned is worth more written down than re-derived.
- Label taxonomy:
priority: {high,medium,low},severity: {critical,high,medium,low},security,i18n,infrastructure,dependencies,production-goal, plus the standardbug/enhancement/documentation. Audit-catalog items use the title prefixes[Security]/[Feature]. production-goalmarks the path to production readiness — it is a gate, not a batch marker, so it belongs on any issue that blocks or defines that path regardless of which review found it, including ones filed long before. Filtering on it should answer "what is still between us and running this for real", which is why it is applied to existing issues rather than duplicating them.- Security-first posture: cap untrusted input (e.g. WebSocket /
wire-frame size limits), never trust client-supplied integrity fields,
use crypto-grade randomness for wire identifiers. A security-relevant
change gets a
SecurityCHANGELOG entry and aseverity:label.