Skip to main content

Writing a Plugin

Walking through the seeded rolling-avg-rpm plugin end to end. The source lives at packages/plugin-sdk/examples/rolling-avg-rpm/src/index.ts.

Project layout

my-plugin/
├── plugin.json ← manifest (id, version, interfaces, …)
├── package.json ← npm metadata + scripts
├── tsconfig.json ← extends @race/plugin-sdk's tsconfig
├── src/
│ └── index.ts ← the plugin entry point
└── tests/
└── index.test.ts ← optional — runs as plain vitest

1. Declare the manifest

plugin.json:

{
"id": "your-team:my-plugin",
"version": "1.0.0",
"interfaces": ["math", "kpiProcessor"],
"capabilities": [],
"publisher": "Your Team",
"description": "What this plugin does."
}

2. Implement the math interface

src/index.ts:

import {
defineMath,
type PluginValue,
} from "@race/plugin-sdk";

export const math = defineMath({
listFunctions() {
return [
{
name: "rollingAvgRpm",
arity: 2,
pure: true,
description:
"Rolling average over a window. Args: samples (JSON list), windowSec.",
},
];
},

call(name: string, args: PluginValue[]): PluginValue {
if (name !== "rollingAvgRpm") {
return { kind: "error", message: `unknown function: ${name}` };
}
if (args.length !== 2) {
return {
kind: "error",
message: `expected 2 args, got ${args.length}`,
};
}

const [samplesArg, windowArg] = args;
if (windowArg?.kind !== "number") {
return { kind: "error", message: "windowSec must be a number" };
}
if (samplesArg?.kind !== "text") {
return {
kind: "error",
message: "samples must be a JSON-encoded list (text value)",
};
}

let parsed: { tSec: number; value: number }[];
try {
parsed = JSON.parse(samplesArg.value);
} catch {
return { kind: "error", message: "samples is not valid JSON" };
}

const result = rollingAverage(parsed, windowArg.value);
return { kind: "text", value: JSON.stringify(result) };
},
});

Why lists travel as JSON text

PluginValue doesn't have a list variant on purpose — keeping the marshalling boundary narrow makes the QuickJS ↔ V8 parity test pass. Pure helpers stringify lists into a text value.

3. Implement the KPI interface

import {
defineKpiProcessor,
type KpiResult,
type LapContext,
type Sample,
} from "@race/plugin-sdk";

export const kpiProcessor = defineKpiProcessor({
kpis() {
return [
{
name: "RPMRollingAvg5s",
description:
"Lap-mean of the 5-second rolling average of channel RPM.",
},
];
},

process(lapId: string, lap: LapContext): KpiResult[] {
const rpmSamples = lap.samples
.filter((s: Sample) => s.channel === "RPM")
.map((s) => ({ tSec: s.tSec, value: s.value }));

if (rpmSamples.length === 0) {
return [{
lapId,
kpiName: "RPMRollingAvg5s",
value: { kind: "error", message: "no RPM samples in lap" },
}];
}

const rolling = rollingAverage(rpmSamples, 5.0);
const lapMean = rolling.reduce((a, b) => a + b, 0) / rolling.length;
return [{
lapId,
kpiName: "RPMRollingAvg5s",
value: { kind: "number", value: lapMean },
}];
},
});

4. Default export (the host injection contract)

export default { math, kpiProcessor };

esbuild's --format=cjs rewrites this to the CommonJS module.exports = ... shape the host expects.

import { describe, it, expect } from "vitest";
import { rollingAverage } from "../src/index.js";

describe("rollingAverage", () => {
it("returns one value per input sample", () => {
const samples = [
{ tSec: 0, value: 4000 },
{ tSec: 1, value: 4500 },
{ tSec: 2, value: 5000 },
];
const result = rollingAverage(samples, 5);
expect(result).toEqual([4000, 4250, 4500]);
});
});

Run via pnpm --filter @race/plugin-sdk-rolling-avg-rpm test (or whatever your package name is). The SDK's parity test (packages/plugin-sdk/tests/parity.test.ts) ensures your bundle produces identical output under the QuickJS and V8 runtimes.

What's coming

  • A pnpm create @race/plugin scaffolding command
  • Hot-reload during local plugin authoring (tsc --watch + bundle + re-upload)
  • Plugin SDK docs site auto-generated from TypeDoc