js/CLAUDE.md
August 12, 2026 ยท View on GitHub
Scopes the /js TypeScript/JavaScript workspace. Universal rules (writing style / no em-dashes, Root Cause First, Cross-Package Sync, Public API Scrutiny, Refactor As You Go, comment/doc conventions) live in the root CLAUDE.md; PR/commit/release mechanics live in the root CONTRIBUTING.md. Neither is repeated here.
Workspace layout
Bun workspaces; members listed in the repo-root package.json (not in js/). Deps hoist to the repo root node_modules, not into js/. Run recipes via just js <recipe> (see js/justfile). Packages, grouped by role (each mirrors its rs/ counterpart where one exists), roughly in dependency order:
Foundation
@moq/signals(signals/): reactive core.Signal,Computed,Effect, plus framework adapters at subpaths./solid,./react,./dom. No deps on other workspace packages. Everything below uses it.
Transport / protocol
@moq/net(net/): browser networking. Connect to a relay, then publish/consume broadcasts/tracks/groups/frames over WebTransport (WebSocket fallback). Negotiatesmoq-lite(lite/) or IETFmoq-transport(ietf/). Mirror ofrs/moq-net. Optionalzodpeer dep for./zodJSON-frame helpers.@moq/wasm(wasm/): experimental browser bindings forrs/moq-wasm(wasm-bindgen overmoq-net); typed npm wrapper built viajust wasm.
Container / catalog formats
@moq/loc(loc/): Low Overhead Container frame encoding. Thin layer on@moq/net.@moq/json(json/): JSON over a track, in two namespaces.Snapshotis lossy latest-value (RFC 7396 merge-patch deltas; consumers only get the most recent value; the baseSnapshot.Producer/Snapshot.Consumerthat@moq/hang's catalog extends);Streamis a lossless append-log (every record preserved in order). DEFLATE via@moq/flate.@moq/flate(flate/): group-scoped DEFLATE primitive (only deps onpako).Encoder/Decoderturn a stream of payloads into self-delimited sync-flushed frames sharing one window; wire-interoperable with the Rustmoq-flatecrate. Used by@moq/json.@moq/msf(msf/): MOQT Streaming Format catalog types (zod schemas).
Media
@moq/hang(hang/): WebCodecs media layer. Subpaths./catalog,./container,./util. Mirror ofrs/hang. Catalog is a JSON track describing other tracks; container frames are timestamp + codec bitstream (CMAF undercontainer/cmaf).@moq/watch(watch/): subscribe + decode + render, with optional UI. Subpaths.,./element,./ui,./support.@moq/publish(publish/): capture + encode + publish, with optional UI. Same subpath shape as watch.
Apps / examples
@moq/boy(moq-boy/): MoQ Boy web viewer. The only package using.tsx/Solid.@moq/clock(clock/): private native example (publish/subscribe a clock).@moq/token(token/): JWT generation/validation (jose); also ships amoq-tokenbin. Mirror ofrs/moq-token.
Top-level entrypoints re-export their deps under namespaces (export * as Net from "@moq/net", Signals, Hang) so consumers get one import. Lite/Moq aliases are @deprecated, use Net.
Signals + Effect (the reactivity model)
This is the spine of the JS code; read signals/src/index.ts before touching reactive code.
Signal<T>: mutable observable.set/update/mutatewrite,peekreads without subscribing. Writes are coalesced per microtask; subscribers fire only when the value actually changed. Equality is deep for plain objects/arrays but identity (===) for class instances (twoBroadcastinstances are never equal). Force a notify withset(v, true); suppress withset(v, false).Signal.from(x)wraps non-signals; cross-package-version identity uses aSymbol.forbrand, notinstanceof.Computed<T>: read-only derived signal. Itsfnreads deps witheffect.get(...)just like an effect. Value isundefineduntil the first run completes and afterclose(); always handle theundefinedcase. A standaloneComputedmust beclose()d; one made viaeffect.computed()is closed with its parent.Effect: runsfn(effect), reruns whenever a tracked signal changes. Track deps insidefnwitheffect.get(signal)(returns current value and subscribes).effect.getAll([...])reads several and returnsundefinedif any is falsy.
Lifecycle and cleanup (the rules that actually bite):
- Register teardown with
effect.cleanup(fn). Everything registered during a run is torn down before the next run and onclose(). A run that is already over runsfnimmediately, so aspawntask that resumes after a rerun still releases what it acquired: register teardown unconditionally rather than checking staleness first. A stale task may still want to bail for its own reasons, since work outside the effect's scope (plain fields, backoff counters) isn't unwound for it.close()is permanent; reruns are not. - A rerun does not open the next run until every
spawntask from the previous one settles, which is what keeps the guarantee above unconditional. Teardown runs first and closes whatever those tasks await, so they unwind from there. A task that ignores cancellation stalls the rerun instead of leaking (it warns after 5s in dev);close()always works and releases everything. - Use the Effect-scoped helpers instead of raw timers/listeners so cleanup is automatic:
effect.interval,effect.timer,effect.timeout,effect.animate,effect.event(target, type, listener)(merges anAbortSignal),effect.subscribe(sig, fn)(runs now + on change),effect.set(sig, value, cleanup),effect.proxy(dst, src). Do NOT reach for rawsetInterval/setTimeout/requestAnimationFrame/addEventListenerinside an effect. - Nesting:
effect.run(fn)/effect.computed(fn)create child scopes closed with the parent. Prefer nested effects over one giant effect so unrelated deps do not re-trigger each other. - Async:
effect.spawn(() => Promise<void>)runs a task and blocks the next rerun until it settles (warns after 5s, but keeps waiting).effect.cancel(promise) andeffect.abort(AbortSignal) are per-run and fire when the current run is torn down;effect.closedresolves onclose(). - DEV warnings catch leaks: a signal passing ~100 subscribers throws ("may be leaking"); an effect that subscribed to nothing warns ("will never rerun"); a
FinalizationRegistrywarns if an Effect is GC'd withoutclose(). If you see these, you forgot aclose()or tracked the wrong thing.
Producer / consumer and pub/sub shapes
Networking objects split state from behavior: a plain XxxState class holds Signal fields, and the public Xxx class wraps it (see net/src/broadcast.ts, track.ts, group.ts). The publisher side answers requested() (await the next subscribed track) and writes; the consumer side subscribe(name, priority)s and reads. Terminal state is a single closed: GetPromise<Error | null> backed by a Once: one handle serves the sync check, the reactive read (effect.get / Signal.race), and the await. Three states, so test them explicitly: undefined is open (the Once pending sentinel), null is a clean close, an Error is an abort. if (closed) means "aborted", not "closed" -- use closed.peek() !== undefined for "is it closed". Once.set throws on a second settle, so every close() path guards on peek() !== undefined and is idempotent; it's a thenable, not a Promise, so use .then() rather than .finally()/.catch(). @moq/json and @moq/hang/catalog follow the same Producer/Consumer pair, with hang's catalog Producer/Consumer extending json's generics.
Component shape: in / out / knobs
Every reactive component in watch/publish follows one shape (see publish/src/video/encoder.ts):
readonly in: Readonlys<XxxInput>: the wired dependencies, built in the constructor withgetter(props?.x ?? default). Read-only to consumers: wire another component'soutstraight in (capture: this.capture), or pass aSignalyou keep a handle to. Export theXxxInputmap so consumers can name it.readonly out = readonlys(this.#out): derived state. The class writesthis.#out.x; consumers only read. Never hand out a writableSignal: it lets a caller forge state behind the owner's back.- Knobs stay public writable
Signals outside both groups (encoder.config,audio.codec,device.preferred). They're live-editable settings the component doesn't derive, and typedT | Signal<T>in props viaSignal.from.XxxProps = Inputs<XxxInput> & { ...knobs }. - Positional identity (a name, a kind) stays a plain constructor arg, not a signal.
- When a parent legitimately produces one of a child's outputs, give the child a method that returns a dispose handle (
Device.capture(deviceId)), rather than exposing the backingSignal. #signals = new Effect()is private;close()is the only handle. The two custom elements (MoqWatch/MoqPublish) are the exception: they exposereadonly signalsas the documented place for an app to hang its own reactivity.
Web Components UI (watch/ui, publish/ui)
Plain custom elements built directly on @moq/signals, no framework (except moq-boy, which uses Solid). The pattern, from watch/src/element.ts and watch/src/ui/element.ts:
class Foo extends HTMLElementwithstatic observedAttributes. Attributes are the public API; mirror each into aSignalon the element'sreadonly controlsbag inattributeChangedCallback.- An invalid attribute value warns and falls back to the default; never throw.
attributeChangedCallbackruns from the browser, so a throw surfaces as an unhandled error and leaves the element half-configured. (Theexhaustive: neverthrow for an unknown attribute name is unreachable and stays.) - Boolean attributes parse through
parseBoolean(value, default): absent uses the default, bare presence is true, and an explicit"false"/"0"is false. Reflect them back as a bare attribute (setAttribute("muted", "")), never"true". - Create the
EffectinconnectedCallback, calleffect.close()indisconnectedCallback. A module-levelFinalizationRegistrycloses the Effect if the element is GC'd without disconnect (there is no real destructor for custom elements). - Build DOM with
@moq/signals/dom(create, reactive helpers) and drive visibility/content fromeffect.get(...)insideeffect.run(...). UI components are functions(parent: Effect, host) => HTMLElementthat register their own reactivity onparent(seewatch/src/ui/components/*). - Styles are imported as
?inlineCSS strings into aShadowRoot. The./element/./ui/./supportsubpaths are side-effectful (they callcustomElements.define); the package marks them insideEffectsand they are NOT re-exported from the main entry (import from the subpath). These web-component packages set"jsr": falsebecause JSR forbids theHTMLElementTagNameMapaugmentation custom elements need.
Conventions
- Retry loops use capped backoff with jitter (root Retries has the policy). For a local loop, escalate a delay toward a
max, jitter each wait (delay * (0.5 + Math.random() / 2)), and hand it toeffect.timer. Reuse an existing operation-specific retry abstraction when one owns the sequence already. - Avoid callback parameters. A function taking a
fn/create/onXxxto invoke later reads poorly and hides control flow. Prefer returning a value the caller acts on, exposing a method or getter, or splitting into a couple of small calls the caller sequences itself (e.g. a cacheget()theninsert(value), notgetOrCreate(key, () => value)). Reserve callbacks for genuine event/subscription sinks where there is no alternative (effect.subscribe, DOM listeners,Signalsubscriptions). - ESM only (
"type": "module"). Relative imports include the.ts/.tsxextension in the lower-level packages (net,signals,hang);rewriteRelativeImportExtensionsintsconfig.jsonrewrites them to.json build. Some higher-level packages (watch/publish) still omit extensions, so match the file you are editing. - Document every exported symbol and add a top-of-file
@moduledoc block to each entrypoint (root convention; the published JSR/.d.tsdocs render these). Use@publicon the load-bearing classes. - Deprecation mechanics (root Deprecation explains the why): mark a deprecated export
@internalor drop it from the entrypoint re-exports so it falls off the published JSR/.d.tsdocs. No "deprecated, use X" note in its doc comment. - Build is per-package:
tsc -b tsconfig.build.json(orvite buildplustsc -p tsconfig.build.jsonfor the bundled UI/web-component packages) thenbun ../common/package.ts, which rewritespackage.jsonexports from./src/*.tsto built./*.js/.d.tsand runspublint. Release viabun ../common/release.ts. tsconfig.build.jsonexists only to dropsrc/**/*.test.tsfrom the emit;tsconfig.jsonis whattsc --noEmit(thecheckscript) and your editor use, so tests are still type-checked. Keeping tests out ofdist/matters twice over:package.tsglobs all ofdist/into the published tarball, and a compiled test indist/is a second runnable copy thatbun testdiscovers at the package root and runs against the wrongimport.meta.dir.
Tooling and testing
-
Use
bunfor everything (install, scripts, test runner). Never npm/yarn/pnpm. -
Biome handles formatting and linting; config is the repo-root
biome.jsonc(tabs, width 4, line length 120).just fixrunsbun biome check --write. -
Tests are
*.test.tsrun bybun test. Add tests where easy (signals, varint, path, ring buffers, sync all have them). -
just js checktype-checks, biome-checks, and builds every package;just js testruns all unit tests. From repo root these arejust check/just test/just fix. Rootjust check/just test/just fixskip the JS half entirely when the branch's diff has no JS files, but run all of it otherwise (unlike the Rust half, which scopes down to the changed crates:tsc -bis fast enough that per-package selection wasn't worth the machinery). -
buildis part ofcheck, not a separate gate. The per-packagecheckscript istsc --noEmit, which never runs declaration emit, so the errors that only appear when writing a.d.ts(TS4023 and friends) passcheckand then break a JSR/npm publish. The whole workspace builds in about 20s, which is cheap enough to fold in rather than discover at release time. -
For UI / web changes (
watch,publish,demo/web, anything touching playback or the<moq-watch>/<moq-publish>components), don't stop at unit tests: runjust devand exercise the change in a real browser via the Claude-in-Chrome plugin (if installed), since WebTransport + WebCodecs playback only surfaces at runtime.<moq-watch>gates video download/render onintersecting && !document.hidden, so a tab that isn't the frontmost visible one renders black at 0 fps even while bytes download (the Claude-in-Chrome tab often reportsdocument.hidden). Setvisible="always"on the element to bypass the gate (it forces download regardless of viewport or tab visibility), or bring the browser window frontmost sovisibilityStateflips tovisible.