Skip to content

Embedding core

A host holds an instance and does two things with it: pushes values in, and watches what comes out. Everything on this page is how.

This picks up where Installation left off: a runtime carrying the host’s contract, and one instance on it.

Everything an instance knows, it publishes. Each is an Observable: get() for the current value, subscribe(listener) for changes, which returns its own unsubscribe.

ObservableHolds
outputs{ outputs, error, stale } - the values, a runtime error if the run failed, and whether they came from a program that is no longer current
diagnosticsevery error and warning, from composing, parsing and analysing
valuesevery program-level input’s current value, whoever fed it
portsthe layers as composed, and which layer placed each name
snapshoteverything a save would capture: the program, its ports, its values
const stop = instance.outputs.subscribe(({ outputs, error, stale }) => {
if (error) return report(error);
act(outputs?.get("alert"), { stale });
});

Subscribe to outputs to act, to diagnostics to show problems, to snapshot to save. A host pushing its own values leaves snapshot silent, so a save listener only fires when something a save would care about changed.

Every command returns nothing and reports through the observables. That is deliberate: it is the shape a command needs to cross a network, and it means a local instance and a remote one behave identically.

CommandDoes
setInput(name, value)set a program-level input. Throws on a global name - those are the runtime’s. Refuses a value that does not fit the input’s type
fireTrigger(name, value)set, evaluate, reset to the default, evaluate again. Refuses a value the same way
setProgram(saved)swap the program
setLayer(id, ports)replace one program-level layer’s declarations

And dispose(), which unregisters the program from the runtime.

Two kinds of input, two places to set them

Section titled “Two kinds of input, two places to set them”

This is the thing hosts most often get wrong, so it is worth stating flatly.

  • A global input belongs to the runtime. Set it with runtime.updateInputs({ ... }), and every program on the runtime sees the new value.
  • A program-level input belongs to one instance. Set it with instance.setInput(name, value).
runtime.updateInputs({ temperature: 30 }); // your state, every program
instance.setInput("limit", 35); // this program's own input

Calling instance.setInput with a global name throws, because a value with two owners would have no right answer. updateInputs takes several at once and evaluates each program a single time, so prefer it for anything that arrives together.

Both places check a value against the type its input declares, with the check a cast (as) runs: a list item by item, a struct field by field, a named type along its whole extends chain with every schema on it. null fits every type. A struct with a declared field missing does not fit, and an extra field is ignored.

What differs is how each one says no.

  • The runtime throws. runtime.updateInputs and a handle’s setInput are called by your code, so a value of the wrong type is a bug in it. A batch with one bad value changes nothing.
  • An instance refuses. instance.setInput and fireTrigger may be called by a pane, or by a client across a network, and neither can catch an exception. The input keeps its value, and diagnostics gains a value_does_not_fit error marked refused that names the input. It goes with the next value that fits, or with the next compile.

A value that outlives a change of declaration is checked again. When an input’s type changes and what it held no longer fits, the input starts over from the default of its new type.

A user editing a program is nearly always mid-keystroke, and so nearly always invalid. What an instance does about that is designed around a host that is acting on its outputs.

instance.setProgram(serialiseSource("output alert = $temperature >"));
instance.diagnostics.get(); // the parse error
instance.outputs.get().stale; // true - the last good values are still there

The broken program is reported and not run. The previous program keeps running, and its outputs stay available with stale: true. The moment a program compiles again, the flag clears. Decide what stale means for your application (keep acting, pause, grey out), but you will not be handed blanks.

An error the analyser found in a binding no output reads does not stop anything at all: it is reported, the binding is pruned, and the outputs run. Only a lost required output fails a program outright.

An instance is the top of four levels. Most hosts only ever touch the top one, but each exists for a reason, and the lower ones are there when you need less.

LevelRemembersReach for it when
run(program, descriptor, inputs)nothingyou evaluate once, in a test or a script
createProgramRunner(program, descriptor)one program’s cacheone program, evaluated repeatedly, no other state
createRuntime()many programs, shared global inputsan application’s whole set of programs
createInstance(runtime, …)one program on a runtime, its own layers and observablesa program somebody edits, saves, or watches

The lower two take a compiled program directly:

import { createProgramRunner, type PortLayer, run } from "@dendrite-lang/core";
const layer: PortLayer = {
id: "sample",
policy: Policy.user,
ports: { inputs: [{ name: "n", type: Type.number, default: 0 }], outputs: [] },
};
const composed = env.forProgram([], [layer]);
if (!composed.ok) throw new Error("those layers do not compose");
const parsed = composed.environment.parse("output doubled = $n * 2");
if (!parsed.ok) throw new Error("that program does not parse");
const { program } = composed.environment.analyse(parsed.program);
const { descriptor } = composed.environment;
run(program, descriptor, { n: 4 }).get("doubled"); // 8
const runner = createProgramRunner(program, descriptor);
runner.run({ n: 5 }).get("doubled"); // 10, and only what `n` reaches recomputes next time

They are deliberately separate rather than one object with options. Each is the smallest thing that does its job, and a host that needs a runner does not carry a runtime’s state.