Worker Messages from the Configuration UI

A FlowBox configuration microfrontend calls into the worker through the worker-messages module exported by @industream/flowmaker-flowbox-ui. From the frontend side the contract is small: register the module once in main.js, then call workerMessages.request(...) from your Svelte component and await the worker's reply.

This page shows the registration idiom actually used in shipped bundles, the workerMessages singleton, status tracking, and a complete Svelte 5 runes config form, using the real cs-data-logger frontend as the worked example. On the worker side, handlers are registered per box version with the SDK's worker bridge (see the C# worker messages guide).

Registering worker messages

The toolkit exports a ready-made workerMessages singleton. Real bundles import it and register it directly in the bundle entry. This is what cs-data-logger does:

import ConfigForm from './ConfigForm.svelte';
import { FlowBoxSvelteUI, workerMessages } from '@industream/flowmaker-flowbox-ui';

register('box-config-frontend', new FlowBoxSvelteUI(ConfigForm));
register('worker-messages', workerMessages);

register is a global provided by the harness; you do not import it. The first call mounts the config form, the second opts the bundle into worker RPC.

[NOTE!info/REGISTRATION OPTS THE BUNDLE IN] register('worker-messages', ...) is what tells the host to wire the Socket.IO channel for this bundle. Without it the host never opens the worker connection, and workerMessages.request(...) has no transport behind it. A bundle that does not need worker communication simply omits the line.

You can also instantiate your own new WorkerMessages() and register that instance instead. Prefer the exported workerMessages singleton: it keeps the entry and every component referring to the same object, so you import the same symbol everywhere and never risk registering one instance while calling another.

import { workerMessages } from '@industream/flowmaker-flowbox-ui';

Making a request

Call request with a requestId, an application event, and a JSON-serializable payload. It returns a Promise that resolves with the worker's return value, or rejects on error, timeout, or disconnect.

const result = await workerMessages.request({
    requestId: crypto.randomUUID(),
    event: 'data-logger:double',
    payload: { value: doubleValue },
});

The contract:

  • requestId is required and must be unique per in-flight request. Always pass crypto.randomUUID(). The host uses it to correlate the worker's response back to this Promise.
  • event is an application-defined string, namespaced by the box (e.g. data-logger:double). It must match the event the worker registers on its handler. Unknown events reject the Promise.
  • payload must be JSON-serializable. Structured clone it if you are unsure.
  • The boxVersionId is not part of the request. The host selects it automatically: it matches the exact box version being configured and picks a live worker for it.
  • The reserved event :health pings the worker without any application handler.

The Promise settles when the host calls writeResponse() with the matching requestId: ok: true resolves with payload, ok: false rejects with an Error carrying the worker's error message. Handle rejections with normal try/catch.

Tracking connection status

workerMessages.status is a plain object { status, error }. status starts as disconnected; the host updates it as the connection changes (e.g. connected, disconnected, error), and error carries a message when something went wrong.

let workerStatus = $state(workerMessages.status);

Subscribe to changes with onStatus. It calls the listener immediately with the current status, then on every change, and returns an unsubscribe function. Call that function in onDestroy.

let removeWorkerStatusListener;
onMount(() => {
    removeWorkerStatusListener = workerMessages.onStatus((status) => {
        workerStatus = status;
    });
});
onDestroy(() => {
    removeWorkerStatusListener?.();
});

Use the status to gate or annotate worker actions: disable buttons until status === 'connected', or show "Worker disconnected." instead of a stale result.

Worker messages vs. config values

Worker messages and config values are two separate channels. Do not conflate them.

  • $values (the normal FlowBoxSvelteUI values flow) is the declarative configuration persisted with the node. In cs-data-logger the fields useWidget, displayField, displayString, and displayUpdateMs, plus icon, are edited through values. They are saved when the node is saved.
  • workerMessages.request(...) is imperative RPC. Use it to talk to a live worker right now: test a connection, fetch a sample, validate credentials, double a number. Nothing it returns is persisted.

[WARNING!shield/RPC IS NOT PERSISTENCE] Worker messages never write to the node configuration. A worker response is a transient result for the current UI session. If a value must survive saving the node, it belongs in $values, not in a worker request. Conversely, do not poll the worker to read config the UI already holds in values.

