Custom I/O processors

An Event I/O Processor carries <send> messages out of a chart and delivers events back in (SCXML spec §6.2.4 and Appendix C). The built-in SCXML processor handles sessions talking to each other: #_internal, #_parent, #_<invokeid> and #_scxml_<sessionid> targets. Anything else (a server, a worker, a message bus) is a custom processor that you pass to createSession().

The spec's Basic HTTP processor isn't included; the example below shows how to write one.

The interface#

import type { IOProcessor } from "@tinyactors/scxmljs";

const processor: IOProcessor = {
  type: "urn:example:api", // canonical type URI: the key in _ioprocessors, and _event.origintype
  aliases: ["api"], // short names allowed in <send type="…">
  location: (session) => `urn:example:api/${session.sessionId}`, // _ioprocessors[type].location
  attach(session) {}, // a session using this processor started: keep `session` to deliver events
  detach(session) {}, // it terminated or was disposed: stop delivering
  send(message, session) {}, // transport one message; throwing raises error.communication
};

send() receives an OutboundSend: event, target, type (the canonical URI), data (the <param>/namelist values as an object, or the <content> value) and sendid. It runs when the send is due, so delay has already passed.

session.deliver(name, data?, origin?) puts an event on the session's external queue. The event arrives with origintype set to your type and origin set to what you pass, so the chart can reply with <send type="…" targetexpr="_event.origin">.

One processor object can serve many sessions: attach and detach tell it which ones exist. Invoked child sessions get the same processors as their parent.

Example: a backend#

order.scxml sends a request when it starts and waits for the answer:

<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" datamodel="ecmascript" name="order" initial="saving">
  <state id="saving">
    <onentry>
      <send type="api" target="/orders" event="order.create">
        <param name="item" expr="'tea'"/>
      </send>
    </onentry>
    <transition event="order.created" target="saved"/>
    <transition event="error.communication" target="failed"/>
  </state>
  <final id="saved">
    <donedata><param name="id" expr="_event.data.id"/></donedata>
  </final>
  <final id="failed"/>
</scxml>

A processor that pretends to be the backend:

import { readFile } from "node:fs/promises";
import { createSession, type IOProcessor } from "@tinyactors/scxmljs";
import { Window } from "happy-dom";

const api: IOProcessor = {
  type: "urn:example:api",
  aliases: ["api"],
  location: (session) => `urn:example:api/${session.sessionId}`,
  send(message, session) {
    console.log("request", message.target, message.event, JSON.stringify(message.data));
    if (message.event !== "order.create") throw new Error(`unknown request ${message.event}`);
    setTimeout(() => session.deliver("order.created", { id: 42 }, message.target), 10);
  },
};

const { DOMParser } = new Window();
const session = await createSession(await readFile("order.scxml", "utf8"), {
  domParser: new DOMParser(),
  ioprocessors: [api],
});
session.start();
console.log("done", JSON.stringify(await session.done));
session.dispose();
request /orders order.create {"item":"tea"}
done {"id":42}

If send() throws, the chart gets error.communication and moves to failed. A <send> with a type that no processor handles raises error.execution.

Example: HTTP with fetch#

A processor that POSTs the event as JSON and delivers the response as <event>.done, or error.communication when the request fails:

import type { IOProcessor, IOSession } from "@tinyactors/scxmljs";

export function httpProcessor(base: string): IOProcessor {
  const live = new Set<IOSession>();
  return {
    type: "urn:example:http",
    aliases: ["http"],
    location: () => base,
    attach: (session) => void live.add(session),
    detach: (session) => void live.delete(session),
    send(message, session) {
      fetch(new URL(message.target, base), {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ event: message.event, data: message.data }),
      })
        .then(async (res) => {
          if (!res.ok) throw new Error(`HTTP ${res.status}`);
          const body = await res.json();
          if (live.has(session)) session.deliver(`${message.event}.done`, body, message.target);
        })
        .catch((error) => {
          if (live.has(session)) session.deliver("error.communication", { message: String(error) }, message.target);
        });
    },
  };
}

Failures that happen after send() returns can't raise the chart's internal error.communication any more, so this processor delivers an external event with that name instead. The live set stops it from delivering to sessions that have ended.

In the explorer#

Pass the same processors to <scxml-explorer> (attach({ session, processors })) to see each one as a service at the System level, with the messages flowing to and from it. The service's slot is named after its first alias (service:api).