Imported from johnhenry/signalle (
AGENTS.md). Install upstream withnpx skills add johnhenry/signalle. Copyright stays with the author.
Agent playbook
signalle -- a fine-grained JavaScript signals library with optional DOM
integration, SSE streaming, scoped isolation, and cross-tab/worker
broadcast. Single package, Node >= 26, node --test (npm test), builds to
dist/ via a plain file copy (npm run build copies src/*.mjs and
src/types.d.ts, no transpile/bundle step). Published on npm as unscoped
signalle, not yet moved into the @johnhenry scope.
CLAUDE.md in this directory is a symlink to this file.
The verification loop (before every push)
npm test--node --test tests/*.test.mjs.tests/dom.test.mjsneeds thejsdomdevDependency to load at all -- if it's ever removed or not installed, that suite silently can't run rather than failing loudly; confirm it actually ran, not just that the command exited 0.npm run build && npm pack --dry-run-- read the file list.filesis["dist", "LICENSE", "README.md"]; the build script copies everysrc/*.mjsfile intodist/by glob, so a new file added undersrc/ships automatically -- and so does anything left insrc/that was never meant to ship (check the glob result, not just that the build succeeded).- A genuinely fresh clone:
git clone . /tmp/signalle-verifyN && cd $_ && npm ci && npm run build && npm test. This is the only way to catch "works on my checked-out tree" bugs -- this repo has direct history of it:signalle/streamwas declared inpackage.jsonexportswith nosrc/stream.mjsbehind it, andsignalle/dom'sdefaultOptionstype export didn't exist at runtime either (both fixed in0.1.0, seeCHANGELOG.md). - Commit, push, close the issue with a comment naming the commit SHA.
CI (.github/workflows/ci.yml) runs npm ci, npm run build, then
npm test; match it locally.
Repo-specific gotchas
- Never use the top-level
createEffectinside aSignalScope. Its auto-dependency-tracking uses a single module-static tracker shared by the whole process, so it will not auto-track signals created viascope.signal()/scope.computed()-- reading a scoped signal inside a plain top-levelcreateEffect(...)simply doesn't register a dependency, and using it as an isolation boundary between concurrent logical contexts (e.g. two server requests) corrupts state across them. Always usescope.createEffect(fn)inside a scope; it has its own independent tracker. Fixed in0.1.0(seeCHANGELOG.mdand the README's## Scoped Signalswarning) -- don't reintroduce a shared tracker when touchingcreateEffectWithTracker. Computed's dirty-check must track one version per dependency, not a single collapsed "high water mark." Every signal's version counter starts at 0 and increments independently, so two unrelated dependencies routinely land on the same version number -- aMath.max-style collapsed check can mask a genuine update to one dependency in a diamond-shaped dependency graph. Fixed in0.1.0; any change toComputed's dirty-check needs a per-dependency version map, not a single scalar.batch()nesting needs a depth counter, not a shared boolean/queue. Abatch()call nested inside another used to flip the shared flag off and drain the entire queue on exit, prematurely flushing the outer batch's still-pending updates. Fixed in0.1.0with a nesting-depth counter -- only the outermostbatch()may flush.BroadcastSignal's equality check must compare against the value beforestructuredClone(), not after. Comparing two independently cloned copies of an object/array is never===equal, so re-assigning the exact same unchanged reference used to still bump the version and re-broadcast to every other tab. Fixed in0.1.0.Computed#dispose()must callsuper.dispose(). It previously didn't, so subscribers added viasubscribe()were never released; a disposed computed could also be resurrected viaupdate(), which bypassed the "cannot modify computed signal directly" guard entirely. Fixed in0.1.0-- any new bypass of the basedispose()/mutation guards reintroduces this.BroadcastSignalneeds a join-time sync, not just a shared-looking version counter. Each instance used to track its own version counter starting at 0 with no way to learn what version/value already-open peers had reached -- a tab opened after others had already written several times started from its own staleinitialValueand never caught up, and its own subsequent writes (still counted from its own low version number) were ignored by peers that were already ahead. Fixed in0.1.2by extending the same message protocol (no separate handshake channel): every instance posts async-requeston construction, and any peer with real state (#version > 0) answers with astatemessage -- the exact same self-describing{ id, value, version }shape used for ordinary updates, so a receiver can't tell (and doesn't need to) whether astatemessage is an organic update or a sync reply. When changing#adopt(): astatemessage can legitimately arrive more than once for the same version (an update and a redundant sync-reply can cross in flight), so it must only call#notifyEffects()when the value is actually changing (Object.ischeck) -- otherwise a tied(version, id)still re-adopts and double-fires subscribers, which is exactly as observable a bug as the original one and was caught the same way (a repro run in a loop, not a single run -- it only reproduced intermittently, depending oncrypto.randomUUID()ordering between the two instances).toSSEResponse/toReadableStreammust enqueue bytes, not strings. AResponse/ReadableStreambody contract requiresUint8Arraychunks; enqueueing a raw SSE-formatted string worked for nothing that actually reads the stream as bytes (res.text(),res.arrayBuffer()) and threwTypeError: Received non-Uint8Array chunkthe moment it was. Fixed in0.1.2with a sharedTextEncoderencoding each chunk beforecontroller.enqueue(). Noteres.text()only rejects fast on a bad chunk type -- it does NOT resolve quickly even once fixed, because it buffers the entire body until the stream closes, and an SSE stream is intentionally long-lived and never closes on its own; test that path with a shortPromise.race()timeout, not a bareawait res.text().
Definition of done
A change is done when all of the following hold, not just when tests pass:
- A regression test exists for any bug fixed -- every gotcha above now has one; a fix without a test that would have caught it can come back unnoticed.
- Anything the feature does not do is stated in the README (the
## Security modelsection forgenerateWorkerCode(), or the relevant API section otherwise), not only in an issue comment. CHANGELOG.mdhas an entry citing the commit/PR.- New public exports are added to both
package.jsonexportsandsrc/types.d.tsin the same change -- thesignalle/streamgap (declared inexports, missing fromsrc/, undocumented in types) shipped because those three didn't move together.
Non-goals
Sandboxing or validating signalCode passed to generateWorkerCode() is
explicitly out of scope for this package -- see the README's
## Security model. Pair it with a real sandbox (@johnhenry/andbox) if
you need that.
Releases
Bump version in package.json in a PR, add the CHANGELOG.md entry, merge,
then gh release create v<version> -- the release event triggers
.github/workflows/publish.yml, which is idempotent (skips if the version is
already on npm).
