Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/static-machine-sites.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@typeonce/effect-machine-devtools": minor
---

Add `effect-machine build` for publishing the project visualizer as a static website.

The command inspects the selected machines once, validates their documents, and writes relative HTML, CSS, JavaScript, `machines.json`, and build metadata to `--out-dir`. The generated site keeps the interactive statechart and topology walkthrough without a live devtools server or project code at viewing time.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ node_modules/
dist/
references/
.data/
.effect-machine/
*.tgz
.DS_Store
.pnpm-store
Expand Down
61 changes: 57 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,64 @@
# Effect Machine
# @typeonce/effect-machine

This repository is the pnpm workspace for Effect Machine and its development tools.
Effect-native, schema-first, completely type-safe state machines and statecharts, inspired by [XState](https://github.com/statelyai/xstate).

> The goal of `effect-machine` is to become a core [effect](https://github.com/Effect-TS/effect) module.
>
> It originates from [the following PR](https://github.com/Effect-TS/effect/pull/6429#issuecomment-5109812313).

## Quick look

```ts
import { Machine } from "@typeonce/effect-machine"
import { Effect, Schema } from "effect"

const States = Machine.states({
Locked: {},
Unlocked: {}
})

const Events = Machine.events(
Schema.TaggedUnion({
Coin: {},
Push: {}
})
)

const Turnstile = Machine.make({
id: "Turnstile",
states: States.states,
events: Events,
initial: (to) => to.Locked()
}).handle({
Locked: {
on: { Coin: (to) => to.full.Unlocked() }
},
Unlocked: {
on: { Push: (to) => to.full.Locked() }
}
})

const program = Effect.gen(function*() {
const ref = yield* Machine.start(Turnstile)
yield* ref.send(Events.Coin())
})
```

State and event schemas define the protocol. The handler tree defines the
statechart, and the result runs as an Effect-managed machine.

## Packages

The workspace publishes three packages at the same version:

- [`@typeonce/effect-machine`](./packages/effect-machine/README.md) contains the machine runtime, testing modules, and documentation.
- [`@typeonce/effect-machine-devtools`](./packages/devtools/README.md) contains the publishable local machine visualizer and CLI.
- [`@typeonce/oxlint-plugin-effect-machine`](./packages/oxlint-plugin/README.md) checks Effect Machine models for common structural mistakes.

All three packages use the same version. Install matching versions so the runtime, devtools, and lint rules stay aligned.
Install matching versions so the runtime, devtools, and lint rules stay aligned.

---

[XState](https://github.com/statelyai/xstate) is the project's main inspiration and reference for statechart semantics and API coverage.

See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for repository development and validation commands.
Direct API comparisons exposed gaps in Effect Machine, and benchmarks against XState drove many runtime performance improvements. `effect-machine` is **not** a direct XState replacement.
21 changes: 21 additions & 0 deletions packages/devtools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,27 @@ pnpm exec effect-machine \

Native file-system events are the default. Polling scans more frequently and may use more CPU in large repositories, so enable it only when the platform watcher misses changes.

## Build a static website

Generate the same visualizer as static files:

```sh
pnpm exec effect-machine build
```

The default output directory is `.effect-machine/site`. Use the shared project flags and `--out-dir` to select a source tree and destination:

```sh
pnpm exec effect-machine build \
--root ./packages/app \
--include "src/**/*.ts" \
--out-dir ./dist/machine-docs
```

The command writes `index.html`, `machines.json`, `manifest.json`, and content-hashed browser assets. Serve that directory with any static file host. The website does not need Node.js, the project source, or a devtools server after generation. Rebuild it when the machine definitions change.

Static generation evaluates candidate project modules once in the same isolated worker used by the live visualizer. It fails when no machines are found or any candidate cannot produce a valid document. The command replaces only an empty directory or a directory previously generated by Effect Machine, so an unrelated non-empty destination is never removed.

## Live results

The browser reports one of these statuses for every candidate:
Expand Down
32 changes: 31 additions & 1 deletion packages/devtools/src/bin.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
#!/usr/bin/env node
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
import * as NodeServices from "@effect/platform-node/NodeServices"
import * as Console from "effect/Console"
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import * as Command from "effect/unstable/cli/Command"
import * as Flag from "effect/unstable/cli/Flag"
import PackageJson from "../package.json" with { type: "json" }
import * as DevServer from "./DevServer.js"
import * as StaticSite from "./internal/staticSite.js"
import * as MachineRegistry from "./MachineRegistry.js"
import * as ProjectInspector from "./ProjectInspector.js"

Expand Down Expand Up @@ -40,7 +42,18 @@ const watchPolling = Flag.boolean("watch-polling").pipe(
Flag.withDefault(false)
)

const cli = Command.make("effect-machine", { root, include, host, port, open, watchPolling }).pipe(
const outputDirectory = Flag.directory("out-dir").pipe(
Flag.withAlias("o"),
Flag.withDescription("Directory to write the static website"),
Flag.withDefault(".effect-machine/site")
)

const base = Command.make("effect-machine", { host, port, open, watchPolling }).pipe(
Command.withSharedFlags({ root, include }),
Command.withDescription("Inspect and publish Effect Machine visualizations")
)

const dev = base.pipe(
Command.withDescription("Inspect Effect Machine definitions in a live local visualizer"),
Command.withHandler(({ host, include, open, port, root, watchPolling }) => {
const RegistryLayer = MachineRegistry.layer({ root, include }).pipe(
Expand All @@ -50,6 +63,23 @@ const cli = Command.make("effect-machine", { root, include, host, port, open, wa
})
)

const build = Command.make("build", { outputDirectory }).pipe(
Command.withDescription("Generate a static website from the project's machines"),
Command.withHandler(Effect.fnUntraced(function*({ outputDirectory }) {
const { include, root } = yield* base
const result = yield* StaticSite.build({ root, include, outputDirectory }).pipe(
Effect.provide(ProjectInspector.layer)
)
yield* Console.log(
`Effect Machine static site: ${result.outputDirectory}\n${result.machineIds.length} machine${
result.machineIds.length === 1 ? "" : "s"
}: ${result.machineIds.join(", ")}`
)
}))
)

const cli = dev.pipe(Command.withSubcommands([build]))

Command.run(cli, { version: PackageJson.version }).pipe(
Effect.provide(NodeServices.layer),
NodeRuntime.runMain
Expand Down
25 changes: 24 additions & 1 deletion packages/devtools/src/internal/browser/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { mountMachineIndex } from "./machine-index.js"
const root = document.querySelector<HTMLDivElement>("#app")
if (root === null) throw new Error("Visualizer root element was not found")

const staticData = document.querySelector<HTMLMetaElement>("meta[name=\"effect-machine-static-data\"]")?.content

const showConnectionFailure = (message: string): void => {
const failure = document.createElement("div")
failure.className = "connection-failure"
Expand Down Expand Up @@ -39,4 +41,25 @@ const connect = Effect.acquireRelease(
)
)

Effect.scoped(connect).pipe(BrowserRuntime.runMain)
const loadStatic = (location: string) =>
Effect.tryPromise({
try: async () => {
const response = await fetch(location)
if (!response.ok) throw new Error(`Could not load ${location}: ${response.status} ${response.statusText}`)
return response.json() as Promise<unknown>
},
catch: (cause) => cause instanceof Error ? cause : new Error(String(cause))
}).pipe(
Effect.flatMap(Schema.decodeUnknownEffect(DevToolsProtocol.RegistrySnapshot)),
Effect.tap((snapshot) => Effect.sync(() => mountMachineIndex(root, snapshot))),
Effect.asVoid
)

const run = staticData === undefined ? Effect.scoped(connect) : loadStatic(staticData)

run.pipe(
Effect.catch((cause) =>
Effect.sync(() => showConnectionFailure(cause instanceof Error ? cause.message : String(cause)))
),
BrowserRuntime.runMain
)
Loading