@sienci/gsender-plugin-sdk
v0.2.2
Published
SDK for gSender UI plugins — bridge client, React hooks, and G-code viewer
Readme
@sienci/gsender-plugin-sdk
SDK for building gSender UI plugins. Talk to the host app over the plugin bridge, subscribe to live state, and optionally embed the same G-code viewer gSender uses.
Install
npm install @sienci/gsender-plugin-sdkPeer dependencies are optional and only needed for the entry points that use them:
| Entry | Peers |
|-------|--------|
| @sienci/gsender-plugin-sdk | none |
| @sienci/gsender-plugin-sdk/react | react ≥ 18 |
| @sienci/gsender-plugin-sdk/viewer | @sienci/gviewer, three |
# React hooks
npm install react
# G-code preview
npm install @sienci/gviewer threeEntries
Build plugin (/vite)
Add the SDK's Vite plugin to your build — it is the only build config a plugin needs:
// vite.config.ts
import gsenderPlugin from "@sienci/gsender-plugin-sdk/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react(), gsenderPlugin()],
base: "./",
build: { outDir: "ui", emptyOutDir: true },
});It handles everything the gSender host needs, automatically:
- Keeps the SDK's specifiers external in your bundle, so gSender can
statically scan which SDK functions you import and show the user an
accurate permission prompt. At runtime they resolve (via an injected
import map) to the SDK copy gSender itself serves at
/plugin-sdk/*.js— the executing SDK always matches the host's bridge. - Keeps
react/react-dom/JSX runtimes external too, and vendors one shared React build intoui/vendor/(single React instance — hooks in your components and in the SDK share the same dispatcher). - Injects the import map into your built
index.html.
Note the built ui/ only runs inside gSender's plugin iframe (vite preview will
not resolve the SDK imports).
Bridge client (default)
Framework-agnostic RPC + subscriptions. Safe for vanilla JS, Vue, Svelte, etc. — does not import React.
Individual clients:
machineworkspacereduxgcodestorage
gsender includes machine, workspace, redux, and gcode in one object. storage is not included in gsender — it must be imported on its own (see below).
import {
gsender,
getWorkspaceState,
subscribeWorkspaceState,
subscribeSelector,
} from "@sienci/gsender-plugin-sdk";
const ctx = await gsender.machine.getContext();
await gsender.gcode.loadToVisualizer(gcode, "job.nc");
const unsub = subscribeWorkspaceState((state) => {
console.log(state);
});Plugin storage
Each plugin gets its own persisted key/value slice on the host, namespaced to
that plugin's manifest id — no plugin can read or write another plugin's
data. Requires the storage permission (see the manifest capabilities
docs).
storage is deliberately not part of the gsender combined client: the
host derives permission grants from a static scan of which SDK names a plugin
imports, so storage needs its own explicit import to get its own,
separately-approved permission rather than riding along with gsender.
import { storage } from "@sienci/gsender-plugin-sdk";
await storage.set("apiKey", "abc123");
const apiKey = await storage.get("apiKey"); // "abc123"
const units = await storage.get("units", "mm"); // default if unset
const all = await storage.getAll(); // whole namespaced object
await storage.setAll({ apiKey: "abc123", units: "mm" }); // replace it wholesale
await storage.delete("apiKey");
await storage.clear(); // wipe this plugin's storage entirelyFirmware response parsers
Define your own parsers for firmware responses and receive the results as events. Matching runs server-side against every raw line the controller reads — including lines that never reach the console — and only your matches cross the bridge.
Declare parsers in your manifest and they are live from the moment the port opens, whether or not your UI is mounted:
"parsers": [
{
"id": "probe",
"mode": "line",
"match": { "source": "^\\[PRB:(?<x>[-\\d.]+),(?<y>[-\\d.]+),(?<z>[-\\d.]+)(?<rest>(?:,[-\\d.]+)*):(?<ok>[01])\\]$" }
}
]import { machine } from "@sienci/gsender-plugin-sdk";
machine.onParsed("probe", (result) => {
console.log(result.groups); // { x, y, z, ok }
});
// Send a command and get its whole response back
const info = await machine.query("$I");
console.log(info.lines);Two permissions, deliberately separate from machine:read:
machine:parse— read the raw firmware stream through a regex you supply.machine:readonly ever returns a curated context object, so this is a categorically wider grant and is prompted for separately.machine:query— send a command and capture its response. Separate again, because it writes.
Note that gsender.machine.* carries these methods too, so importing the
combined gsender client requests machine:parse and machine:query along
with everything else. Import machine on its own if you want a narrower
prompt.
Block mode (multi-line responses like $$), lifetimes, limits, and the full
spec reference are in the plugin parsers
guide. plugins/parser-demo is a working
example of every path.
Controller events
Listen for a controller event by name.
import { machine } from "@sienci/gsender-plugin-sdk";
const unsubscribe = machine.addListener("job:start", () => {
console.log("a job started");
});
machine.addListener("workflow:state", (state) => {
console.log("workflow state:", state); // "idle" | "paused" | "running"
});
// later
unsubscribe();machine.addListener returns an unsubscribe function.
Unavailable events:
serialport:read/serialport:write: usemachine.onParsed/onLineinsteadplugin:parser:match/plugin:parser:error: usemachine.onParsed/onParserErrorinstead
See plugins/controller-events-demo for a working example.
React hooks
import { gsender } from "@sienci/gsender-plugin-sdk";
import {
useWorkspaceState,
useTypedSelector,
useParsed,
} from "@sienci/gsender-plugin-sdk/react";
const workspace = useWorkspaceState();
const isConnected = useTypedSelector((s) => s.connection?.isConnected);
// Latest result from one of your parsers. Populated on mount if it has
// already matched this session — see the parsers guide.
const probe = useParsed("probe");G-code viewer
Uses @sienci/gviewer (same engine as gSender’s visualizer). Requires a bundler.
React:
import {
GCodeVisualizer,
type GCodeViewerHandle,
} from "@sienci/gsender-plugin-sdk/viewer";
import { useEffect, useRef } from "react";
const ref = useRef<GCodeViewerHandle>(null);
useEffect(() => {
ref.current?.loadFromText(gcode).then(() => ref.current?.focusToModel());
}, [gcode]);
<GCodeVisualizer ref={ref} id="preview" style={{ height: 320 }} />;Imperative:
import { GCodeViewer } from "@sienci/gsender-plugin-sdk/viewer";
const viewer = new GCodeViewer({
id: "preview",
container: document.getElementById("preview"),
});
await viewer.loadFromText(gcode);
viewer.focusToModel();Bridge API surface
| API | Description |
|-----|-------------|
| machine.getContext() | Current machine / controller context |
| machine.command(cmd, ...args) | Run a host machine command. Resolves on delivery, not completion — most command handlers have no completion signal. Use machine.query() when you need the response. |
| machine.query(cmd, opts?) | Send a command and collect its response lines until a terminator |
| machine.registerParser(spec) | Register a firmware response parser for this view's lifetime |
| machine.unregisterParser(id) | Remove a runtime parser |
| machine.onParsed(id, cb) | Every match for a parser, losslessly; fires immediately with the last result if there is one |
| machine.onLine(pattern, cb) | One-off line tap (sugar over an anonymous runtime parser) |
| machine.setBusy(busy, label?) | Flag the machine as busy for a feeder-driven op (stable status, host auto-releases) |
| machine.addListener(eventName, cb) | Listen for a controller event by name; returns an unsubscribe function |
| getLastParsed(id) | Synchronous last result for a parser, or undefined |
| onParserError(cb) | Parsers that were rejected, rate limited, or quarantined |
| workspace.getState() | One-shot workspace snapshot |
| redux.getState() | One-shot full Redux state |
| gcode.loadToVisualizer(gcode, name?) | Load G-code into the main visualizer/job |
| storage.get(key, defaultValue?) | Read a value from this plugin's own namespaced storage |
| storage.set(key, value) | Write a value to this plugin's own namespaced storage |
| storage.delete(key) | Delete a key from this plugin's own namespaced storage |
| storage.getAll(defaultValue?) | Read this plugin's entire namespaced storage object |
| storage.setAll(value) | Replace this plugin's entire namespaced storage object |
| storage.clear() | Clear this plugin's entire namespaced storage object |
| subscribeWorkspaceState(cb) | Live workspace updates |
| subscribeSelector(selector, cb, equalityFn?) | Live Redux slice |
| useWorkspaceState() | React hook for workspace |
| useTypedSelector(selector, equalityFn?) | React hook for Redux slice |
| useParsed(id) | React hook for a parser's latest result |
Plugins run in an iframe; the SDK posts messages on the gsender:plugin-bridge channel to the parent window. gSender mirrors dark mode onto the iframe as html.dark — style plugins with class-based dark mode, not prefers-color-scheme.
License
MIT
