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
10 changes: 10 additions & 0 deletions .changeset/headless-agent-runtime-dom-free.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 18 additions & 1 deletion packages/ignite-element/src/IgniteElementFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
0xjcf marked this conversation as resolved.
: document.createElement("div");

const createRuntimeDomBridge = (
renderer: ComponentRenderer<RenderArgs, View>,
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading