From 3fbc33aa337f327d44c1a2fa4417fa66e0c67a80 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Thu, 27 Aug 2026 10:44:49 +0200 Subject: [PATCH] Add static devtools site builds --- .changeset/static-machine-sites.md | 7 + .gitignore | 1 + README.md | 61 ++++- packages/devtools/README.md | 21 ++ packages/devtools/src/bin.ts | 32 ++- .../devtools/src/internal/browser/main.ts | 25 ++- packages/devtools/src/internal/staticSite.ts | 209 ++++++++++++++++++ packages/devtools/test/StaticSite.test.ts | 85 +++++++ packages/devtools/vite.config.ts | 1 + scripts/devtools-pack-check.mjs | 29 ++- 10 files changed, 462 insertions(+), 9 deletions(-) create mode 100644 .changeset/static-machine-sites.md create mode 100644 packages/devtools/src/internal/staticSite.ts create mode 100644 packages/devtools/test/StaticSite.test.ts diff --git a/.changeset/static-machine-sites.md b/.changeset/static-machine-sites.md new file mode 100644 index 0000000..faba987 --- /dev/null +++ b/.changeset/static-machine-sites.md @@ -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. diff --git a/.gitignore b/.gitignore index 91d443e..f00949a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ dist/ references/ .data/ +.effect-machine/ *.tgz .DS_Store .pnpm-store diff --git a/README.md b/README.md index 8c702a4..0796519 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/packages/devtools/README.md b/packages/devtools/README.md index b6e0a5d..57d0533 100644 --- a/packages/devtools/README.md +++ b/packages/devtools/README.md @@ -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: diff --git a/packages/devtools/src/bin.ts b/packages/devtools/src/bin.ts index 193af46..5a954b8 100644 --- a/packages/devtools/src/bin.ts +++ b/packages/devtools/src/bin.ts @@ -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" @@ -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( @@ -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 diff --git a/packages/devtools/src/internal/browser/main.ts b/packages/devtools/src/internal/browser/main.ts index ee52cbe..4c54e6d 100644 --- a/packages/devtools/src/internal/browser/main.ts +++ b/packages/devtools/src/internal/browser/main.ts @@ -8,6 +8,8 @@ import { mountMachineIndex } from "./machine-index.js" const root = document.querySelector("#app") if (root === null) throw new Error("Visualizer root element was not found") +const staticData = document.querySelector("meta[name=\"effect-machine-static-data\"]")?.content + const showConnectionFailure = (message: string): void => { const failure = document.createElement("div") failure.className = "connection-failure" @@ -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 + }, + 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 +) diff --git a/packages/devtools/src/internal/staticSite.ts b/packages/devtools/src/internal/staticSite.ts new file mode 100644 index 0000000..7f1dafa --- /dev/null +++ b/packages/devtools/src/internal/staticSite.ts @@ -0,0 +1,209 @@ +import * as Effect from "effect/Effect" +import * as FileSystem from "effect/FileSystem" +import * as Path from "effect/Path" +import * as Schema from "effect/Schema" +import { fileURLToPath } from "node:url" +import { build as buildVite } from "vite" +import PackageJson from "../../package.json" with { type: "json" } +import * as DevToolsProtocol from "../DevToolsProtocol.js" +import * as MachineDocument from "../MachineDocument.js" +import * as ProjectInspector from "../ProjectInspector.js" + +export interface Options { + readonly root: string + readonly include?: string | undefined + readonly outputDirectory: string +} + +export interface BuildResult { + readonly outputDirectory: string + readonly machineIds: ReadonlyArray +} + +export class StaticSiteError extends Schema.Error( + "@typeonce/effect-machine-devtools/internal/StaticSiteError" +)({ + _tag: Schema.tag("StaticSiteError"), + message: Schema.String, + cause: Schema.optional(Schema.Defect()) +}) {} + +const packageRoot = fileURLToPath(new URL("../..", import.meta.url)) +const generatedMarker = ".effect-machine-site" +const staticDataMeta = "" + +const prettyJson = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n` + +export const staticIndex = (index: string): Effect.Effect => { + if (!index.includes("")) { + return Effect.fail( + new StaticSiteError({ + message: "The visualizer index does not contain a closing head element" + }) + ) + } + return Effect.succeed(index.replace("", ` ${staticDataMeta}\n `)) +} + +const formatFailures = (failures: ReadonlyArray): string => + failures.map((failure) => { + const messages = failure.diagnostics.map((diagnostic) => diagnostic.message).join("; ") + return `- ${failure.key}: ${messages}` + }).join("\n") + +const ensureReplaceable = Effect.fnUntraced( + function*( + fs: FileSystem.FileSystem, + path: Path.Path, + outputDirectory: string + ) { + if (!(yield* fs.exists(outputDirectory))) return + if (yield* fs.exists(path.join(outputDirectory, generatedMarker))) return + const entries = yield* fs.readDirectory(outputDirectory) + if (entries.length === 0) return + return yield* new StaticSiteError({ + message: `Refusing to replace non-generated directory: ${outputDirectory}` + }) + }, + (effect, _fs, _path, outputDirectory) => + effect.pipe( + Effect.mapError((cause) => + cause instanceof StaticSiteError + ? cause + : new StaticSiteError({ + message: `Could not inspect output directory: ${outputDirectory}`, + cause + }) + ) + ) +) + +const inspect = Effect.fnUntraced(function*(options: Options) { + const inspector = yield* ProjectInspector.ProjectInspector + const results = yield* inspector.inspect({ + root: options.root, + include: options.include, + revision: 1 + }) + const failures = results.filter((result): result is DevToolsProtocol.Failed => result._tag === "Failed") + if (failures.length > 0) { + return yield* new StaticSiteError({ + message: `Static site generation failed for ${failures.length} machine candidate${ + failures.length === 1 ? "" : "s" + }:\n${formatFailures(failures)}` + }) + } + const ready = results + .filter((result): result is DevToolsProtocol.Ready => result._tag === "Ready") + .sort((left, right) => left.key.localeCompare(right.key)) + if (ready.length === 0) { + return yield* new StaticSiteError({ + message: `No Effect Machine definitions were found under ${options.root}` + }) + } + const snapshot = yield* Schema.decodeUnknownEffect(DevToolsProtocol.RegistrySnapshot)({ + protocolVersion: DevToolsProtocol.protocolVersion, + revision: 1, + results: ready + }).pipe( + Effect.mapError((cause) => + new StaticSiteError({ + message: "The generated machine registry is invalid", + cause + }) + ) + ) + return { ready, snapshot } +}) + +export const build = (options: Options): Effect.Effect< + BuildResult, + StaticSiteError, + FileSystem.FileSystem | Path.Path | ProjectInspector.ProjectInspector +> => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const root = path.resolve(options.root) + const outputDirectory = path.resolve(options.outputDirectory) + if (outputDirectory === root || path.dirname(outputDirectory) === outputDirectory) { + return yield* new StaticSiteError({ + message: `Refusing to replace project or filesystem root: ${outputDirectory}` + }) + } + yield* ensureReplaceable(fs, path, outputDirectory) + const { ready, snapshot } = yield* inspect({ ...options, root }) + const parent = path.dirname(outputDirectory) + yield* fs.makeDirectory(parent, { recursive: true }) + + return yield* Effect.acquireUseRelease( + fs.makeTempDirectory({ directory: parent, prefix: ".effect-machine-site-" }), + (stagingDirectory) => + Effect.gen(function*() { + yield* Effect.tryPromise({ + try: () => + buildVite({ + root: packageRoot, + base: "./", + configFile: false, + logLevel: "error", + build: { + outDir: stagingDirectory, + emptyOutDir: true + } + }), + catch: (cause) => + new StaticSiteError({ + message: "Could not bundle the static visualizer", + cause + }) + }) + + const indexPath = path.join(stagingDirectory, "index.html") + const index = yield* fs.readFileString(indexPath) + yield* fs.writeFileString(indexPath, yield* staticIndex(index)) + yield* fs.writeFileString(path.join(stagingDirectory, "machines.json"), prettyJson(snapshot)) + yield* fs.writeFileString( + path.join(stagingDirectory, "manifest.json"), + prettyJson({ + formatVersion: 1, + generator: { + name: PackageJson.name, + version: PackageJson.version + }, + protocolVersion: DevToolsProtocol.protocolVersion, + machineDocumentSchemaVersion: MachineDocument.schemaVersion, + machines: ready.map((result) => ({ + key: result.key, + machineId: result.document.machineId, + source: result.document.source + })) + }) + ) + yield* fs.writeFileString( + path.join(stagingDirectory, generatedMarker), + `${PackageJson.name}@${PackageJson.version}\n` + ) + + yield* ensureReplaceable(fs, path, outputDirectory) + if (yield* fs.exists(outputDirectory)) { + yield* fs.remove(outputDirectory, { recursive: true }) + } + yield* fs.rename(stagingDirectory, outputDirectory) + return { + outputDirectory, + machineIds: ready.map((result) => result.document.machineId) + } + }), + (stagingDirectory) => fs.remove(stagingDirectory, { recursive: true }).pipe(Effect.ignore) + ) + }).pipe( + Effect.mapError((cause) => + cause instanceof StaticSiteError + ? cause + : new StaticSiteError({ + message: "Could not build the Effect Machine static site", + cause + }) + ) + ) diff --git a/packages/devtools/test/StaticSite.test.ts b/packages/devtools/test/StaticSite.test.ts new file mode 100644 index 0000000..fdcd555 --- /dev/null +++ b/packages/devtools/test/StaticSite.test.ts @@ -0,0 +1,85 @@ +import * as NodeServices from "@effect/platform-node/NodeServices" +import { assert, describe, it } from "@effect/vitest" +import * as Effect from "effect/Effect" +import * as FileSystem from "effect/FileSystem" +import * as Layer from "effect/Layer" +import * as Path from "effect/Path" +import * as Schema from "effect/Schema" +import * as DevToolsProtocol from "../src/DevToolsProtocol.js" +import * as StaticSite from "../src/internal/staticSite.js" +import * as ProjectInspector from "../src/ProjectInspector.js" + +const TestLayer = Layer.mergeAll(NodeServices.layer, ProjectInspector.layer) + +const localExampleMachineIds = [ + "inspection-example", + "invoke-gallery-child", + "invoke-outcomes", + "layout-resilience", + "optional-parent-protocol", + "parallel-completion", + "parent-child-protocol", + "planner-example", + "required-parent-child", + "transition-semantics" +] + +describe("StaticSite", () => { + it.effect("builds a validated static website from inspected machines", () => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "effect-machine-static-site-" }) + const outputDirectory = path.join(directory, "site") + const result = yield* StaticSite.build({ + root: process.cwd(), + include: "packages/devtools/src/internal/browser/{example-machine,*-example}.ts", + outputDirectory + }) + const index = yield* fs.readFileString(path.join(outputDirectory, "index.html")) + const snapshot = yield* fs.readFileString(path.join(outputDirectory, "machines.json")) + const manifest = yield* fs.readFileString(path.join(outputDirectory, "manifest.json")) + const assets = yield* fs.readDirectory(path.join(outputDirectory, "assets")) + + assert.deepStrictEqual([...result.machineIds].sort(), localExampleMachineIds) + assert.include(index, "") + assert.match(index, /(?:src|href)="\.\/assets\//) + assert.strictEqual( + Schema.decodeUnknownSync(DevToolsProtocol.RegistrySnapshot)(JSON.parse(snapshot)).results.length, + localExampleMachineIds.length + ) + const parsedManifest = JSON.parse(manifest) as { + readonly formatVersion?: unknown + readonly protocolVersion?: unknown + readonly machines?: ReadonlyArray<{ readonly machineId?: unknown }> + } + assert.strictEqual(parsedManifest.formatVersion, 1) + assert.strictEqual(parsedManifest.protocolVersion, DevToolsProtocol.protocolVersion) + assert.deepStrictEqual( + parsedManifest.machines?.map(({ machineId }) => machineId).sort(), + localExampleMachineIds + ) + assert.isTrue(assets.some((asset) => asset.endsWith(".js"))) + assert.isTrue(assets.some((asset) => asset.endsWith(".css"))) + }).pipe(Effect.provide(TestLayer))) + + it.effect("refuses to generate an index without a head element", () => + Effect.gen(function*() { + const failure = yield* Effect.flip(StaticSite.staticIndex("")) + assert.strictEqual(failure._tag, "StaticSiteError") + })) + + it.effect("refuses to replace an unrelated non-empty directory", () => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const outputDirectory = yield* fs.makeTempDirectoryScoped({ prefix: "effect-machine-static-site-" }) + yield* fs.writeFileString(path.join(outputDirectory, "notes.txt"), "keep me") + const failure = yield* Effect.flip(StaticSite.build({ + root: process.cwd(), + include: "packages/devtools/src/internal/browser/example-machine.ts", + outputDirectory + })) + assert.include(failure.message, "Refusing to replace non-generated directory") + }).pipe(Effect.provide(TestLayer))) +}) diff --git a/packages/devtools/vite.config.ts b/packages/devtools/vite.config.ts index a6e64c7..ff99ce9 100644 --- a/packages/devtools/vite.config.ts +++ b/packages/devtools/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "vite" export default defineConfig({ root: new URL(".", import.meta.url).pathname, + base: "./", server: { host: "127.0.0.1", port: 5173, diff --git a/scripts/devtools-pack-check.mjs b/scripts/devtools-pack-check.mjs index 0e6fc92..5fc0aa1 100644 --- a/scripts/devtools-pack-check.mjs +++ b/scripts/devtools-pack-check.mjs @@ -8,6 +8,7 @@ const repositoryRoot = resolve(import.meta.dirname, "..") const destination = await mkdtemp(join(tmpdir(), "effect-machine-devtools-pack-")) const consumer = join(destination, "consumer") const machineFile = join(consumer, "src", "machine.ts") +const staticSite = join(consumer, "machine-site") let child const childOutput = [] @@ -124,8 +125,8 @@ try { throw new Error(`packed package versions differ: core ${corePackage.version}, devtools ${devtoolsPackage.version}`) } const help = run(binary, ["--help"], { cwd: consumer }) - if (!help.stdout.includes("--watch-polling")) { - throw new Error("installed CLI help does not document the polling fallback") + if (!help.stdout.includes("--watch-polling") || !help.stdout.includes("build")) { + throw new Error("installed CLI help does not document live and static workflows") } run(process.execPath, [ @@ -145,6 +146,28 @@ try { throw new Error("ProjectInspector is unexpectedly importable from the packed package") } + const staticBuild = run(binary, [ + "build", + "--root", + consumer, + "--out-dir", + staticSite + ], { cwd: consumer }) + if (!staticBuild.stdout.includes("1 machine: packed-fixture")) { + throw new Error(`installed CLI did not report the generated machine\n${staticBuild.stdout.trim()}`) + } + const staticIndex = await readFile(join(staticSite, "index.html"), "utf8") + const staticSnapshot = JSON.parse(await readFile(join(staticSite, "machines.json"), "utf8")) + const staticManifest = JSON.parse(await readFile(join(staticSite, "manifest.json"), "utf8")) + if ( + !staticIndex.includes('content="./machines.json"') || + !staticIndex.includes('src="./assets/') || + staticSnapshot.results?.[0]?.document?.machineId !== "packed-fixture" || + staticManifest.machines?.[0]?.machineId !== "packed-fixture" + ) { + throw new Error("installed CLI generated an invalid static website") + } + const port = await availablePort() child = spawn(binary, [ "--root", @@ -223,7 +246,7 @@ try { ) } - console.log("installed devtools CLI, worker, browser, live reload, shutdown, and failure handling passed") + console.log("installed devtools CLI, worker, browser, static build, live reload, shutdown, and failure handling passed") } catch (cause) { if (child !== undefined) { child.kill("SIGKILL")