Skip to content

js: make @moq/watch component signals explicit inputs vs outputs#1592

Open
kixelated wants to merge 1 commit into
devfrom
claude/hopeful-ellis-ee6de7
Open

js: make @moq/watch component signals explicit inputs vs outputs#1592
kixelated wants to merge 1 commit into
devfrom
claude/hopeful-ellis-ee6de7

Conversation

@kixelated
Copy link
Copy Markdown
Collaborator

Why

A long-standing complaint about @moq/watch (and @moq/publish) is that you can't tell, from a component's public surface, which signals you're meant to drive (inputs) and which you're meant to read (outputs). Everything was a bare Signal<T>, so nothing stopped a caller from .set()-ing a value the component owns, and wiring one component's output into another's input gave no protection against clobbering it.

This reshapes every @moq/watch component around an explicit input / output split. @moq/publish is intentionally left for a follow-up.

The pattern

Three small helpers added to @moq/signals:

  • getter(value) — read-only counterpart to Signal.from. Reuses a passed Signal/Getter (so one component's output wires straight into another's input) or wraps a raw value.
  • readonlys(map) — identity-at-runtime cast exposing a record of Signals as read-only Getters.
  • InputProps<I> — derives a component's { [k]?: value | Signal } constructor props from its input map, so the props contract lives in one place.

Each component now looks like:

type FooInput = { enabled: Getter<boolean> };          // the component only READS these
type FooOutput = { status: Signal<Status> };           // the component WRITES these
export type FooProps = InputProps<FooInput>;

class Foo {
  readonly input: Readonlys<FooInput>;                 // read-only to callers
  readonly #output: FooOutput = { status: new Signal("offline") };
  readonly output = readonlys(this.#output);           // read-only to callers
}
  • Inputs are read-only Getters. Whoever owns the backing Signal (the caller, or another component whose output is wired in) does the writing. This is what stops renderer.input.frame.set(...) from clobbering the source.output.frame it's wired to.
  • Outputs are read-only Getters over private Signals the component owns.
  • TypeScript enforces both: calling .set() on an input/output, or reassigning one, is a compile error.

Owner-at-top

Because inputs are read-only, the mutable source of truth for user controls (volume, muted, paused, latency, the video target, plus name/reload/catalogFormat/catalog) now lives at the top — the <moq-watch> element owns those writable Signals and wires read-only views down into broadcast/backend. The UI and the element's attribute/property accessors read and write the element's controls group. MultiBackend and everything below only ever see read-only inputs.

This required relocating a few feedback policies to their owners (these are the behavioral parts — see Testing):

  • Source↔Sync cycle broken: Source no longer holds Sync; it produces output.jitter, which MultiBackend wires into Sync's video/audio inputs. Sources are constructed before Sync.
  • Decoders/MSE take sync as a constructor arg instead of reaching through source.sync.
  • Video enable policy (paused ? !frame : visible) moved from Renderer (which now just exposes output.visible) to MultiBackend, which owns the decoder's enabled input.
  • Audio enable is now the emitter's output.enabled, proxied into the audio decoder.
  • volumemuted coupling (mute zeroes/stashes volume; volume 0 reports muted) moved from the audio Emitter to the element owner. Note: it now runs in both backends; previously it only ran in the WebCodecs path (the emitter didn't exist under MSE).
  • Native <video> volumechange → volume moved from the audio MSE backend (which now only applies volume one-way) to the element.
  • Per the design discussion, this means a standalone MultiBackend no longer bundles the mute-coupling policy; the owner provides it.

Other changes

  • @moq/hang Container.Consumer's latency widened from Signal<Time.Milli> to Getter<Time.Milli> (it only ever .peek()s it) so a component's read-only output.buffer can be passed directly. Backward compatible (a Signal is a Getter).
  • Fixed a latent bug surfaced by explicit ownership: Video.Mse.close() used to close the shared #videoSource (owned by MultiBackend), causing a double-close and a use-after-close if the <canvas>/<video> element was swapped. It no longer does, matching Audio.Mse.
  • Updated the two external low-level consumers: demo/web and @moq/boy (which builds its own pipeline from Watch.Sync/Decoder/Renderer/Source) — new constructors + read-only inputs (it now owns its own canvas/target signals).

Testing

  • bun run --filter='*' check (tsc + unit tests) passes across all 11 JS packages.
  • biome check clean.
  • Not automatically verified: the real-time media pipeline (decode/sync/buffer/MSE) has no automated tests, so the behavioral relocations above warrant a manual smoke test — WebCodecs and MSE playback, mute/unmute + volume slider, pause/resume, quality switching, and the moq-boy grid (expand/collapse, audio on hover).

🤖 Generated with Claude Code

(Written by Claude)

Adds input/output grouping to every @moq/watch component so callers can tell at a
glance which signals they drive and which they read, and so outputs can't be
clobbered. See PR description for the full rationale.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant