Counter panel
Example apps / counter-panel / v0.2.0
Node modneeds counter-service
Calls Counter service through REDnode's mod services, hears its events, and tries a timeout, Stop, an error and an unknown function.
Here the service runs in the same preview; in the game it is a package of its own, and the host carries each call.
// Counter panel: another package's service, called through the REDnode host.
// service.connect finds Counter service by its id; each of its exported functions is
// then a method here that returns a promise. Its "changed" events arrive with $on.
import { service, ui } from 'rednode';
const count = ui.text('-', { tone: 'accent' });
const status = ui.text('Connecting to Counter service...', { tone: 'dim' });
const log = ui.list({ keep: 100 });
function say(text, tone) {
log.push(`${new Date().toLocaleTimeString()} ${text}`);
console.log(text);
status.set({ text, tone: tone ?? 'dim' });
}
let counter = null;
let stopper = null; // the AbortController of a running Wait
// Runs a call and shows its answer or its error.
async function run(label, fn) {
if (!counter) return say('Not connected yet: switch Counter service on', 'danger');
const started = performance.now();
try {
const r = await fn();
say(`${label}: ${JSON.stringify(r)} (${(performance.now() - started).toFixed(0)} ms)`);
return r;
} catch (err) {
say(`${label} failed: ${err.message}`, 'danger');
}
}
async function connect() {
try {
counter = await service.connect('counter-service');
say(`Connected to ${counter.$info.name} ${counter.$info.version}: ${counter.$info.functions.join(', ')}`);
counter.$on('changed', (e) => {
count.set({ text: String(e.count) });
log.push(`${new Date().toLocaleTimeString()} event changed: ${e.count} (by ${e.by}, from ${e.from})`);
});
count.set({ text: String(await counter.get()) });
} catch (err) {
counter = null;
say(`Could not connect: ${err.message}`, 'danger');
}
}
// Each function's description, parameters and marks, from the service's JSDoc.
async function describe() {
try {
const d = await service.describe('counter-service');
for (const f of Object.values(d.functions)) {
const params = f.params.map((p) => `${p.name}${p.optional ? '?' : ''}: ${p.type}`).join(', ');
const marks = [f.tool && (f.readonly ? 'tool, reads' : 'tool'), !f.checked && 'unchecked'].filter(Boolean).join(', ');
log.push(`${f.name}(${params})${f.returns ? ` -> ${f.returns.type}` : ''}${marks ? ` [${marks}]` : ''} ${f.description}`);
}
for (const e of Object.values(d.events)) log.push(`event ${e.name}: ${e.description}`);
say(`Described ${Object.keys(d.functions).length} functions`);
} catch (err) {
say(`Describe failed: ${err.message}`, 'danger');
}
}
ui.window({
title: 'Counter panel',
anchor: 'left',
x: 40,
y: 80,
width: 460,
height: 620,
closable: true,
children: [
ui.row(ui.text('Count', { tone: 'dim' }), count),
ui.row(
ui.button('+1', () => run('add(1)', () => counter.add(1))),
ui.button('+10', () => run('add(10)', () => counter.add(10)), { variant: 'ghost' }),
ui.button('Reset', () => run('reset()', () => counter.reset()), { variant: 'ghost' }),
ui.button('Read', () => run('get()', () => counter.get()), { variant: 'ghost' }),
),
ui.heading('Edge cases'),
ui.row(
ui.button('Wait 3 s', () => run('wait(3000)', () => counter.wait(3000)), { variant: 'ghost' }),
ui.button('Wait 40 s', () => run('wait(40000), default 30 s timeout', () => counter.wait(40000)), { variant: 'ghost' }),
ui.button('Wait, then Stop', () => {
stopper = new AbortController();
run('wait(20000) with a signal', () => counter.$with({ signal: stopper.signal }).wait(20000));
}, { variant: 'ghost' }),
ui.button('Stop', () => stopper?.abort(), { variant: 'ghost' }),
),
ui.row(
ui.button('Fail', () => run('fail()', () => counter.fail()), { variant: 'ghost' }),
ui.button('Unknown function', () => run('nothing()', () => counter.nothing()), { variant: 'ghost' }),
ui.button('Reconnect', connect, { variant: 'ghost' }),
),
ui.heading('Descriptions'),
ui.row(
// REDnode 0.35.0 and later: the service's JSDoc, as the host read it.
ui.button('Describe', describe, { variant: 'ghost' }),
// Refused by the host before the service sees it: add's JSDoc says "by" is a number.
ui.button('Bad call', () => run('add("ten")', () => counter.add('ten')), { variant: 'ghost' }),
),
status,
log,
],
});
console.log('Counter panel ready');
connect();
REDnode runs JavaScript. The rednode package also has TypeScript types, so an editor checks these calls; compile TypeScript to .mjs first.
{
"id": "counter-panel",
"name": "Counter panel",
"version": "0.2.0",
"author": "Edgemodders",
"description": "Calls Counter service through REDnode's mod services: reads and changes its count, hears its events, and tries a timeout, Stop, an error and an unknown function.",
"main": "mod.mjs",
"open": false,
"requires": {
"counter-service": ">=0.1"
}
}
The manifest names the package and says how REDnode runs it. Its fields are in Overlay apps.
How it works
service.connect("counter-service") finds the other package by its id and answers what it offers; each of its functions is then a method that returns a promise.
counter.$on("changed", ...) hears the service's events. counter.$with({ timeoutMs, signal }) gives calls a timeout or an AbortSignal.
Its rednode-app.json lists "counter-service": ">=0.1" in requires, so the Mod Manager shows it as missing before the panel runs.
What it uses
service.connect $on $with requires
Try this
- Press +1 and +10: the count and the log follow the service's events.
- Press Wait, then Stop, then Stop.
- Press Fail and Unknown function.
- In the game, Describe lists the service's JSDoc, and Bad call is refused by REDnode before the service sees it.
Use it
Copy the files into red4ext/plugins/REDnode/apps/counter-panel/ in the game folder, or start your own package from them: change id and name in the manifest first. The whole folder is on GitHub.