Reflection
Every spawned process exposes a
$reflectionhandle — callable methods that let you inspect and drive a live actor from outside its message loop.
Defining methods
Add methods with the $reflectionMethods config field. They appear on the
process handle as proc.$reflection:
import { defineActor } from "posipaki";
const counter = defineActor({
$reflectionMethods: {
async getCount() {
return this.state.count;
},
async reset() {
this.state.count = 0;
},
},
setup: () => ({ count: 42 }),
handlers: {},
});
const proc = await counter.spawn(null);
await proc.ready();
await proc.$reflection.getCount(); // 42
await proc.$reflection.reset();
await proc.$reflection.getCount(); // 0
Every call is async
A reflection method is written as one that answers with a promise, and a call
always gives you one — locally it is the method's value wrapped, over a wire it
is the frame that has to arrive first. A caller reads the same call the same way
either way, so proc.$reflection.x() is awaited whether the actor is here or
somewhere else.
That is a type contract, not a convention: ReflectionMethod answers with a
promise and ReflectionOptions holds every declared method to it, so a method
written as () => number does not compile. Write it async.
this is the actor context
Inside a method, this is the full ActorContext — this.state, this.name,
this.id, this.$child, this.emit, this.exit, plus any decoration a plugin
added.
$reflectionMethods: {
async describe() {
return { name: this.name, children: Object.keys(this.$child) };
},
},
Typing
Your own $reflectionMethods are typed automatically from the config.
proc.$reflection is typed as your methods and every method a
plugin adds. Plugin-provided methods are typed via declaration merging on
ActorReflection; see the Plugins page for that pattern.
declare module "posipaki" {
interface ActorReflection {
"counters.get": () => Promise<number>;
}
}
Plugins add methods too
mergeConfigs deep-merges $reflectionMethods, so several plugins can each
register their own namespaced methods. See Plugins.
import { mergeConfigs, type ActorPlugin } from "posipaki";
const counters: ActorPlugin = (config) =>
mergeConfigs(config, {
$reflectionMethods: {
"counters.get": async function () {
return this.state.count;
},
},
});
Reaching children
Every process handle has $reflection — including children. The handle
returned by fork is typed, so a child's methods are directly callable:
async setup() {
const worker = await this.fork(workerActor);
return { worker };
},
// ...
await this.state.worker.$reflection.getCount();
this.$child and this.ctx.children hold AnyProcess, whose $reflection is
typed as {} — cast to reach a specific method:
const child = this.$child["main:worker"];
await (child.$reflection as { getCount: () => Promise<number> }).getCount();
The Introspection plugin is the bundled consumer — it walks
$reflection on children to build a live process tree.
Over a wire
A remoteClient proxy answers to the same $reflection, with two differences
worth knowing.
What can be called is what the far side announced. When the proxy starts, the far side sends the list of methods it can answer; the proxy installs one function per name. A method the far side does not have is not a function on the proxy at all, rather than a call that fails later. And because the answer travels, every call there takes at least a round trip.
const tools = remoteClient<ToolArgs, ToolState, ToolIn, ToolOut>("tools", spawner);
const env = await root.fork(tools, { env: "agent" });
await env.$reflection["inspect.getTree"](); // the far side's own tree, one frame away
A process that comes back over a wire is a handle, not the process. A method may
return a process — inspect.find does. Locally you get the process itself; over a
wire you get a RemoteProcess: the name the far side knows it by, the id this
connection uses for it, and the ways to talk to it.
const found = await env.$reflection["inspect.find"]("env:worker");
found.pname; // "env:worker"
found.ref.id; // this connection's id for it
found.send({ type: "PING" }); // a message for it
found.$reflection["inspect.getTree"](); // what it announced it can answer
A reference handed the other way — as an argument to another call — travels as the same id, and comes back to the side that holds the process as the process itself. See Remote actors for what a handle is and is not, and for how much of it you hear — nothing crosses until you ask.