Full worked example

The real cs-data-logger ConfigForm.svelte, trimmed to the worker-relevant parts. Config fields bind through values; the "Worker Test" fieldset uses worker RPC and status.

<script>
    import { flowMakerBridge, getValue, workerMessages } from '@industream/flowmaker-flowbox-ui';
    import { onMount, onDestroy } from 'svelte';

    const props = $props();
    const { values } = flowMakerBridge(props);

    let doubleValue = $state(0);
    let workerResponse = $state('');
    let workerRequestPending = $state(false);
    let workerStatus = $state(workerMessages.status);

    const doubleNumber = async () => {
        workerRequestPending = true;
        workerResponse = '';
        try {
            workerResponse = await workerMessages.request({
                requestId: crypto.randomUUID(),
                event: 'data-logger:double',
                payload: { value: doubleValue },
            });
        } catch (error) {
            workerResponse = `Request failed: ${error.message}`;
        } finally {
            workerRequestPending = false;
        }
    };

    let removeWorkerStatusListener;
    onMount(() => {
        removeWorkerStatusListener = workerMessages.onStatus((status) => {
            workerStatus = status;
        });
    });
    onDestroy(() => {
        removeWorkerStatusListener?.();
    });
</script>

<fieldset>
    <legend>Worker Test</legend>

    <label>
        Value
        <input type="number" bind:value={doubleValue} />
    </label>

    <button type="button" onclick={doubleNumber} disabled={workerRequestPending}>
        {workerRequestPending ? 'Requesting...' : 'Double number'}
    </button>

    <span>
        {workerResponse || workerStatus.error || (workerStatus.status === 'connected' ? '' : `Worker ${workerStatus.status}.`)}
    </span>
</fieldset>

The precedence in the status line is deliberate: show the last worker response if there is one, otherwise a connection error, otherwise a neutral "Worker <status>." hint when not connected. The button is disabled while a request is in flight so concurrent taps cannot stack requests.

The corresponding worker handler (worker side, not part of this bundle) registers the same data-logger:double event for the box version and returns the doubled value; see the C# worker messages guide.

Common Pitfalls

Forgetting to register worker-messages. Importing workerMessages in a component is not enough; the bundle entry must register it or the host never wires the transport.

// WRONG
register('box-config-frontend', new FlowBoxSvelteUI(ConfigForm));
// workerMessages.request(...) has no channel behind it

// CORRECT
register('box-config-frontend', new FlowBoxSvelteUI(ConfigForm));
register('worker-messages', workerMessages);

Omitting requestId. The host correlates responses by requestId; without a unique one the Promise cannot be matched and may hang or resolve against the wrong call.

// WRONG
await workerMessages.request({ event: 'data-logger:double', payload: { value } });

// CORRECT
await workerMessages.request({ requestId: crypto.randomUUID(), event: 'data-logger:double', payload: { value } });

Not cleaning up the onStatus listener. onStatus returns an unsubscribe function. Leaving it registered after the component is destroyed leaks the listener and writes into dead state.

// WRONG
onMount(() => workerMessages.onStatus((status) => { workerStatus = status; }));

// CORRECT
let remove;
onMount(() => { remove = workerMessages.onStatus((status) => { workerStatus = status; }); });
onDestroy(() => remove?.());

Using worker RPC to read or write config values. Config belongs in $values and is persisted with the node; worker messages are transient imperative calls.

// WRONG: persisting a setting through a worker call
await workerMessages.request({ requestId: crypto.randomUUID(), event: 'save-display-field', payload: { displayField } });

// CORRECT: edit config through values, persist via the normal save cycle
values.displayField = newField;

Importing socket.io-client directly. The host owns the transport, authentication, namespace, and lifecycle. Never open your own socket to the worker endpoint.

// WRONG
import { io } from 'socket.io-client';
const socket = io(workerEndpoint);

// CORRECT
import { workerMessages } from '@industream/flowmaker-flowbox-ui';
await workerMessages.request({ requestId: crypto.randomUUID(), event: ':health', payload: {} });

Reference

  • C# worker messages guide (worker-side handlers)
  • sdk/config/flowbox-ui/src/worker-messages.ts
  • sdk/config/flowbox-ui/src/index.ts