From 52077ed997f201781808b69c3bb97efddd1c8fa7 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Wed, 19 Aug 2026 17:43:58 +0200 Subject: [PATCH] Fix example statechart modeling --- README.md | 42 +- docs/agent-guide.md | 64 ++- examples/playground/README.md | 22 +- .../playground/src/examples/examples.test.ts | 11 +- .../examples/media-player/MediaPlayerPage.tsx | 50 +- .../src/examples/media-player/atoms.ts | 137 ++++- .../src/examples/media-player/definition.ts | 24 - .../src/examples/media-player/invocations.ts | 34 -- .../src/examples/media-player/machine.test.ts | 50 +- .../src/examples/media-player/machine.ts | 518 ++++++++++++------ .../src/examples/media-player/schemas.ts | 162 ------ .../src/examples/media-player/service.ts | 499 ++++++++--------- .../src/examples/media-player/view.ts | 154 ------ .../src/examples/microwave/MicrowavePage.tsx | 13 +- .../src/examples/microwave/machine.ts | 53 +- examples/playground/src/router.tsx | 8 +- examples/pokemon/README.md | 4 +- examples/pokemon/src/atoms.ts | 10 + examples/pokemon/src/machine.ts | 20 +- examples/pokemon/src/machines/replace.ts | 52 +- examples/pokemon/src/machines/selection.ts | 168 +++--- examples/pokemon/src/pokemon.ts | 14 +- examples/pokemon/src/router.tsx | 18 +- 23 files changed, 1015 insertions(+), 1112 deletions(-) delete mode 100644 examples/playground/src/examples/media-player/definition.ts delete mode 100644 examples/playground/src/examples/media-player/invocations.ts delete mode 100644 examples/playground/src/examples/media-player/schemas.ts delete mode 100644 examples/playground/src/examples/media-player/view.ts create mode 100644 examples/pokemon/src/atoms.ts diff --git a/README.md b/README.md index cbc0ea5..3636f4d 100644 --- a/README.md +++ b/README.md @@ -49,11 +49,14 @@ import { Machine } from "@typeonce/effect-machine" import { Effect, Schema, Stream } from "effect" const State = Schema.TaggedUnion({ - Idle: {}, Running: { count: Schema.Number } }) -const States = Machine.states(State.cases) +const States = Machine.states({ + Idle: {}, + Running: State.cases.Running +}) + const CounterEvent = Machine.events( Schema.TaggedUnion({ Start: {}, @@ -127,6 +130,23 @@ or `Machine.Snapshot`, schema-backed state payloads as `Machine.Value`, and path-rooted snapshots as `Machine.SnapshotAt`. +### Make invalid states unrepresentable + +Treat topology as a domain contract, not as file organization. A parallel state +declares the full Cartesian product of its regions, so use it only when every +combination has a coherent meaning. If one region must inspect another before +entering a state safely, prefer a compound hierarchy that makes the forbidden +combination impossible. `matches` remains useful for views, tests, and genuine +coordination between independent regions; it should not repair an invalid +state product. + +Keep state-scoped Effects beneath the state that guarantees their resources, +and enforce command availability in the machine rather than only by disabling +UI controls. When entering an inactive compound or parallel state's declared +default, select `.initial`; explicitly construct descendants only for a +non-default configuration or a complete replacement of an already-active +parallel root. + ### Construct state through builders Use `.from(...)` when constructing a new state from fields: @@ -153,7 +173,8 @@ const handlers = { } ``` -Omit `schema` when a state represents control flow but owns no data: +Omit `schema` when a state represents control flow but owns no data. Use `{}` +instead of defining an empty tagged schema: ```ts const States = Machine.states({ @@ -178,6 +199,11 @@ Schema-less states remain active, targetable, matchable, and visible through their handler `state` is `undefined`, and `get` / `getWithParents` accept only schema-backed paths. Add a schema later if the state starts owning data. +Keep data-bearing state schemas together in a named `Schema.TaggedUnion` and +reference its cases from the topology. For a standalone state schema whose +class identity is useful, declare a named `Schema.TaggedClass`. Do not bury +one-off tagged schema declarations inside `Machine.states`. + Put data on the narrowest state where it is valid. If sibling phases share data, put it on their compound parent. @@ -646,11 +672,11 @@ Each ESM entrypoint is independent and tree-shakeable. Every package directly under [`examples/`](./examples) has its own lockfile and `check` script. -| Example | What it demonstrates | -| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| [Playground](./examples/playground) | Five focused React examples: atomic turnstile commands, state-scoped traffic-light timers, microwave safety across parallel regions, a service-backed media player, and a worker-hosted machine synchronized across tabs | -| [Pokémon](./examples/pokemon) | Compound and parallel states, invoked child machines, typed emissions, Atom reactivity, and a live Effect service | -| [Platformer](./examples/platformer) | Nested parallel statecharts, typed deep history, raised events, state-scoped timers, deterministic model tests, and a playable SVG adapter | +| Example | What it demonstrates | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Playground](./examples/playground) | Five focused React examples: atomic turnstile commands, state-scoped traffic-light timers, hierarchical microwave safety, a resource-owned media player, and a worker-hosted machine synchronized across tabs | +| [Pokémon](./examples/pokemon) | Compound workflow states, invoked child machines, typed emissions, Atom reactivity, and a live Effect service | +| [Platformer](./examples/platformer) | Nested parallel statecharts, typed deep history, raised events, state-scoped timers, deterministic model tests, and a playable SVG adapter | The playground is the shortest path from one concept to working code. The standalone examples show larger composition and ownership boundaries. diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 7ea0688..dfd2466 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -54,12 +54,15 @@ testing, or simulation variant. ```ts const State = Schema.TaggedUnion({ - Idle: {}, Saving: { draft: Draft }, Failed: { message: Schema.String } }) -const States = Machine.states(State.cases) +const States = Machine.states({ + Idle: {}, + Saving: State.cases.Saving, + Failed: State.cases.Failed +}) export const Event = Machine.events( Schema.TaggedUnion({ Save: {} @@ -140,6 +143,23 @@ its extra control is required: ## Atomic, compound, parallel, and history states +### Topology is a validity boundary + +Design the state tree so invalid domain situations cannot be constructed. A +parallel node is not merely a convenient grouping of related concepts: it +declares the Cartesian product of its regions. Every combination must be +meaningful in snapshots, explicit targets, decoding, and resume. + +If a handler reads a sibling region to decide whether entering its target is +legal, treat that as a topology smell and try a compound hierarchy first. Do +not move the same invariant into a disabled UI control, redundant event field, +or invoked-service failure. Cross-region reads remain useful for coordinating +genuinely independent regions and for projecting snapshots into views. + +Place an invoked Effect or resource-dependent state beneath the state that +guarantees the resource exists. Exiting the owner should structurally exit and +interrupt all dependent work. + ### Inline topology by default; extract only repeated states Prefer writing the complete topology inline in `Machine.states`. A one-off @@ -234,7 +254,8 @@ machine-bound forms over `.cases.Case.Type`, `typeof States.states`, or composing `Machine.Machine.States` with raw-tree path extractors. An active state does not need a schema unless it owns data. Omit `schema` for -control-only atomic, compound, parallel, and final states: +control-only atomic, compound, parallel, and final states. In particular, use +`{}` instead of an empty tagged-union case or tagged class: ```ts const States = Machine.states({ @@ -258,11 +279,7 @@ in snapshots. They do not have a state value: ```ts Idle: { on: { - Start: (to) => - to.full.Form.initial.resolve(({ state, target }) => { - // state: undefined - return target.from((form) => form.Editing.from()) - }) + Start: (to) => to.full.Form.initial } } @@ -277,6 +294,13 @@ also omitted from `ancestors`; an immediate structural containing state is typed as `undefined`. Add `schema` when a state begins to own data or needs runtime validation and persistence for that data. +Declare data-bearing states together with the named +`const State = Schema.TaggedUnion(...)` pattern, then reference `State.cases` +from the topology. This keeps the state value protocol visible and reusable. +Use a named `Schema.TaggedClass` instead when a standalone state benefits from +class identity. Do not bury one-off tagged schema declarations inside +`Machine.states`. + Use an atomic state when no child phase can be active beneath it. Use a compound state when exactly one child phase is active. It must declare an @@ -326,6 +350,11 @@ Every parallel region needs an active state in initial and full snapshot builders. The same rule applies when a local or branch target enters an inactive nested parallel state. +This is also a semantic product: `Online + Closed`, `Online + Open`, +`Offline + Closed`, and `Offline + Open` are all valid configurations in the +example above. If even one combination must be prevented for correctness, use +a compound hierarchy or redesign the regions. + Use `type: "final"` for a terminal leaf in `Machine.states`. A final child completes its compound parent. Put `onDone` on that completed parent, never on the final leaf. The definition owns the output schema and the handler @@ -363,6 +392,20 @@ with `.initial`. This is available on top-level state methods under Open: (to) => to.full.opened.initial.resolve(({ target }) => target.from({ teamId: "team-1" })) ``` +Return the `.initial` transition directly when the selected state owns no data +and the transition has no commands to enqueue: + +```ts +Close: (to) => to.full.closed.initial +``` + +Do not manually reconstruct the declared initial descendants at ordinary entry +transitions. Reserve explicit descendant builders for deliberately non-default +configurations and for replacing an already-active parallel root with one +complete canonical configuration. `Machine.make({ initial })` still constructs +the first complete snapshot through its initial selector; that selector +statically restricts a compound node to its declared initial child. + The definition-time `.initial` property is a topology value. The exact resolver `target` is still a callable runtime builder. @@ -624,6 +667,11 @@ Use the existing `States.matches`, `States.get`, `States.getWithParents`, and selected in one microstep receive the same capture. Synchronous handlers use that captured value and cannot consult live runtime state later. +Before using a cross-region read to permit or reject a target, verify that all +combinations of the parallel regions are valid. If the check excludes an +invalid combination, move the invariant into a compound hierarchy. Observer, +view, diagnostic, and test queries do not have this concern. + Do not expect `snapshot` in entry, exit, invoke, initializer, history-default, or choice contexts. Choice is an important soundness boundary: a startup or chained choice can run without a complete stable configuration containing the diff --git a/examples/playground/README.md b/examples/playground/README.md index 07af69c..2111dd3 100644 --- a/examples/playground/README.md +++ b/examples/playground/README.md @@ -18,13 +18,13 @@ pnpm check ## Examples -| Route | Machine concept | Integration concept | -| ---------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------- | -| `/turnstile` | Atomic states, typed commands, ignored events | Service-free `AtomMachine` | -| `/traffic-light` | Internal events, cancellable state-scoped timers, re-entry | Reactive timer-driven rendering | -| `/microwave` | Parallel regions, simultaneous transitions, conditional behavior | Safety-oriented controls | -| `/media-player` | Nested compound/parallel states and state-scoped Effects | Shared Atom runtime, DOM audio, Web Audio service | -| `/worker-tabs` | A machine hosted outside the UI thread | Schema-validated worker messages and `BroadcastChannel` synchronization | +| Route | Machine concept | Integration concept | +| ---------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------- | +| `/turnstile` | Atomic states, typed commands, ignored events | Service-free `AtomMachine` | +| `/traffic-light` | Internal events, cancellable state-scoped timers, re-entry | Reactive timer-driven rendering | +| `/microwave` | Compound hierarchy and unrepresentable invalid states | Safety-oriented controls | +| `/media-player` | Resource-owned compound states and an independent settings region | Shared Atom runtime, DOM audio, Web Audio service | +| `/worker-tabs` | A machine hosted outside the UI thread | Schema-validated worker messages and `BroadcastChannel` synchronization | Each route keeps its machine, adapter, and supporting protocol beside the page. The machines own legal behavior; components project snapshots and send typed @@ -35,10 +35,12 @@ public commands. - The traffic light exposes `Reset` publicly while timer deliveries stay in `internalEvents`. - The microwave stores elapsed time only on `Cooking`, where it is valid. - `DoorOpened` is handled by both active parallel regions, opening the door and - stopping the engine in the same macrostep. + `Cooking` is nested below `Closed`, so opening the door exits and interrupts + cooking and `Cooking + Open` cannot be represented. - The media player keeps browser APIs behind an Effect service. Its invoked - work returns typed internal events to the deterministic transition core. + transport is nested below the registered audio session that it requires, + while sound settings remain an independent parallel region. Invoked work + returns typed internal events to the deterministic transition core. - The worker validates unknown incoming messages with Effect Schema before forwarding public events. Tabs replicate commands and exchange a typed synchronization state when a tab joins. diff --git a/examples/playground/src/examples/examples.test.ts b/examples/playground/src/examples/examples.test.ts index 09b2746..5693d9a 100644 --- a/examples/playground/src/examples/examples.test.ts +++ b/examples/playground/src/examples/examples.test.ts @@ -42,7 +42,7 @@ describe("playground machines", () => { yield* ref.stop })) - it.effect("interrupts cooking and opens the door in one parallel macrostep", () => + it.effect("makes cooking with an open door unreachable", () => Effect.gen(function*() { const trace = yield* MachineTest.run(MicrowaveMachine, { events: [ @@ -56,11 +56,12 @@ describe("playground machines", () => { yield* MachineTest.verify(MicrowaveMachine, trace) const opened = trace.steps[1]?.after - assert.strictEqual(opened?.states.engine.state.path, "Oven.engine.Idle") - assert.strictEqual(opened?.states.door.state.path, "Oven.door.Open") + assert.strictEqual(opened?.state.path, "Oven.Open") assert.deepStrictEqual(trace.steps[2]?.before, trace.steps[2]?.after) - assert.strictEqual(trace.final.states.engine.state.path, "Oven.engine.Cooking") - assert.strictEqual(trace.final.states.door.state.path, "Oven.door.Closed") + assert.strictEqual(trace.final.state.path, "Oven.Closed") + if (trace.final.state.path === "Oven.Closed") { + assert.strictEqual(trace.final.state.state.path, "Oven.Closed.Cooking") + } })) it.effect("restores worker state from a tab synchronization command", () => diff --git a/examples/playground/src/examples/media-player/MediaPlayerPage.tsx b/examples/playground/src/examples/media-player/MediaPlayerPage.tsx index 2167d88..2ed1419 100644 --- a/examples/playground/src/examples/media-player/MediaPlayerPage.tsx +++ b/examples/playground/src/examples/media-player/MediaPlayerPage.tsx @@ -2,35 +2,20 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react" import { Match } from "effect" import { useCallback, useEffect, useState } from "react" import { ExamplePage } from "../../components/ExamplePage.tsx" -import { mediaPlayerAtom, mediaPlayerViewAtom, registerMediaPlayerElement } from "./atoms.ts" -import { MediaPlayerEvents } from "./definition.ts" - -interface AudioSource { - readonly name: string - readonly url: string -} - -const formatTime = (seconds: number): string => { - if (!Number.isFinite(seconds)) return "0:00" - - const minutes = Math.floor(seconds / 60) - const remainingSeconds = Math.floor(seconds % 60) - return `${minutes}:${String(remainingSeconds).padStart(2, "0")}` -} +import { mediaPlayerAtom, mediaPlayerViewAtom, setMediaPlayerElement } from "./atoms.ts" +import { MediaPlayerEvents } from "./machine.ts" export function MediaPlayerPage() { const viewResult = useAtomValue(mediaPlayerViewAtom) const send = useAtomSet(mediaPlayerAtom.send) - const register = useAtomSet(registerMediaPlayerElement) - const [source, setSource] = useState() + const setAudioElement = useAtomSet(setMediaPlayerElement) + const [source, setSource] = useState<{ readonly name: string; readonly url: string }>() const registerAudioElement = useCallback( (audioRef: HTMLAudioElement | null) => { - if (audioRef !== null) { - register(audioRef) - } + setAudioElement(audioRef) }, - [register] + [setAudioElement] ) useEffect( @@ -43,7 +28,7 @@ export function MediaPlayerPage() { return ( {Match.value(viewResult).pipe( @@ -51,7 +36,7 @@ export function MediaPlayerPage() { Initial: () =>
Starting the media player machine…
, Failure: () =>
The media player machine failed to start.
, Success: ({ value: view }) => { - const { settings, status, transport } = view + const { session, settings, status, transport } = view const { canPause, canPlay, canRestart, isPlaying, loudness, playback } = transport const loudnessLevel = Math.min(100, Math.round((loudness?.rms ?? 0) * 220)) const peakLevel = Math.min(100, Math.round((loudness?.peak ?? 0) * 100)) @@ -86,16 +71,6 @@ export function MediaPlayerPage() { ref={registerAudioElement} className="media-audio-element" preload="auto" - onWaiting={() => send(MediaPlayerEvents.MediaWaiting())} - onCanPlay={() => send(MediaPlayerEvents.MediaCanPlay())} - onError={({ currentTarget }) => - send(MediaPlayerEvents.MediaFailed({ - message: currentTarget.error?.message ?? "The selected audio file could not be loaded" - }))} - onTimeUpdate={({ currentTarget }) => - send(MediaPlayerEvents.TimeUpdated({ currentTime: currentTarget.currentTime }))} - onEnded={({ currentTarget }) => - send(MediaPlayerEvents.PlaybackEnded({ currentTime: currentTarget.currentTime }))} />
@@ -109,7 +84,13 @@ export function MediaPlayerPage() {
Current time - {formatTime(playback.currentTime)} + + {Number.isFinite(playback.currentTime) + ? `${Math.floor(playback.currentTime / 60)}:${ + String(Math.floor(playback.currentTime % 60)).padStart(2, "0") + }` + : "0:00"} +
@@ -209,6 +190,7 @@ export function MediaPlayerPage() { {JSON.stringify({ status, states: { + session, transport: transport.path, settings: settings.path }, diff --git a/examples/playground/src/examples/media-player/atoms.ts b/examples/playground/src/examples/media-player/atoms.ts index 0515777..81d678c 100644 --- a/examples/playground/src/examples/media-player/atoms.ts +++ b/examples/playground/src/examples/media-player/atoms.ts @@ -1,22 +1,141 @@ import { AtomMachine } from "@typeonce/effect-machine/reactivity" -import { Effect } from "effect" +import { Effect, Match } from "effect" import { Atom } from "effect/unstable/reactivity" -import { MediaPlayerMachine } from "./machine.ts" +import { MediaPlayerEvents, MediaPlayerMachine } from "./machine.ts" import { MediaPlayer } from "./service.ts" -import { toMediaPlayerView } from "./view.ts" const mediaPlayerRuntime = Atom.runtime(MediaPlayer.layer) export const mediaPlayerAtom = AtomMachine.bind(mediaPlayerRuntime).make(MediaPlayerMachine) -export const mediaPlayerViewAtom = Atom.mapResult(mediaPlayerAtom.snapshot, (snapshot) => ({ - status: snapshot.status, - ...toMediaPlayerView(snapshot.state) -})) - -export const registerMediaPlayerElement = mediaPlayerRuntime.fn((audioRef: HTMLAudioElement) => +export const setMediaPlayerElement = mediaPlayerRuntime.fn((audioRef: HTMLAudioElement | null, get) => Effect.gen(function*() { const mediaPlayer = yield* MediaPlayer yield* mediaPlayer.register(audioRef) + yield* get.setResult( + mediaPlayerAtom.send, + audioRef === null ? MediaPlayerEvents.AudioElementUnmounted() : MediaPlayerEvents.AudioElementMounted() + ) }) ) + +export const mediaPlayerViewAtom = Atom.mapResult(mediaPlayerAtom.snapshot, ({ state, status }) => { + const transportDefaults = { + playback: { currentTime: 0 }, + loudness: null, + error: null, + isPlaying: false, + isBuffering: false, + canPlay: false, + canPause: false, + canRestart: false + } as const + + const session = state.states.session.state + + const transport = session.path === "Player.session.Registered" + ? Match.value(session.state).pipe( + Match.discriminatorsExhaustive("path")({ + "Player.session.Registered.Empty": ({ path, value }) => ({ + ...transportDefaults, + path, + name: "Empty" as const, + value + }), + "Player.session.Registered.Loading": ({ path, value }) => ({ + ...transportDefaults, + path, + name: "Loading" as const, + value + }), + "Player.session.Registered.Ready": ({ state }) => + Match.value(state).pipe( + Match.discriminatorsExhaustive("path")({ + "Player.session.Registered.Ready.Paused": ({ path, value }) => ({ + ...transportDefaults, + path, + name: "Paused" as const, + value, + playback: value, + canPlay: true, + canRestart: true + }), + "Player.session.Registered.Ready.Playing": ({ path, value }) => ({ + ...transportDefaults, + path, + name: "Playing" as const, + value, + playback: value, + loudness: value.loudness, + isPlaying: true, + canPause: true, + canRestart: true + }), + "Player.session.Registered.Ready.Buffering": ({ path, value }) => ({ + ...transportDefaults, + path, + name: "Buffering" as const, + value, + playback: value, + isBuffering: true, + canPause: true, + canRestart: true + }), + "Player.session.Registered.Ready.Restarting": ({ path, value }) => ({ + ...transportDefaults, + path, + name: "Restarting" as const, + value, + playback: value + }), + "Player.session.Registered.Ready.Ended": ({ path, value }) => ({ + ...transportDefaults, + path, + name: "Ended" as const, + value, + playback: value, + canPlay: true, + canRestart: true + }) + }) + ), + "Player.session.Registered.Failed": ({ path, value }) => ({ + ...transportDefaults, + path, + name: "Failed" as const, + value, + error: value.message + }) + }) + ) + : { + ...transportDefaults, + path: session.path, + name: "Unavailable" as const, + value: session.value + } + + return { + status, + session: session.path, + transport, + settings: Match.value(state.states.settings.state).pipe( + Match.discriminatorsExhaustive("path")({ + "Player.settings.Audible": ({ path, value }) => ({ + path, + value, + volume: value.volume, + playbackRate: value.playbackRate, + muted: false + }), + "Player.settings.Muted": ({ path, value }) => ({ + path, + value, + volume: value.volume, + playbackRate: value.playbackRate, + muted: true + }) + }) + ) + } +}) diff --git a/examples/playground/src/examples/media-player/definition.ts b/examples/playground/src/examples/media-player/definition.ts deleted file mode 100644 index ed8faeb..0000000 --- a/examples/playground/src/examples/media-player/definition.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Machine } from "@typeonce/effect-machine" -import { initialAudioSettings, MediaPlayerEvents, MediaPlayerInternalEvents, MediaPlayerStates } from "./schemas.ts" - -export { MediaPlayerEvents, MediaPlayerInternalEvents } from "./schemas.ts" - -export const MediaPlayerDefinition = Machine.make({ - id: "MediaPlayer", - states: MediaPlayerStates.states, - events: MediaPlayerEvents, - internalEvents: MediaPlayerInternalEvents, - initial: (to) => - to.Player.initial.resolve(({ target }) => - target.from((player) => - player - .transport.from((transport) => transport.Empty.from()) - .settings.from((settings) => - settings.Audible.from({ - volume: initialAudioSettings.volume, - playbackRate: initialAudioSettings.playbackRate - }) - ) - ) - ) -}) diff --git a/examples/playground/src/examples/media-player/invocations.ts b/examples/playground/src/examples/media-player/invocations.ts deleted file mode 100644 index be1cd6e..0000000 --- a/examples/playground/src/examples/media-player/invocations.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Effect, Stream } from "effect" -import { type SoundSettings, toAudioSettings } from "./schemas.ts" -import { MediaPlayer } from "./service.ts" - -export const loadAudio = (url: string) => - Effect.gen(function*() { - const mediaPlayer = yield* MediaPlayer - yield* mediaPlayer.load(url) - }) - -export const pauseAudio = Effect.gen(function*() { - const mediaPlayer = yield* MediaPlayer - yield* mediaPlayer.pause -}) - -export const playAudio = Effect.gen(function*() { - const mediaPlayer = yield* MediaPlayer - yield* mediaPlayer.play -}) - -export const restartAudio = Effect.gen(function*() { - const mediaPlayer = yield* MediaPlayer - yield* mediaPlayer.restart -}) - -export const applyAudioSettings = (settings: SoundSettings, muted: boolean) => - Effect.gen(function*() { - const mediaPlayer = yield* MediaPlayer - yield* mediaPlayer.applySettings(toAudioSettings(settings, muted)) - }) - -export const analyzeAudio = Stream.unwrap( - Effect.map(MediaPlayer, (mediaPlayer) => mediaPlayer.loudness) -) diff --git a/examples/playground/src/examples/media-player/machine.test.ts b/examples/playground/src/examples/media-player/machine.test.ts index fa210f0..3f903cc 100644 --- a/examples/playground/src/examples/media-player/machine.test.ts +++ b/examples/playground/src/examples/media-player/machine.test.ts @@ -2,18 +2,16 @@ import { assert, describe, it } from "@effect/vitest" import { Machine } from "@typeonce/effect-machine" import { MachineTest } from "@typeonce/effect-machine/testing" import { Effect, Graph } from "effect" +import { FastCheck } from "effect/testing" import { MediaPlayerMachine } from "./machine.ts" const everyPublicEvent: ReadonlyArray> = [ + { _tag: "AudioElementMounted" }, + { _tag: "AudioElementUnmounted" }, { _tag: "SourceSelected", url: "https://example.com/audio.mp3" }, { _tag: "PlayRequested" }, { _tag: "PauseRequested" }, { _tag: "RestartRequested" }, - { _tag: "MediaWaiting" }, - { _tag: "MediaCanPlay" }, - { _tag: "PlaybackEnded", currentTime: 42 }, - { _tag: "TimeUpdated", currentTime: 21 }, - { _tag: "MediaFailed", message: "unsupported codec" }, { _tag: "VolumeChanged", volume: 0.4 }, { _tag: "PlaybackRateChanged", playbackRate: 1.5 }, { _tag: "MuteRequested" }, @@ -21,24 +19,22 @@ const everyPublicEvent: ReadonlyArray JSON.stringify(left) === JSON.stringify(right) -const settingsEvents = new Set(["VolumeChanged", "PlaybackRateChanged", "MuteRequested", "UnmuteRequested"]) - const invariant = MachineTest.invariants(MediaPlayerMachine) const parallelRegionsAreIndependent = invariant.step( - "transport and settings commands stay in their own parallel region", + "session and settings commands stay in their own parallel region", ({ after, before, event }) => { - const transportUnchanged = same(before.states.transport.state, after.states.transport.state) + const sessionUnchanged = same(before.states.session.state, after.states.session.state) const settingsUnchanged = same(before.states.settings.state, after.states.settings.state) - return settingsEvents.has(event._tag) - ? transportUnchanged || `settings event ${event._tag} changed transport` - : settingsUnchanged || `transport event ${event._tag} changed settings` + if (["VolumeChanged", "PlaybackRateChanged", "MuteRequested", "UnmuteRequested"].includes(event._tag)) { + return sessionUnchanged || `settings event ${event._tag} changed the session region` + } + return settingsUnchanged || `session event ${event._tag} changed the settings region` } ) @@ -85,7 +81,7 @@ describe("media-player statechart model", () => { Effect.tap((trace) => Effect.sync(() => { assert.strictEqual(trace.final.path, "Player") - assert.strictEqual(trace.final.states.transport.state.path.startsWith("Player.transport."), true) + assert.strictEqual(trace.final.states.session.state.path.startsWith("Player.session."), true) assert.strictEqual(trace.final.states.settings.state.path.startsWith("Player.settings."), true) }) ), @@ -107,9 +103,9 @@ describe("media-player statechart model", () => { assert.strictEqual(coverage.transitions.branches.hit > 0, true) const activated = new Set(coverage.states.activation.hits.map(({ path }) => path)) - assert.strictEqual(activated.has("Player.transport.Empty"), true) - assert.strictEqual(activated.has("Player.transport.Loading"), true) - assert.strictEqual(activated.has("Player.transport.Failed"), true) + assert.strictEqual(activated.has("Player.session.Unregistered"), true) + assert.strictEqual(activated.has("Player.session.Registered"), true) + assert.strictEqual(activated.has("Player.session.Registered.Empty"), true) assert.strictEqual(activated.has("Player.settings.Audible"), true) assert.strictEqual(activated.has("Player.settings.Muted"), true) })) @@ -134,7 +130,7 @@ describe("media-player statechart model", () => { events: () => everyPublicEvent, stateKey: ({ snapshot }) => JSON.stringify({ - transport: snapshot.states.transport.state, + session: snapshot.states.session.state, settings: snapshot.states.settings.state }), invariants: laws @@ -142,22 +138,24 @@ describe("media-player statechart model", () => { assert.deepStrictEqual(explored.completeness, { _tag: "Complete" }) - const loadingAndMuted = yield* MachineTest.assertReachable( + const registeredAndMuted = yield* MachineTest.assertReachable( explored, - "loading media while muted", + "registered element while muted", ({ configuration }) => - configuration.includes("Player.transport.Loading") && + configuration.includes("Player.session.Registered") && configuration.includes("Player.settings.Muted") ) - assert.deepStrictEqual(loadingAndMuted.trace.scenario.events, [ - { _tag: "SourceSelected", url: "https://example.com/audio.mp3" }, + assert.deepStrictEqual(registeredAndMuted.trace.scenario.events, [ + everyPublicEvent[0], { _tag: "MuteRequested" } ]) yield* MachineTest.assertUnreachable( explored, - "Ready without an internal LoadSucceeded event", - ({ configuration }) => configuration.includes("Player.transport.Ready") + "transport active while the audio element is unregistered", + ({ configuration }) => + configuration.includes("Player.session.Unregistered") && + configuration.some((path) => path.startsWith("Player.session.Registered.")) ) })) }) diff --git a/examples/playground/src/examples/media-player/machine.ts b/examples/playground/src/examples/media-player/machine.ts index d9bcb1f..c8d982a 100644 --- a/examples/playground/src/examples/media-player/machine.ts +++ b/examples/playground/src/examples/media-player/machine.ts @@ -1,209 +1,397 @@ import { Machine } from "@typeonce/effect-machine" -import { Effect } from "effect" -import { MediaPlayerDefinition, MediaPlayerInternalEvents } from "./definition.ts" -import { analyzeAudio, applyAudioSettings, loadAudio, pauseAudio, playAudio, restartAudio } from "./invocations.ts" -import { initialPlaybackData, updatePlaybackData } from "./schemas.ts" +import { Effect, Match, Schema, Stream } from "effect" import { MediaPlayer } from "./service.ts" -export const MediaPlayerMachine = MediaPlayerDefinition.handle({ +interface PlaybackData { + readonly currentTime: number +} + +const State = Schema.TaggedUnion({ + Loading: { url: Schema.String }, + Paused: { currentTime: Schema.Number }, + Playing: { + currentTime: Schema.Number, + loudness: Schema.NullOr(Schema.Struct({ + rms: Schema.Number, + peak: Schema.Number, + decibels: Schema.Number + })) + }, + Buffering: { currentTime: Schema.Number }, + Restarting: { currentTime: Schema.Number }, + Ended: { currentTime: Schema.Number }, + Failed: { message: Schema.String }, + Audible: { + volume: Schema.Number, + playbackRate: Schema.Number + }, + Muted: { + volume: Schema.Number, + playbackRate: Schema.Number + } +}) + +export const MediaPlayerEvents = Machine.events( + Schema.TaggedUnion({ + AudioElementMounted: {}, + AudioElementUnmounted: {}, + SourceSelected: { url: Schema.String }, + PlayRequested: {}, + PauseRequested: {}, + RestartRequested: {}, + VolumeChanged: { volume: Schema.Number }, + PlaybackRateChanged: { playbackRate: Schema.Number }, + MuteRequested: {}, + UnmuteRequested: {} + }) +) + +const MediaPlayerInternalEvents = Machine.internalEvents( + Schema.TaggedUnion({ + LoadSucceeded: {}, + RestartSucceeded: {}, + MediaWaiting: {}, + MediaCanPlay: {}, + PlaybackEnded: { currentTime: Schema.Number }, + TimeUpdated: { currentTime: Schema.Number }, + MediaFailed: { message: Schema.String }, + LoudnessMeasured: { + rms: Schema.Number, + peak: Schema.Number, + decibels: Schema.Number + }, + OperationFailed: { message: Schema.String } + }) +) + +const MediaPlayerStates = Machine.states({ Player: { + type: "parallel", states: { - transport: { - on: { - SourceSelected: (to) => - to.local.Loading().resolve(({ event, target }) => target.from({ url: event.url }), { reenter: true }), + session: { + initial: "Unregistered", + states: { + Unregistered: {}, + Registered: { + initial: "Empty", + states: { + Empty: {}, + Loading: State.cases.Loading, + Ready: { + initial: "Paused", + states: { + Paused: State.cases.Paused, + Playing: State.cases.Playing, + Buffering: State.cases.Buffering, + Restarting: State.cases.Restarting, + Ended: State.cases.Ended + } + }, + Failed: State.cases.Failed + } + } + } + }, + settings: { + initial: "Audible", + states: { + Audible: State.cases.Audible, + Muted: State.cases.Muted + } + } + } + } +}) - MediaFailed: (to) => - to.local.Failed().resolve(({ event, target }) => target.from({ message: event.message })), +const updatePlaybackData = ( + state: PlaybackData, + patch: Partial +): PlaybackData => ({ + currentTime: patch.currentTime ?? state.currentTime +}) - OperationFailed: (to) => - to.local.Failed().resolve(({ event, target }) => target.from({ message: event.message })) +export const MediaPlayerMachine = Machine.make({ + id: "MediaPlayer", + states: MediaPlayerStates.states, + events: MediaPlayerEvents, + internalEvents: MediaPlayerInternalEvents, + initial: (to) => + to.Player.initial.resolve(({ target }) => + target.from((player) => + player + .session.from((session) => session.Unregistered.from()) + .settings.from((settings) => + settings.Audible.from({ + volume: 1, + playbackRate: 1 + }) + ) + ) + ) +}).handle({ + Player: { + states: { + session: { + on: { + AudioElementMounted: (to) => + to.local.Registered.initial.resolve(({ target }) => target.from(), { reenter: true }), + AudioElementUnmounted: (to) => + to.local.Unregistered().resolve(({ target }) => target.from(), { reenter: true }) }, states: { - Empty: {}, + Unregistered: {}, - Loading: { + Registered: { invoke: (from) => - from.effect("load-audio", ({ state }) => loadAudio(state.url)).onDone((to) => - to.none.resolve((_, enqueue) => { - enqueue.raise(MediaPlayerInternalEvents.LoadSucceeded()) - return undefined - }) - ).onFailure((to) => - to.none.resolve(({ error }, enqueue) => { - enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message })) - return undefined - }) - ), + from.stream("media-element-events", () => + Stream.unwrap( + Effect.map(MediaPlayer, (mediaPlayer) => mediaPlayer.events) + )).onElement((to) => + to.none.resolve(({ element }, enqueue) => { + Match.value(element).pipe( + Match.tagsExhaustive({ + Waiting: () => enqueue.raise(MediaPlayerInternalEvents.MediaWaiting()), + CanPlay: () => enqueue.raise(MediaPlayerInternalEvents.MediaCanPlay()), + Ended: ({ currentTime }) => + enqueue.raise(MediaPlayerInternalEvents.PlaybackEnded({ currentTime })), + TimeUpdated: ({ currentTime }) => + enqueue.raise(MediaPlayerInternalEvents.TimeUpdated({ currentTime })), + Failed: ({ message }) => enqueue.raise(MediaPlayerInternalEvents.MediaFailed({ message })) + }) + ) + }) + ).onDone((to) => to.none).onFailure((to) => + to.local.Failed().resolve(({ error, target }) => target.from({ message: error.message })) + ), on: { - LoadSucceeded: (to) => - to.local.Ready().resolve(({ target }) => target.from((ready) => ready.Paused.from(initialPlaybackData))) - } - }, + SourceSelected: (to) => + to.local.Loading().resolve(({ event, target }) => target.from({ url: event.url }), { reenter: true }), + + MediaFailed: (to) => + to.local.Failed().resolve(({ event, target }) => target.from({ message: event.message })), - Ready: { + OperationFailed: (to) => + to.local.Failed().resolve(({ event, target }) => target.from({ message: event.message })) + }, states: { - Paused: { + Empty: {}, + + Loading: { invoke: (from) => - from.effect("pause-audio", () => pauseAudio).onDone((to) => to.none).onFailure((to) => - to.none.resolve(({ error }, enqueue) => { - enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message })) - return undefined - }) - ), - on: { - PlayRequested: (to) => - to.local.Playing().resolve(({ state, target }) => - target.from({ - ...updatePlaybackData(state, {}), - loudness: null + from.effect("load-audio", ({ state }) => + Effect.gen(function*() { + const mediaPlayer = yield* MediaPlayer + yield* mediaPlayer.load(state.url) + })).onDone((to) => + to.none.resolve((_, enqueue) => { + enqueue.raise(MediaPlayerInternalEvents.LoadSucceeded()) + return undefined + }) + ).onFailure((to) => + to.none.resolve(({ error }, enqueue) => { + enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message })) + return undefined }) ), - - RestartRequested: (to) => - to.local.Restarting().resolve(({ state, target }) => target.from(updatePlaybackData(state, {}))) + on: { + LoadSucceeded: (to) => to.local.Ready.initial } }, - Playing: { - invoke: ( - from - ) => [ - from.effect("play-audio", () => playAudio).onDone((to) => to.none).onFailure((to) => - to.none.resolve(({ error }, enqueue) => { - enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message })) - return undefined - }) - ), - from.stream("analyze-audio", () => analyzeAudio).onElement((to) => - to.none.resolve(({ element }, enqueue) => { - enqueue.raise(MediaPlayerInternalEvents.LoudnessMeasured(element)) - }) - ).onDone((to) => to.none).onFailure((to) => - to.none.resolve(({ error }, enqueue) => { - enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message })) - }) - ) - ], - on: { - PauseRequested: (to) => - to.local.Paused().resolve(({ state, target }) => target.from(updatePlaybackData(state, {}))), + Ready: { + initialize: ({ builder }) => builder.from({ currentTime: 0 }), + states: { + Paused: { + invoke: (from) => + from.effect("pause-audio", () => + Effect.gen(function*() { + const mediaPlayer = yield* MediaPlayer + yield* mediaPlayer.pause + })).onDone((to) => to.none).onFailure((to) => + to.none.resolve(({ error }, enqueue) => { + enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message })) + return undefined + }) + ), + on: { + PlayRequested: (to) => + to.local.Playing().resolve(({ state, target }) => + target.from({ + ...updatePlaybackData(state, {}), + loudness: null + }) + ), - RestartRequested: (to) => - to.local.Restarting().resolve(({ state, target }) => target.from(updatePlaybackData(state, {}))), + RestartRequested: (to) => + to.local.Restarting().resolve(({ state, target }) => target.from(updatePlaybackData(state, {}))) + } + }, - MediaWaiting: (to) => - to.local.Buffering().resolve(({ state, target }) => target.from(updatePlaybackData(state, {}))), + Playing: { + invoke: ( + from + ) => [ + from.effect("play-audio", () => + Effect.gen(function*() { + const mediaPlayer = yield* MediaPlayer + yield* mediaPlayer.play + })).onDone((to) => to.none).onFailure((to) => + to.none.resolve(({ error }, enqueue) => { + enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message })) + return undefined + }) + ), + from.stream("analyze-audio", () => + Stream.unwrap( + Effect.map(MediaPlayer, (mediaPlayer) => mediaPlayer.loudness) + )).onElement((to) => + to.none.resolve(({ element }, enqueue) => { + enqueue.raise(MediaPlayerInternalEvents.LoudnessMeasured(element)) + }) + ).onDone((to) => to.none).onFailure((to) => + to.none.resolve(({ error }, enqueue) => { + enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message })) + }) + ) + ], + on: { + PauseRequested: (to) => + to.local.Paused().resolve(({ state, target }) => target.from(updatePlaybackData(state, {}))), - PlaybackEnded: (to) => - to.local.Ended().resolve(({ event, state, target }) => - target.from(updatePlaybackData(state, { currentTime: event.currentTime })) - ), + RestartRequested: (to) => + to.local.Restarting().resolve(({ state, target }) => + target.from(updatePlaybackData(state, {})) + ), - TimeUpdated: (to) => - to.local.Playing().resolve(({ event, state, target }) => - target.from({ - currentTime: event.currentTime, - loudness: state.loudness - }) - ), + MediaWaiting: (to) => + to.local.Buffering().resolve(({ state, target }) => target.from(updatePlaybackData(state, {}))), - LoudnessMeasured: (to) => - to.local.Playing().resolve(({ event, state, target }) => - target.from({ - currentTime: state.currentTime, - loudness: { - rms: event.rms, - peak: event.peak, - decibels: event.decibels - } - }) - ) - } - }, + PlaybackEnded: (to) => + to.local.Ended().resolve(({ event, state, target }) => + target.from(updatePlaybackData(state, { currentTime: event.currentTime })) + ), - Buffering: { - on: { - MediaCanPlay: (to) => - to.local.Playing().resolve(({ state, target }) => - target.from({ - ...updatePlaybackData(state, {}), - loudness: null - }) - ), + TimeUpdated: (to) => + to.local.Playing().resolve(({ event, state, target }) => + target.from({ + currentTime: event.currentTime, + loudness: state.loudness + }) + ), - PauseRequested: (to) => - to.local.Paused().resolve(({ state, target }) => target.from(updatePlaybackData(state, {}))), + LoudnessMeasured: (to) => + to.local.Playing().resolve(({ event, state, target }) => + target.from({ + currentTime: state.currentTime, + loudness: { + rms: event.rms, + peak: event.peak, + decibels: event.decibels + } + }) + ) + } + }, - RestartRequested: (to) => - to.local.Restarting().resolve(({ state, target }) => target.from(updatePlaybackData(state, {}))), + Buffering: { + on: { + MediaCanPlay: (to) => + to.local.Playing().resolve(({ state, target }) => + target.from({ + ...updatePlaybackData(state, {}), + loudness: null + }) + ), - PlaybackEnded: (to) => - to.local.Ended().resolve(({ event, state, target }) => - target.from(updatePlaybackData(state, { currentTime: event.currentTime })) - ), + PauseRequested: (to) => + to.local.Paused().resolve(({ state, target }) => target.from(updatePlaybackData(state, {}))), - TimeUpdated: (to) => - to.local.Buffering().resolve(({ event, state, target }) => - target.from(updatePlaybackData(state, { currentTime: event.currentTime })) - ) - } - }, + RestartRequested: (to) => + to.local.Restarting().resolve(({ state, target }) => + target.from(updatePlaybackData(state, {})) + ), - Restarting: { - invoke: (from) => - from.effect("restart-audio", () => restartAudio).onDone((to) => - to.none.resolve((_, enqueue) => { - enqueue.raise(MediaPlayerInternalEvents.RestartSucceeded()) - return undefined - }) - ).onFailure((to) => - to.none.resolve(({ error }, enqueue) => { - enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message })) - return undefined - }) - ), - on: { - RestartSucceeded: (to) => - to.local.Playing().resolve(({ target }) => target.from({ currentTime: 0, loudness: null })), + PlaybackEnded: (to) => + to.local.Ended().resolve(({ event, state, target }) => + target.from(updatePlaybackData(state, { currentTime: event.currentTime })) + ), - TimeUpdated: (to) => - to.local.Restarting().resolve(({ event, state, target }) => - target.from(updatePlaybackData(state, { currentTime: event.currentTime })) - ) - } - }, + TimeUpdated: (to) => + to.local.Buffering().resolve(({ event, state, target }) => + target.from(updatePlaybackData(state, { currentTime: event.currentTime })) + ) + } + }, - Ended: { - on: { - PlayRequested: (to) => - to.local.Restarting().resolve(({ state, target }) => target.from(updatePlaybackData(state, {}))), + Restarting: { + invoke: (from) => + from.effect("restart-audio", () => + Effect.gen(function*() { + const mediaPlayer = yield* MediaPlayer + yield* mediaPlayer.restart + })).onDone((to) => + to.none.resolve((_, enqueue) => { + enqueue.raise(MediaPlayerInternalEvents.RestartSucceeded()) + return undefined + }) + ).onFailure((to) => + to.none.resolve(({ error }, enqueue) => { + enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message })) + return undefined + }) + ), + on: { + RestartSucceeded: (to) => + to.local.Playing().resolve(({ target }) => target.from({ currentTime: 0, loudness: null })), + + TimeUpdated: (to) => + to.local.Restarting().resolve(({ event, state, target }) => + target.from(updatePlaybackData(state, { currentTime: event.currentTime })) + ) + } + }, - RestartRequested: (to) => - to.local.Restarting().resolve(({ state, target }) => target.from(updatePlaybackData(state, {}))) + Ended: { + on: { + PlayRequested: (to) => + to.local.Restarting().resolve(({ state, target }) => + target.from(updatePlaybackData(state, {})) + ), + + RestartRequested: (to) => + to.local.Restarting().resolve(({ state, target }) => target.from(updatePlaybackData(state, {}))) + } + } } + }, + + Failed: { + invoke: (from) => + from.effect("report-error", ({ state }) => + Effect.gen(function*() { + const mediaPlayer = yield* MediaPlayer + yield* mediaPlayer.reportError(state.message) + })).onDone((to) => to.none) } } - }, - - Failed: { - invoke: (from) => - from.effect("report-error", ({ state }) => - Effect.gen(function*() { - const mediaPlayer = yield* MediaPlayer - yield* mediaPlayer.reportError(state.message) - })).onDone((to) => to.none) } } }, settings: { + initialize: ({ builder }) => + builder.from({ + volume: 1, + playbackRate: 1 + }), states: { Audible: { invoke: (from) => - from.effect("apply-audio-settings", ({ state }) => applyAudioSettings(state, false)).onDone((to) => - to.none - ), + from.effect("apply-audio-settings", ({ state }) => + Effect.gen(function*() { + const mediaPlayer = yield* MediaPlayer + yield* mediaPlayer.applySettings({ ...state, muted: false }) + })).onDone((to) => to.none), on: { VolumeChanged: (to) => to.local.Audible().resolve(({ event, state, target }) => @@ -231,9 +419,11 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({ Muted: { invoke: (from) => - from.effect("apply-audio-settings", ({ state }) => applyAudioSettings(state, true)).onDone((to) => - to.none - ), + from.effect("apply-audio-settings", ({ state }) => + Effect.gen(function*() { + const mediaPlayer = yield* MediaPlayer + yield* mediaPlayer.applySettings({ ...state, muted: true }) + })).onDone((to) => to.none), on: { VolumeChanged: (to) => to.local.Muted().resolve(({ event, state, target }) => diff --git a/examples/playground/src/examples/media-player/schemas.ts b/examples/playground/src/examples/media-player/schemas.ts deleted file mode 100644 index fcf2203..0000000 --- a/examples/playground/src/examples/media-player/schemas.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { Machine } from "@typeonce/effect-machine" -import { Schema } from "effect" - -export interface AudioSettings { - readonly volume: number - readonly muted: boolean - readonly playbackRate: number -} - -export interface SoundSettings { - readonly volume: number - readonly playbackRate: number -} - -export interface LoudnessSample { - readonly rms: number - readonly peak: number - readonly decibels: number -} - -export interface PlaybackData { - readonly currentTime: number -} - -export interface PlayingData extends PlaybackData { - readonly loudness: LoudnessSample | null -} - -const Loudness = Schema.Struct({ - rms: Schema.Number, - peak: Schema.Number, - decibels: Schema.Number -}) - -const playbackFields = { currentTime: Schema.Number } - -const soundSettingsFields = { - volume: Schema.Number, - playbackRate: Schema.Number -} - -export const MediaPlayerState = Schema.TaggedUnion({ - Loading: { url: Schema.String }, - - Paused: playbackFields, - - Playing: { - ...playbackFields, - loudness: Schema.NullOr(Loudness) - }, - - Buffering: playbackFields, - - Restarting: playbackFields, - - Ended: playbackFields, - - Failed: { message: Schema.String }, - - Audible: soundSettingsFields, - - Muted: soundSettingsFields -}) - -export const MediaPlayerEvents = Machine.events( - Schema.TaggedUnion({ - SourceSelected: { url: Schema.String }, - PlayRequested: {}, - PauseRequested: {}, - RestartRequested: {}, - MediaWaiting: {}, - MediaCanPlay: {}, - PlaybackEnded: { currentTime: Schema.Number }, - TimeUpdated: { currentTime: Schema.Number }, - MediaFailed: { message: Schema.String }, - VolumeChanged: { volume: Schema.Number }, - PlaybackRateChanged: { playbackRate: Schema.Number }, - MuteRequested: {}, - UnmuteRequested: {} - }) -) - -export const MediaPlayerInternalEvents = Machine.internalEvents( - Schema.TaggedUnion({ - LoadSucceeded: {}, - RestartSucceeded: {}, - LoudnessMeasured: { - rms: Schema.Number, - peak: Schema.Number, - decibels: Schema.Number - }, - OperationFailed: { message: Schema.String } - }) -) - -export const MediaPlayerStates = Machine.states({ - Player: { - type: "parallel", - states: { - transport: { - initial: "Empty", - states: { - Empty: {}, - - Loading: MediaPlayerState.cases.Loading, - - Ready: { - initial: "Paused", - states: { - Paused: MediaPlayerState.cases.Paused, - - Playing: MediaPlayerState.cases.Playing, - - Buffering: MediaPlayerState.cases.Buffering, - - Restarting: MediaPlayerState.cases.Restarting, - - Ended: MediaPlayerState.cases.Ended - } - }, - - Failed: MediaPlayerState.cases.Failed - } - }, - - settings: { - initial: "Audible", - states: { - Audible: MediaPlayerState.cases.Audible, - - Muted: MediaPlayerState.cases.Muted - } - } - } - } -}) - -export const initialPlaybackData: PlaybackData = { - currentTime: 0 -} - -export const initialAudioSettings: AudioSettings = { - volume: 1, - muted: false, - playbackRate: 1 -} - -export const updatePlaybackData = ( - state: PlaybackData, - patch: Partial -): PlaybackData => ({ - currentTime: patch.currentTime ?? state.currentTime -}) - -export const toAudioSettings = ( - settings: SoundSettings, - muted: boolean -): AudioSettings => ({ - volume: settings.volume, - muted, - playbackRate: settings.playbackRate -}) diff --git a/examples/playground/src/examples/media-player/service.ts b/examples/playground/src/examples/media-player/service.ts index 2369fcd..2e3c0de 100644 --- a/examples/playground/src/examples/media-player/service.ts +++ b/examples/playground/src/examples/media-player/service.ts @@ -1,313 +1,280 @@ -import { Context, Data, Effect, Layer, Schedule, Stream, SynchronizedRef } from "effect" -import { type AudioSettings, initialAudioSettings, type LoudnessSample } from "./schemas.ts" +import { Context, Data, Effect, Fiber, Layer, Option, Ref, Schedule, ScopedRef, Stream, SynchronizedRef } from "effect" + +export interface AudioSettings { + readonly volume: number + readonly muted: boolean + readonly playbackRate: number +} + +export interface LoudnessSample { + readonly rms: number + readonly peak: number + readonly decibels: number +} export class MediaPlayerError extends Data.TaggedError("MediaPlayerError")<{ - readonly operation: "load" | "play" | "pause" | "restart" | "analyze" + readonly operation: "register" | "observe" | "load" | "play" | "pause" | "restart" | "analyze" readonly message: string }> {} -type MediaGraph = Data.TaggedEnum<{ - Empty: { - readonly settings: AudioSettings - } +type MediaElementEvent = Data.TaggedEnum<{ + Waiting: {} + CanPlay: {} + Ended: { readonly currentTime: number } + TimeUpdated: { readonly currentTime: number } + Failed: { readonly message: string } +}> + +const MediaElementEvent = Data.taggedEnum() + +type AudioGraph = Data.TaggedEnum<{ Registered: { readonly audioRef: HTMLAudioElement - readonly audioContext: AudioContext | null - readonly settings: AudioSettings } Loaded: { readonly audioRef: HTMLAudioElement readonly audioContext: AudioContext readonly trackSource: MediaElementAudioSourceNode readonly analyserNode: AnalyserNode - readonly settings: AudioSettings } }> -const MediaGraph = Data.taggedEnum() +const AudioGraph = Data.taggedEnum() -type BrowserWindow = Window & { - readonly webkitAudioContext?: typeof AudioContext +interface AudioSession { + readonly graph: SynchronizedRef.SynchronizedRef } -const error = ( +const fail = ( operation: MediaPlayerError["operation"], message: string -): MediaPlayerError => new MediaPlayerError({ operation, message }) - -const applySettingsToElement = ( - audioRef: HTMLAudioElement, - settings: AudioSettings -): void => { - audioRef.volume = settings.volume - audioRef.muted = settings.muted - audioRef.playbackRate = settings.playbackRate -} - -const waitForMedia = ( - audioRef: HTMLAudioElement, - url: string -): Effect.Effect => - Effect.callback((resume) => { - const cleanup = () => { - audioRef.removeEventListener("loadeddata", onLoaded) - audioRef.removeEventListener("error", onError) - } - const onLoaded = () => { - cleanup() - resume(Effect.void) - } - const onError = () => { - cleanup() - resume(Effect.fail(error("load", audioRef.error?.message ?? "The selected audio file could not be loaded"))) - } - - audioRef.addEventListener("loadeddata", onLoaded, { once: true }) - audioRef.addEventListener("error", onError, { once: true }) - - try { - audioRef.src = url - audioRef.load() - } catch { - onError() - } - - return Effect.sync(cleanup) - }) - -const releaseGraph = (graphRef: SynchronizedRef.SynchronizedRef) => - SynchronizedRef.updateEffect(graphRef, (graph) => - Effect.gen(function*() { - if (graph._tag === "Loaded") { - yield* Effect.sync(() => { - graph.trackSource.disconnect() - graph.analyserNode.disconnect() - }) - } - - const audioContext = graph._tag === "Empty" ? null : graph.audioContext - - if (audioContext !== null && audioContext.state !== "closed") { - yield* Effect.tryPromise({ - try: () => audioContext.close(), - catch: () => undefined - }).pipe(Effect.ignore) - } - - return MediaGraph.Empty({ settings: graph.settings }) - })) +): Effect.Effect => Effect.fail(new MediaPlayerError({ operation, message })) export class MediaPlayer extends Context.Service()( "effect-machine/playground/MediaPlayer", { - make: Effect.acquireRelease( - Effect.gen(function*() { - const graphRef = yield* SynchronizedRef.make( - MediaGraph.Empty({ settings: initialAudioSettings }) + make: Effect.gen(function*() { + const settingsRef = yield* Ref.make({ volume: 1, muted: false, playbackRate: 1 }) + const sessionRef = yield* ScopedRef.make>(() => Option.none()) + + const session = (operation: MediaPlayerError["operation"]) => + ScopedRef.get(sessionRef).pipe( + Effect.flatMap( + Option.match({ + onNone: () => fail(operation, "The audio element is not registered"), + onSome: Effect.succeed + }) + ) ) - const service = { - register: (nextAudioRef: HTMLAudioElement) => - SynchronizedRef.updateEffect(graphRef, (graph) => { - if (graph._tag !== "Empty" && graph.audioRef === nextAudioRef) { - return Effect.sync(() => { - applySettingsToElement(nextAudioRef, graph.settings) - return graph - }) - } - - return Effect.sync(() => { - if (graph._tag === "Loaded") { - graph.trackSource.disconnect() - graph.analyserNode.disconnect() - } - - applySettingsToElement(nextAudioRef, graph.settings) - - return MediaGraph.Registered({ - audioRef: nextAudioRef, - audioContext: graph._tag === "Empty" ? null : graph.audioContext, - settings: graph.settings - }) - }) - }), + const applySettings = (audioRef: HTMLAudioElement, settings: AudioSettings) => + Effect.sync(() => { + audioRef.volume = settings.volume + audioRef.muted = settings.muted + audioRef.playbackRate = settings.playbackRate + }) - load: (url: string) => - SynchronizedRef.updateEffect( - graphRef, - (graph): Effect.Effect => + const resume = (audioContext: AudioContext, operation: "play" | "restart") => + audioContext.state === "suspended" + ? Effect.tryPromise({ + try: () => audioContext.resume(), + catch: () => new MediaPlayerError({ operation, message: "The audio context could not be resumed" }) + }) + : Effect.void + + return { + register: (audioRef: HTMLAudioElement | null) => + ScopedRef.set( + sessionRef, + audioRef === null + ? Effect.succeed(Option.none()) + : Effect.acquireRelease( Effect.gen(function*() { - if (graph._tag === "Empty") { - return yield* Effect.fail(error("load", "The audio element is not ready")) - } - - yield* waitForMedia(graph.audioRef, url) - - if (graph._tag === "Loaded") return graph - - const AudioContextConstructor = window.AudioContext ?? (window as BrowserWindow).webkitAudioContext - - if (AudioContextConstructor === undefined) { - return yield* Effect.fail(error("load", "Web Audio is not supported by this browser")) - } - - return yield* Effect.try({ - try: () => { - const audioContext = graph.audioContext === null || graph.audioContext.state === "closed" - ? new AudioContextConstructor() - : graph.audioContext - const trackSource = audioContext.createMediaElementSource(graph.audioRef) - const analyserNode = audioContext.createAnalyser() - - analyserNode.fftSize = 256 - trackSource.connect(analyserNode) - analyserNode.connect(audioContext.destination) - - return MediaGraph.Loaded({ - audioRef: graph.audioRef, - audioContext, - trackSource, - analyserNode, - settings: graph.settings - }) - }, - catch: () => error("load", "The audio graph could not be connected") + yield* applySettings(audioRef, yield* Ref.get(settingsRef)) + return Option.some({ + graph: yield* SynchronizedRef.make(AudioGraph.Registered({ audioRef })) }) + }), + Option.match({ + onNone: () => Effect.void, + onSome: ({ graph }) => + SynchronizedRef.get(graph).pipe( + Effect.flatMap((current) => + AudioGraph.$is("Loaded")(current) + ? Effect.all([ + Effect.sync(() => { + current.trackSource.disconnect() + current.analyserNode.disconnect() + }), + current.audioContext.state === "closed" + ? Effect.void + : Effect.promise(() => current.audioContext.close()).pipe(Effect.ignore) + ], { discard: true }) + : Effect.void + ) + ) }) - ), - - applySettings: (settings: AudioSettings) => - SynchronizedRef.updateEffect(graphRef, (graph) => - Effect.sync(() => { - if (graph._tag !== "Empty") { - applySettingsToElement(graph.audioRef, settings) - } - - return MediaGraph.$match(graph, { - Empty: () => MediaGraph.Empty({ settings }), - Registered: ({ audioRef, audioContext }) => - MediaGraph.Registered({ audioRef, audioContext, settings }), - Loaded: ({ audioRef, audioContext, trackSource, analyserNode }) => - MediaGraph.Loaded({ audioRef, audioContext, trackSource, analyserNode, settings }) - }) - })), - - play: SynchronizedRef.updateEffect( - graphRef, - (graph): Effect.Effect => - Effect.gen(function*() { - if (graph._tag !== "Loaded") { - return yield* Effect.fail(error("play", "The audio graph is not ready")) - } - - if (graph.audioContext.state === "suspended") { - yield* Effect.tryPromise({ - try: () => graph.audioContext.resume(), - catch: () => error("play", "The audio context could not be resumed") - }) - } - - yield* Effect.tryPromise({ - try: () => graph.audioRef.play(), - catch: () => error("play", "Playback could not be started") - }) - - return graph - }) + ) ), - pause: SynchronizedRef.updateEffect( - graphRef, - (graph): Effect.Effect => + events: Stream.unwrap( + Effect.gen(function*() { + const { graph } = yield* session("observe") + const audioRef = (yield* SynchronizedRef.get(graph)).audioRef + return Stream.mergeAll([ + Stream.fromEventListener(audioRef, "waiting").pipe( + Stream.map((): MediaElementEvent => MediaElementEvent.Waiting()) + ), + Stream.fromEventListener(audioRef, "canplay").pipe( + Stream.map((): MediaElementEvent => MediaElementEvent.CanPlay()) + ), + Stream.fromEventListener(audioRef, "ended").pipe( + Stream.map((): MediaElementEvent => MediaElementEvent.Ended({ currentTime: audioRef.currentTime })) + ), + Stream.fromEventListener(audioRef, "timeupdate").pipe( + Stream.map((): MediaElementEvent => + MediaElementEvent.TimeUpdated({ currentTime: audioRef.currentTime }) + ) + ), + Stream.fromEventListener(audioRef, "error").pipe( + Stream.map((): MediaElementEvent => + MediaElementEvent.Failed({ + message: audioRef.error?.message ?? "The selected audio file could not be loaded" + }) + ) + ) + ], { concurrency: "unbounded" }) + }) + ), + + load: (url: string) => + Effect.gen(function*() { + const { graph } = yield* session("load") + yield* SynchronizedRef.updateEffect(graph, (current) => Effect.gen(function*() { - if (graph._tag === "Empty") { - return yield* Effect.fail(error("pause", "The audio element is not ready")) - } + const audioRef = current.audioRef + const loaded = Stream.fromEventListener(audioRef, "loadeddata").pipe( + Stream.take(1), + Stream.runHead, + Effect.asVoid + ) + const failed = Stream.fromEventListener(audioRef, "error").pipe( + Stream.take(1), + Stream.runHead, + Effect.flatMap(() => + fail("load", audioRef.error?.message ?? "The selected audio file could not be loaded") + ) + ) + const waiting = yield* Stream.merge(loaded.pipe(Stream.fromEffect), failed.pipe(Stream.fromEffect)) + .pipe(Stream.runHead, Effect.forkChild({ startImmediately: true })) yield* Effect.try({ - try: () => graph.audioRef.pause(), - catch: () => error("pause", "Playback could not be paused") + try: () => { + audioRef.src = url + audioRef.load() + }, + catch: () => new MediaPlayerError({ operation: "load", message: "The audio file could not load" }) }) + yield* Fiber.join(waiting) - return graph - }) - ), + if (AudioGraph.$is("Loaded")(current)) return current - restart: SynchronizedRef.updateEffect( - graphRef, - (graph): Effect.Effect => - Effect.gen(function*() { - if (graph._tag !== "Loaded") { - return yield* Effect.fail(error("restart", "The audio graph is not ready")) - } - - if (graph.audioContext.state === "suspended") { - yield* Effect.tryPromise({ - try: () => graph.audioContext.resume(), - catch: () => error("restart", "The audio context could not be resumed") - }) + const AudioContextConstructor = window.AudioContext ?? + (window as Window & { readonly webkitAudioContext?: typeof AudioContext }).webkitAudioContext + if (AudioContextConstructor === undefined) { + return yield* fail("load", "Web Audio is not supported by this browser") } - yield* Effect.tryPromise({ - try: async () => { - graph.audioRef.currentTime = 0 - await graph.audioRef.play() + return yield* Effect.try({ + try: () => { + const audioContext = new AudioContextConstructor() + const trackSource = audioContext.createMediaElementSource(audioRef) + const analyserNode = audioContext.createAnalyser() + analyserNode.fftSize = 256 + trackSource.connect(analyserNode) + analyserNode.connect(audioContext.destination) + return AudioGraph.Loaded({ audioRef, audioContext, trackSource, analyserNode }) }, - catch: () => error("restart", "Playback could not be restarted") + catch: () => + new MediaPlayerError({ operation: "load", message: "The audio graph could not be connected" }) }) - - return graph - }) - ), - - loudness: Stream.unwrap( - Effect.gen(function*() { - const graph = yield* SynchronizedRef.get(graphRef) - - if (graph._tag !== "Loaded") { - return yield* Effect.fail(error("analyze", "The audio analyser is not ready")) - } - - const samples = new Uint8Array(graph.analyserNode.fftSize) - - return Stream.fromEffectSchedule( - Effect.sync(() => { - graph.analyserNode.getByteTimeDomainData(samples) - - let sum = 0 - let peak = 0 - - for (const value of samples) { - const normalized = (value - 128) / 128 - const absolute = Math.abs(normalized) - - sum += normalized * normalized - peak = Math.max(peak, absolute) - } - - const rms = Math.sqrt(sum / samples.length) - - return { - rms, - peak, - decibels: 20 * Math.log10(Math.max(rms, 0.0001)) - } satisfies LoudnessSample - }), - Schedule.spaced("100 millis") + })) + }), + + applySettings: (settings: AudioSettings) => + Effect.gen(function*() { + yield* Ref.set(settingsRef, settings) + const current = yield* ScopedRef.get(sessionRef) + if (Option.isSome(current)) { + yield* SynchronizedRef.get(current.value.graph).pipe( + Effect.flatMap((graph) => applySettings(graph.audioRef, settings)) ) - }) - ), - - reportError: (message: string) => - Effect.sync(() => { - console.error(`[media-player] ${message}`) - }) - } - - return { graphRef, service } - }), - ({ graphRef }) => releaseGraph(graphRef) - ).pipe(Effect.map(({ service }) => service)) + } + }), + + play: Effect.gen(function*() { + const { graph } = yield* session("play") + const current = yield* SynchronizedRef.get(graph) + if (!AudioGraph.$is("Loaded")(current)) return yield* fail("play", "The audio graph is not ready") + yield* resume(current.audioContext, "play") + yield* Effect.tryPromise({ + try: () => current.audioRef.play(), + catch: () => new MediaPlayerError({ operation: "play", message: "Playback could not be started" }) + }) + }), + + pause: Effect.gen(function*() { + const { graph } = yield* session("pause") + const current = yield* SynchronizedRef.get(graph) + yield* Effect.try({ + try: () => current.audioRef.pause(), + catch: () => new MediaPlayerError({ operation: "pause", message: "Playback could not be paused" }) + }) + }), + + restart: Effect.gen(function*() { + const { graph } = yield* session("restart") + const current = yield* SynchronizedRef.get(graph) + if (!AudioGraph.$is("Loaded")(current)) return yield* fail("restart", "The audio graph is not ready") + yield* resume(current.audioContext, "restart") + yield* Effect.tryPromise({ + try: async () => { + current.audioRef.currentTime = 0 + await current.audioRef.play() + }, + catch: () => new MediaPlayerError({ operation: "restart", message: "Playback could not be restarted" }) + }) + }), + + loudness: Stream.unwrap( + Effect.gen(function*() { + const { graph } = yield* session("analyze") + const current = yield* SynchronizedRef.get(graph) + if (!AudioGraph.$is("Loaded")(current)) { + return yield* fail("analyze", "The audio analyser is not ready") + } + const samples = new Uint8Array(current.analyserNode.fftSize) + return Stream.fromEffectSchedule( + Effect.sync(() => { + current.analyserNode.getByteTimeDomainData(samples) + let sum = 0 + let peak = 0 + for (const value of samples) { + const normalized = (value - 128) / 128 + sum += normalized * normalized + peak = Math.max(peak, Math.abs(normalized)) + } + const rms = Math.sqrt(sum / samples.length) + return { rms, peak, decibels: 20 * Math.log10(Math.max(rms, 0.0001)) } satisfies LoudnessSample + }), + Schedule.spaced("100 millis") + ) + }) + ), + + reportError: (message: string) => Effect.logError(message) + } + }) } ) { static readonly layer = Layer.effect(this)(this.make) diff --git a/examples/playground/src/examples/media-player/view.ts b/examples/playground/src/examples/media-player/view.ts deleted file mode 100644 index a64ab48..0000000 --- a/examples/playground/src/examples/media-player/view.ts +++ /dev/null @@ -1,154 +0,0 @@ -import type { Machine } from "@typeonce/effect-machine" -import { Match } from "effect" -import { MediaPlayerMachine } from "./machine.ts" -import { initialPlaybackData, type LoudnessSample, type PlaybackData } from "./schemas.ts" - -type MediaPlayerSnapshot = Machine.Snapshot -type TransportSnapshot = MediaPlayerSnapshot["states"]["transport"]["state"] -type ReadySnapshot = Extract["state"] -type SettingsSnapshot = MediaPlayerSnapshot["states"]["settings"]["state"] - -export type MediaPlayerTransportPath = - | Exclude - | ReadySnapshot["path"] - -export interface MediaPlayerTransportView { - readonly path: MediaPlayerTransportPath - readonly name: "Empty" | "Loading" | "Paused" | "Playing" | "Buffering" | "Restarting" | "Ended" | "Failed" - readonly value: TransportSnapshot["value"] | ReadySnapshot["value"] - readonly playback: PlaybackData - readonly loudness: LoudnessSample | null - readonly error: string | null - readonly isPlaying: boolean - readonly isBuffering: boolean - readonly canPlay: boolean - readonly canPause: boolean - readonly canRestart: boolean -} - -export interface MediaPlayerSettingsView { - readonly path: SettingsSnapshot["path"] - readonly value: SettingsSnapshot["value"] - readonly volume: number - readonly playbackRate: number - readonly muted: boolean -} - -export interface MediaPlayerView { - readonly transport: MediaPlayerTransportView - readonly settings: MediaPlayerSettingsView -} - -const transportDefaults = { - playback: initialPlaybackData, - loudness: null, - error: null, - isPlaying: false, - isBuffering: false, - canPlay: false, - canPause: false, - canRestart: false -} as const - -const toReadyView = (snapshot: ReadySnapshot): MediaPlayerTransportView => - Match.value(snapshot).pipe( - Match.discriminatorsExhaustive("path")({ - "Player.transport.Ready.Paused": ({ path, value }) => ({ - ...transportDefaults, - path, - name: "Paused" as const, - value, - playback: value, - canPlay: true, - canRestart: true - }), - "Player.transport.Ready.Playing": ({ path, value }) => ({ - ...transportDefaults, - path, - name: "Playing" as const, - value, - playback: value, - loudness: value.loudness, - isPlaying: true, - canPause: true, - canRestart: true - }), - "Player.transport.Ready.Buffering": ({ path, value }) => ({ - ...transportDefaults, - path, - name: "Buffering" as const, - value, - playback: value, - isBuffering: true, - canPause: true, - canRestart: true - }), - "Player.transport.Ready.Restarting": ({ path, value }) => ({ - ...transportDefaults, - path, - name: "Restarting" as const, - value, - playback: value - }), - "Player.transport.Ready.Ended": ({ path, value }) => ({ - ...transportDefaults, - path, - name: "Ended" as const, - value, - playback: value, - canPlay: true, - canRestart: true - }) - }) - ) - -const toTransportView = (snapshot: TransportSnapshot): MediaPlayerTransportView => - Match.value(snapshot).pipe( - Match.discriminatorsExhaustive("path")({ - "Player.transport.Empty": ({ path, value }) => ({ - ...transportDefaults, - path, - name: "Empty" as const, - value - }), - "Player.transport.Loading": ({ path, value }) => ({ - ...transportDefaults, - path, - name: "Loading" as const, - value - }), - "Player.transport.Ready": ({ state }) => toReadyView(state), - "Player.transport.Failed": ({ path, value }) => ({ - ...transportDefaults, - path, - name: "Failed" as const, - value, - error: value.message - }) - }) - ) - -const toSettingsView = (snapshot: SettingsSnapshot): MediaPlayerSettingsView => - Match.value(snapshot).pipe( - Match.discriminatorsExhaustive("path")({ - "Player.settings.Audible": ({ path, value }) => ({ - path, - value, - volume: value.volume, - playbackRate: value.playbackRate, - muted: false - }), - "Player.settings.Muted": ({ path, value }) => ({ - path, - value, - volume: value.volume, - playbackRate: value.playbackRate, - muted: true - }) - }) - ) - -export const toMediaPlayerView = (snapshot: MediaPlayerSnapshot): MediaPlayerView => ({ - transport: toTransportView(snapshot.states.transport.state), - settings: toSettingsView(snapshot.states.settings.state) -}) diff --git a/examples/playground/src/examples/microwave/MicrowavePage.tsx b/examples/playground/src/examples/microwave/MicrowavePage.tsx index c2e0b2b..c6417f0 100644 --- a/examples/playground/src/examples/microwave/MicrowavePage.tsx +++ b/examples/playground/src/examples/microwave/MicrowavePage.tsx @@ -11,7 +11,7 @@ export function MicrowavePage() { return ( {Match.value(stateResult).pipe( @@ -19,11 +19,10 @@ export function MicrowavePage() { Initial: () =>
Starting the microwave…
, Failure: () =>
The microwave failed to start.
, Success: ({ value: state }) => { - const engine = state.states.engine.state - const door = state.states.door.state - const cooking = engine.path === "Oven.engine.Cooking" - const open = door.path === "Oven.door.Open" - const elapsedSeconds = cooking ? engine.value.elapsedSeconds : 0 + const oven = state.state + const open = oven.path === "Oven.Open" + const cooking = oven.path === "Oven.Closed" && oven.state.path === "Oven.Closed.Cooking" + const elapsedSeconds = cooking ? oven.state.value.elapsedSeconds : 0 const engineName = cooking ? "Cooking" : "Idle" const doorName = open ? "Open" : "Closed" @@ -40,7 +39,7 @@ export function MicrowavePage() {
-

Parallel configuration

+

Hierarchical safety state

{cooking ? `Cooking · ${elapsedSeconds}s` : open ? "Idle · door open" : "Idle · door closed"}

Engine: {engineName} · Door: {doorName} diff --git a/examples/playground/src/examples/microwave/machine.ts b/examples/playground/src/examples/microwave/machine.ts index 21c102c..3b43150 100644 --- a/examples/playground/src/examples/microwave/machine.ts +++ b/examples/playground/src/examples/microwave/machine.ts @@ -15,22 +15,16 @@ export const MicrowaveEvents = Machine.events( export const MicrowaveStates = Machine.states({ Oven: { - type: "parallel", + initial: "Closed", states: { - engine: { + Closed: { initial: "Idle", states: { Idle: {}, Cooking: MicrowaveState.cases.Cooking } }, - door: { - initial: "Closed", - states: { - Closed: {}, - Open: {} - } - } + Open: {} } } }) @@ -40,29 +34,18 @@ export const MicrowaveMachine = Machine.make({ states: MicrowaveStates.states, events: MicrowaveEvents, initial: (to) => - to.Oven.initial.resolve(({ target }) => - target.from((oven) => - oven - .engine.from((engine) => engine.Idle.from()) - .door.from((door) => door.Closed.from()) - ) - ) + to.Oven.initial.resolve(({ target }) => target.from((oven) => oven.Closed.from((closed) => closed.Idle.from()))) }).handle({ Oven: { states: { - engine: { + Closed: { + on: { + DoorOpened: (to) => to.branch.Oven.Open().resolve(({ target }) => target.from()) + }, states: { Idle: { on: { - PowerPressed: (to) => - to.branches({ - doorClosed: { title: "Door closed", target: to.local.Cooking() }, - unchanged: { target: to.none } - }).resolve(({ snapshot, select }) => - MicrowaveStates.matches(snapshot, "Oven.door.Closed") - ? select.doorClosed.from({ elapsedSeconds: 0 }) - : select.unchanged() - ) + PowerPressed: (to) => to.local.Cooking().resolve(({ target }) => target.from({ elapsedSeconds: 0 })) } }, Cooking: { @@ -73,24 +56,14 @@ export const MicrowaveMachine = Machine.make({ ) ), on: { - PowerPressed: (to) => to.local.Idle().resolve(({ target }) => target.from()), - DoorOpened: (to) => to.local.Idle().resolve(({ target }) => target.from()) + PowerPressed: (to) => to.local.Idle().resolve(({ target }) => target.from()) } } } }, - door: { - states: { - Closed: { - on: { - DoorOpened: (to) => to.local.Open().resolve(({ target }) => target.from()) - } - }, - Open: { - on: { - DoorClosed: (to) => to.local.Closed().resolve(({ target }) => target.from()) - } - } + Open: { + on: { + DoorClosed: (to) => to.local.Closed.initial } } } diff --git a/examples/playground/src/router.tsx b/examples/playground/src/router.tsx index a86c1a9..7e9ab86 100644 --- a/examples/playground/src/router.tsx +++ b/examples/playground/src/router.tsx @@ -21,14 +21,14 @@ const examples = [ { to: "/microwave" as const, title: "Microwave", - description: "Door and engine behavior modeled as cooperating parallel regions.", - concepts: ["parallel states", "conditions"] + description: "A hierarchy that makes cooking with an open door impossible.", + concepts: ["compound states", "structural invariants"] }, { to: "/media-player" as const, title: "Media player", - description: "Coordinate parallel transport and sound modes with state-scoped Effects.", - concepts: ["parallel states", "compound states", "Effect services"] + description: "Keep transport beneath its required audio session while settings remain independent.", + concepts: ["resource ownership", "parallel states", "Effect services"] }, { to: "/worker-tabs" as const, diff --git a/examples/pokemon/README.md b/examples/pokemon/README.md index ef60bb5..30d7075 100644 --- a/examples/pokemon/README.md +++ b/examples/pokemon/README.md @@ -1,7 +1,7 @@ # Pokémon statechart example -A standalone React and Vite application demonstrating parent, child, parallel, -and invoked Effect machines with a live PokéAPI integration. +A standalone React and Vite application demonstrating hierarchical parent and +child workflows and invoked Effect machines with a live PokéAPI integration. This project installs `@typeonce/effect-machine` from the repository root through a local `file:` dependency. It keeps an independent lockfile and diff --git a/examples/pokemon/src/atoms.ts b/examples/pokemon/src/atoms.ts new file mode 100644 index 0000000..75cbdaf --- /dev/null +++ b/examples/pokemon/src/atoms.ts @@ -0,0 +1,10 @@ +import { AtomMachine } from "@typeonce/effect-machine/reactivity" +import { Atom } from "effect/unstable/reactivity" +import { machine, ReplaceChild, SelectionChild } from "./machine.ts" +import { PokemonService } from "./pokemon.ts" + +const atomRuntime = Atom.runtime(PokemonService.layer) + +export const machineAtom = AtomMachine.bind(atomRuntime).make(machine) +export const selectionMachineAtom = machineAtom.child(SelectionChild) +export const replaceMachineAtom = machineAtom.child(ReplaceChild) diff --git a/examples/pokemon/src/machine.ts b/examples/pokemon/src/machine.ts index 45ef672..bb46988 100644 --- a/examples/pokemon/src/machine.ts +++ b/examples/pokemon/src/machine.ts @@ -1,7 +1,5 @@ import { Machine } from "@typeonce/effect-machine" -import { AtomMachine } from "@typeonce/effect-machine/reactivity" import { Effect, Schema } from "effect" -import { Atom } from "effect/unstable/reactivity" import { ReplaceMachine } from "./machines/replace.ts" import { SelectionMachine } from "./machines/selection.ts" import { Pokemon, PokemonService, TeamEvents } from "./pokemon.ts" @@ -10,12 +8,16 @@ class ActiveTeam extends Schema.TaggedClass("ActiveTeam")("ActiveTea team: Schema.Array(Pokemon) }) {} -export const States = Machine.states({ Loading: {}, ActiveTeam, Failed: {} }) +export const States = Machine.states({ + Loading: {}, + ActiveTeam, + Failed: {} +}) export const SelectionChild = Machine.child("selection", SelectionMachine) export const ReplaceChild = Machine.child("replace", ReplaceMachine) -const machine = Machine.make({ +export const machine = Machine.make({ states: States.states, events: TeamEvents, initial: (to) => to.Loading().resolve(({ target }) => target.from()) @@ -33,9 +35,7 @@ const machine = Machine.make({ invoke: ( from ) => [ - from.child(SelectionChild).onDone((to) => to.none).onFailure((to) => - to.full.Failed().resolve(({ target }) => target.from()) - ), + from.child(SelectionChild).onFailure((to) => to.full.Failed().resolve(({ target }) => target.from())), from.child(ReplaceChild).onFailure((to) => to.full.Failed().resolve(({ target }) => target.from())) ], on: { @@ -49,9 +49,3 @@ const machine = Machine.make({ }, Failed: {} }) - -const atomRuntime = Atom.runtime(PokemonService.layer) -export const machineAtom = AtomMachine.bind(atomRuntime).make(machine) - -export const selectionMachineAtom = machineAtom.child(SelectionChild) -export const replaceMachineAtom = machineAtom.child(ReplaceChild) diff --git a/examples/pokemon/src/machines/replace.ts b/examples/pokemon/src/machines/replace.ts index a50d7ac..43f1313 100644 --- a/examples/pokemon/src/machines/replace.ts +++ b/examples/pokemon/src/machines/replace.ts @@ -6,31 +6,17 @@ class Replacing extends Schema.TaggedClass("Replacing")("Replacing", id: Pokemon.fields.id }) {} -/** Events */ - -class ReplacePokemon extends Schema.TaggedClass("ReplacePokemon")("ReplacePokemon", { - id: Pokemon.fields.id -}) {} - -class Replaced extends Schema.TaggedClass("Replaced")("Replaced", { - pokemon: Pokemon -}) {} +export const ReplaceStates = Machine.states({ + Idle: {}, + Replacing +}) -const replaceWithRandom = Effect.sleep("500 millis").pipe( - Effect.andThen( - Effect.gen(function*() { - const pk = yield* PokemonService - const pokemon = yield* pk.getRandomPokemon() - return new Replaced({ pokemon }) - }) - ), - Effect.onInterrupt(() => Effect.log("Replace with random interrupted")) +export const ReplaceEvents = Machine.events( + Schema.TaggedUnion({ ReplacePokemon: { id: Pokemon.fields.id } }) +) +const ReplaceInternalEvents = Machine.internalEvents( + Schema.TaggedUnion({ Replaced: { pokemon: Pokemon } }) ) - -export const ReplaceStates = Machine.states({ Idle: {}, Replacing }) - -export const ReplaceEvents = Machine.events(ReplacePokemon) -const ReplaceInternalEvents = Machine.internalEvents(Replaced) export const ReplaceMachine = Machine.make({ states: ReplaceStates.states, events: ReplaceEvents, @@ -45,11 +31,21 @@ export const ReplaceMachine = Machine.make({ }, Replacing: { invoke: (from) => - from.effect("replaceWithRandom", () => replaceWithRandom).onDone((to) => - to.none.resolve(({ output }, enqueue) => { - enqueue.raise(ReplaceInternalEvents.Replaced({ pokemon: output.pokemon })) - }) - ).onFailure((to) => to.full.Idle().resolve(({ target }) => target.from())), + from.effect("replaceWithRandom", () => + Effect.sleep("500 millis").pipe( + Effect.andThen( + Effect.gen(function*() { + const service = yield* PokemonService + const pokemon = yield* service.getRandomPokemon() + return ReplaceInternalEvents.Replaced({ pokemon }) + }) + ), + Effect.onInterrupt(() => Effect.log("Replace with random interrupted")) + )).onDone((to) => + to.none.resolve(({ output }, enqueue) => { + enqueue.raise(output) + }) + ).onFailure((to) => to.full.Idle().resolve(({ target }) => target.from())), on: { Replaced: (to) => to.full.Idle().resolve(({ event, parent, state, target }, enqueue) => { diff --git a/examples/pokemon/src/machines/selection.ts b/examples/pokemon/src/machines/selection.ts index 6a84f05..fc7c188 100644 --- a/examples/pokemon/src/machines/selection.ts +++ b/examples/pokemon/src/machines/selection.ts @@ -2,93 +2,77 @@ import { Machine } from "@typeonce/effect-machine" import { Effect, Option, Schema } from "effect" import { Pokemon, PokemonService, TeamEvents } from "../pokemon.ts" -class Search extends Schema.TaggedClass("Search")("Search", { - searchText: Schema.String -}) {} - -class Selected extends Schema.TaggedClass("Selected")("Selected", { - id: Pokemon.fields.id -}) {} - -class WithPokemon extends Schema.TaggedClass("WithPokemon")("WithPokemon", { - pokemon: Pokemon -}) {} - -/** Events */ - -class SelectPokemon extends Schema.TaggedClass("SelectPokemon")("SelectPokemon", { - id: Pokemon.fields.id -}) {} - -class UpdateSearchText extends Schema.TaggedClass("UpdateSearchText")("UpdateSearchText", { - value: Schema.String -}) {} - -class SearchResult extends Schema.TaggedClass("SearchResult")("SearchResult", { - result: Schema.Option(Pokemon) -}) {} - -class ReplacePokemon extends Schema.TaggedClass("ReplacePokemon")("ReplacePokemon", { - id: Pokemon.fields.id -}) {} - -const searchPokemon = (searchText: string) => - Effect.sleep("500 millis").pipe( - Effect.andThen( - Effect.gen(function*() { - const pk = yield* PokemonService - const pokemon = yield* pk.getByName(searchText) - return new SearchResult({ result: pokemon }) - }) - ), - Effect.onInterrupt(() => Effect.log("Search interrupted")) - ) +const State = Schema.TaggedUnion({ + Selected: { + id: Pokemon.fields.id, + searchText: Schema.String + }, + WithPokemon: { pokemon: Pokemon } +}) export const SelectionStates = Machine.states({ form: { - type: "parallel", + initial: "Unselected", states: { - search: { - schema: Search, + Unselected: {}, + Selected: { + schema: State.cases.Selected, initial: "NoPokemon", states: { NoPokemon: {}, - WithPokemon, + WithPokemon: State.cases.WithPokemon, Searching: {} } - }, - selection: { - initial: "Unselected", - states: { - Unselected: {}, - Selected - } } } } }) -export const SelectionEvents = Machine.events(SelectPokemon, UpdateSearchText, SearchResult, ReplacePokemon) +export const SelectionEvents = Machine.events( + Schema.TaggedUnion({ + SelectPokemon: { id: Pokemon.fields.id }, + UpdateSearchText: { value: Schema.String }, + ReplacePokemon: {} + }) +) + +const SelectionInternalEvents = Machine.internalEvents( + Schema.TaggedUnion({ SearchResult: { result: Schema.Option(Pokemon) } }) +) + export const SelectionMachine = Machine.make({ states: SelectionStates.states, events: SelectionEvents, + internalEvents: SelectionInternalEvents, parent: Machine.parent(TeamEvents), - initial: (to) => - to.form.initial.resolve(({ target }) => - target.from((form) => - form - .search.from({ searchText: "" }, (search) => search.NoPokemon.from()) - .selection.from((selection) => selection.Unselected.from()) - ) - ) + initial: (to) => to.form.initial.resolve(({ target }) => target.from((form) => form.Unselected.from())) }).handle({ form: { states: { - search: { + Unselected: { + on: { + SelectPokemon: (to) => + to.local.Selected.initial.resolve(({ event, target }) => target.from({ id: event.id, searchText: "" })) + } + }, + Selected: { on: { + SelectPokemon: (to) => + to.branches({ + unselected: { title: "Unselect", target: to.branch.form.Unselected() }, + selected: { title: "Select another Pokémon", target: to.branch.form.Selected.initial } + }).resolve(({ event, select, state }) => + state.id === event.id + ? select.unselected.from() + : select.selected.from({ id: event.id, searchText: "" }) + ), UpdateSearchText: (to) => to.local.with.resolve( - ({ event, target }) => target.from({ searchText: event.value }, (search) => search.Searching.from()), + ({ event, state, target }) => + target.from( + { id: state.id, searchText: event.value }, + (selected) => selected.Searching.from() + ), { reenter: true } ) }, @@ -96,25 +80,35 @@ export const SelectionMachine = Machine.make({ WithPokemon: { on: { ReplacePokemon: (to) => - to.full.form().resolve(({ event, parent, state, target }, enqueue) => { - enqueue.sendTo(parent, TeamEvents.ReplaceInTeam({ id: event.id, pokemon: state.pokemon })) - return target.from((form) => - form - .search.from({ searchText: "" }, (search) => search.NoPokemon.from()) - .selection.from((selection) => selection.Unselected.from()) + to.branch.form.Unselected().resolve(({ ancestors, parent, state, target }, enqueue) => { + enqueue.sendTo( + parent, + TeamEvents.ReplaceInTeam({ + id: ancestors["form.Selected"].id, + pokemon: state.pokemon + }) ) + return target.from() }) } }, Searching: { invoke: (from) => - from.effect("search", ({ ancestors }) => searchPokemon(ancestors["form.search"].searchText)).onDone(( - to - ) => - to.none.resolve(({ output }, enqueue) => { - enqueue.raise(output) - }) - ).onFailure((to) => to.local.NoPokemon().resolve(({ target }) => target.from())), + from.effect("search", ({ ancestors }) => + Effect.sleep("500 millis").pipe( + Effect.andThen( + Effect.gen(function*() { + const service = yield* PokemonService + const pokemon = yield* service.getByName(ancestors["form.Selected"].searchText) + return SelectionInternalEvents.SearchResult({ result: pokemon }) + }) + ), + Effect.onInterrupt(() => Effect.log("Search interrupted")) + )).onDone((to) => + to.none.resolve(({ output }, enqueue) => { + enqueue.raise(output) + }) + ).onFailure((to) => to.local.NoPokemon().resolve(({ target }) => target.from())), on: { SearchResult: (to) => to.branches({ @@ -129,28 +123,6 @@ export const SelectionMachine = Machine.make({ } } } - }, - selection: { - states: { - Unselected: { - on: { - SelectPokemon: (to) => to.local.Selected().resolve(({ event, target }) => target.from({ id: event.id })) - } - }, - Selected: { - on: { - SelectPokemon: (to) => - to.branches({ - alreadySelected: { title: "Already selected", target: to.local.Unselected() }, - selected: { target: to.local.Selected() } - }).resolve(({ event, select, state }) => - state.id === event.id - ? select.alreadySelected.from() - : select.selected.from({ id: event.id }) - ) - } - } - } } } } diff --git a/examples/pokemon/src/pokemon.ts b/examples/pokemon/src/pokemon.ts index cf91c02..ca25c46 100644 --- a/examples/pokemon/src/pokemon.ts +++ b/examples/pokemon/src/pokemon.ts @@ -18,12 +18,14 @@ export const Pokemon = Schema.Struct({ }) }) -export class ReplaceInTeam extends Schema.TaggedClass("ReplaceInTeam")("ReplaceInTeam", { - id: Pokemon.fields.id, - pokemon: Pokemon -}) {} - -export const TeamEvents = Machine.events(ReplaceInTeam) +export const TeamEvents = Machine.events( + Schema.TaggedUnion({ + ReplaceInTeam: { + id: Pokemon.fields.id, + pokemon: Pokemon + } + }) +) export class PokemonService extends Context.Service()("app/PokemonService", { make: Effect.gen(function*() { diff --git a/examples/pokemon/src/router.tsx b/examples/pokemon/src/router.tsx index cb3bde2..f07111c 100644 --- a/examples/pokemon/src/router.tsx +++ b/examples/pokemon/src/router.tsx @@ -2,7 +2,8 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react" import { createRootRoute, createRoute, createRouter, Link, Outlet } from "@tanstack/react-router" import { Match, Option } from "effect" import { AsyncResult } from "effect/unstable/reactivity" -import { machineAtom, replaceMachineAtom, selectionMachineAtom, States } from "./machine.js" +import { machineAtom, replaceMachineAtom, selectionMachineAtom } from "./atoms.js" +import { States } from "./machine.js" import { ReplaceEvents, ReplaceStates } from "./machines/replace.ts" import { SelectionEvents, SelectionStates } from "./machines/selection.ts" import type { Pokemon } from "./pokemon.ts" @@ -60,13 +61,10 @@ function Selection() {

Selection state: {state.value.path}

- {Option.all({ - selected: SelectionStates.get(state.value, "form.selection.Selected"), - search: SelectionStates.get(state.value, "form.search") - }).pipe( + {SelectionStates.get(state.value, "form.Selected").pipe( Option.match({ onNone: () =>

No Pokémon selected.

, - onSome: ({ selected: { id }, search: { searchText } }) => ( + onSome: ({ id, searchText }) => ( <>

Selected Pokémon id: {id}

@@ -75,16 +73,16 @@ function Selection() { onChange={(event) => send(SelectionEvents.UpdateSearchText({ value: event.target.value }))} /> - {SelectionStates.matches(state.value, "form.search.Searching") &&

Searching…

} + {SelectionStates.matches(state.value, "form.Selected.Searching") &&

Searching…

} - {SelectionStates.get(state.value, "form.search.WithPokemon").pipe( + {SelectionStates.get(state.value, "form.Selected.WithPokemon").pipe( Option.match({ onNone: () => null, onSome: ({ pokemon }) => (

{pokemon.name}

{pokemon.name} -
@@ -137,7 +135,7 @@ function PokemonGrid({ team }: { team: readonly (typeof Pokemon.Type)[] }) { Failure: () => null, Success: (current) => current.value.pipe( - Option.flatMap((current) => SelectionStates.get(current, "form.selection.Selected")), + Option.flatMap((current) => SelectionStates.get(current, "form.Selected")), Option.map((current) => current.id), Option.getOrNull )