diff --git a/.changeset/headless-agent-runtime-dom-free.md b/.changeset/headless-agent-runtime-dom-free.md new file mode 100644 index 00000000..b2e0e7f9 --- /dev/null +++ b/.changeset/headless-agent-runtime-dom-free.md @@ -0,0 +1,10 @@ +--- +"@ignite-element/core": minor +"@ignite-element/adapters": minor +"@ignite-element/renderer": minor +"ignite-element": minor +--- + +Make the headless agent runtime DOM-free, so `getSchema()` / `execute()` / `on()` / `watchView()` work in pure Node and edge runtimes with no jsdom polyfill. + +The agent runtime is meant to be headless, but it allocated its internal host element via `document.createElement`, so `getSchema()` / `execute()` threw `document is not defined` in a non-DOM runtime. That host is only ever used as an **EventTarget** — `on()` registers `host.addEventListener` / `removeEventListener` and effect emits go through `host.dispatchEvent` — so a real element was never required for headless use. `createRuntimeHost` now falls back to a bare `EventTarget` when there is no `document` (Node 22 ships `EventTarget` + `CustomEvent` globally), and keeps `document.createElement` when a real or jsdom DOM is present (no behavior change in the browser or in tests). The DOM render path (the custom element / DOM bridge) is unchanged and still requires a real DOM. This unblocks running an igniteTools agent loop — the act → observe → act surface — headless on a server, CLI, or edge device with zero DOM shim. diff --git a/packages/ignite-element/src/IgniteElementFactory.ts b/packages/ignite-element/src/IgniteElementFactory.ts index 7703cc97..d099b435 100644 --- a/packages/ignite-element/src/IgniteElementFactory.ts +++ b/packages/ignite-element/src/IgniteElementFactory.ts @@ -327,7 +327,24 @@ export default function igniteElementFactory< sharedInstanceCount = 0; }; - const createRuntimeHost = () => document.createElement("div"); + // The headless agent runtime (getSchema/execute/on/watchView) only ever uses + // its host as an EventTarget — `on()` calls host.addEventListener/ + // removeEventListener and effect emits go through host.dispatchEvent. So in a + // non-DOM runtime (Node, edge) it does not need a real element: a bare + // EventTarget keeps the agent runtime fully DOM-free. The render path + // (createRuntimeDomBridge / the custom element) creates its own real element + // and still requires a DOM. + // + // The `as unknown as HTMLElement` cast is forced because the runtime host + // flows through the same `Host` generic the render path uses (typed + // HTMLElement for the JSX renderer's host.appendChild). It is safe here — no + // code path touches the runtime host with element-only APIs (verified) — but + // the proper fix is to split the agent-runtime host type (EventTarget) from + // the render-host `Host` generic so this cast disappears (tracked follow-up). + const createRuntimeHost = (): HTMLElement => + typeof document === "undefined" + ? (new EventTarget() as unknown as HTMLElement) + : document.createElement("div"); const createRuntimeDomBridge = ( renderer: ComponentRenderer, diff --git a/packages/ignite-element/src/tests/agent-runtime-headless-node.test.ts b/packages/ignite-element/src/tests/agent-runtime-headless-node.test.ts new file mode 100644 index 00000000..4abb26f5 --- /dev/null +++ b/packages/ignite-element/src/tests/agent-runtime-headless-node.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment node +// +// The headless agent runtime must be DOM-free: getSchema()/execute()/on()/ +// watchView() have to work in pure Node (no jsdom). Before the createRuntimeHost +// fix these threw "document is not defined" via createRuntimeHost -> +// document.createElement. This file runs in the `node` environment (overriding +// the package's global jsdom) so it would fail without a genuinely DOM-free +// runtime. The DOM render path is intentionally not exercised here — it still +// requires a real DOM. +import { describe, expect, it, vi } from "vitest"; +import { assign, setup } from "xstate"; +import { igniteCore } from "../xstate"; + +function createCounter() { + const machine = setup({ + types: { + context: {} as { count: number }, + events: {} as { type: "INC" }, + }, + }).createMachine({ + id: "headless-counter", + context: { count: 0 }, + initial: "active", + states: { + active: { + on: { + INC: { + actions: assign({ count: ({ context }) => context.count + 1 }), + }, + }, + }, + }, + }); + + return igniteCore({ + source: machine, + events: (event) => ({ counted: event<{ count: number }>() }), + view: ({ snapshot }) => ({ count: snapshot.context.count }), + commands: ({ actor }) => ({ + increment: () => actor.send({ type: "INC" }), + }), + effects: ({ emit, select }) => { + const count = select((state) => state.context.count); + if (count.changed) { + emit("counted", { count: count.current }); + } + }, + }); +} + +describe("agent runtime is DOM-free (pure Node, no jsdom)", () => { + it("runs in an environment with no document", () => { + expect(typeof document).toBe("undefined"); + }); + + it("getSchema() builds the manifest without a DOM", () => { + const counter = createCounter(); + const schema = counter.getSchema(); + expect(Object.keys(schema.commands)).toContain("increment"); + expect(schema.events).toContain("counted"); + expect(schema.view).toMatchObject({ count: 0 }); + }); + + it("execute() runs a command and returns the post-ack snapshot + events", async () => { + const counter = createCounter(); + const result = await counter.execute("increment"); + expect(result.state.context.count).toBe(1); + expect(result.events).toEqual([{ type: "counted", payload: { count: 1 } }]); + }); + + it("on() receives effect-emitted events via the host EventTarget", async () => { + const counter = createCounter(); + const handler = vi.fn(); + const subscription = counter.on("counted", handler); + + await counter.execute("increment"); + + expect(handler).toHaveBeenCalledTimes(1); + const event = handler.mock.calls[0][0] as CustomEvent<{ count: number }>; + expect(event.detail).toEqual({ count: 1 }); + subscription.unsubscribe(); + }); + + it("watchView()/getView() observe the derived view without a DOM", async () => { + const counter = createCounter(); + const seen: Array<{ count: number }> = []; + const subscription = counter.watchView((view) => seen.push(view)); + + await counter.execute("increment"); + + expect(counter.getView()).toEqual({ count: 1 }); + expect(seen[seen.length - 1]).toEqual({ count: 1 }); + subscription.unsubscribe(); + }); +});