An application-agnostic MeshCore companion-protocol library for Node.js, in TypeScript.
meshcore-ts speaks the MeshCore companion-radio wire protocol (the framing a phone/desktop app uses to talk to a MeshCore device over BLE or serial). It owns the protocol logic, frame parsing, the connect handshake, the DM/channel messaging state machines, and repeater administration — and keeps an in-memory model of contacts, channels, messages, and device state. It does zero persistence and ships with zero runtime dependencies (only node:buffer / node:crypto).
You bring a Transport (the bytes in/out of your radio); the library does everything above that line and emits typed events. You subscribe and persist however you like.
pnpm add @andyshinn/meshcore-ts
Note
This is a Node-only package since it requires node:buffer and node:crypto. It doesn't work in the browser.
This package was mostly written with the assistance of Claude. While I use this for my own personal projects it should be noted I am not a TypeScript expert. There may be quality issues with the package or you may have qualms about using LLM generated code. Consider this your "AI warning".
- Stateful session, injected ports. You construct a
MeshCoreSessionwith an injectedTransport(and optionalLogger). The session owns a typed event emitter and an in-memorySessionState; it never writes to disk. - You own persistence. Subscribe to events (
contacts,messages,owner, …) and store them however you want. On reconnect the session re-syncs from the radio. - Multi-instance safe. No module-level singletons — every session keeps its own state, so you can run several concurrently in one process.
The only thing you must implement. It moves raw companion frames to/from your radio. Each onData chunk must be exactly one complete companion frame (which is what a BLE GATT notification delivers).
interface Transport {
send(bytes: Uint8Array): Promise<void>; // write one companion frame
onData(cb: (chunk: Uint8Array) => void): void; // one complete frame per call
onStateChange(cb: (s: TransportState) => void): void;
getState(): TransportState; // 'idle' | 'scanning' | 'connecting' | 'connected' | 'error'
}The library does the companion-frame parsing (0x84/0x88 mesh vs. companion classification) internally — your transport only deals in raw frame bytes. BLE/serial drivers, scanning, and native deps stay your responsibility (they're intentionally not in this library).
A ready-made LoopbackTransport is exported for tests and examples (send captures to .sent, .receive(bytes) / .receiveHex(hex) deliver inbound frames, .setState(s) drives connection state).
interface Logger { trace; debug; info; warn; error: (...args: unknown[]) => void; }Defaults to a no-op. Pass your own (pino, console, etc.) to see protocol-level logging.
You don't inject these — the session creates them and exposes them:
session.events— a typed emitter. Subscribe withsession.events.on('contacts', cb).session.state— the in-memory model. Read withsession.state.getContacts(),getChannels(),getOwner(),getMessagesForKey(key), etc.
import { MeshCoreSession, LoopbackTransport } from '@andyshinn/meshcore-ts';
const transport = new LoopbackTransport(); // swap for your BLE/serial adapter
const session = new MeshCoreSession({ transport /*, logger, appName, appVersion */ });
// Subscribe BEFORE connecting so you don't miss the handshake.
session.events.on('owner', (owner) => console.log('this device:', owner?.name));
session.events.on('contacts', (contacts) => persistContacts(contacts));
session.events.on('channels', (channels) => persistChannels(channels));
session.events.on('messages', (key, messages) => persistMessages(key, messages));
session.events.on('messageState', (id, state) => updateBubble(id, state));
session.events.on('syncProgress', (p) => console.log(p.phase, p.contacts));
session.start();
// When your transport connects, the session runs the handshake automatically:
// DEVICE_QUERY → APP_START → GET_CONTACTS → channel enumeration → drain.
transport.setState('connected');
// Send a direct message (you supply the message id; track state via events):
await session.sendDmText('c:<pubkeyhex>', 'hello', 'msg-1');
// 'sending' → 'sent' (RESP_SENT) → 'ack' (PUSH_SEND_CONFIRMED), surfaced via 'messageState'.
// Send to a channel:
const { ok, channelHash, timestampUnix } = await session.sendChannelText('ch:General', 'hi all');
// To learn which repeaters relayed your send back over the air, do BOTH:
// 1. listen for 'messagePathHeard' — it carries { id, path }, and
// 2. register the send so heard 0x88 relays correlate to your message id.
// Registering alone surfaces nothing; the path arrives only via the event.
// Pass timestampUnix too: it is encrypted into the packet, so a heard relay can
// be tied to this exact send rather than guessed at by arrival order.
session.events.on('messagePathHeard', ({ id, path }) => {
console.log(`message ${id} was relayed via`, path);
});
if (ok && channelHash != null) {
session.registerChannelSend({ messageId: 'msg-2', channelHash, timestampUnix });
}class BleTransport implements Transport {
#dataCb?: (c: Uint8Array) => void;
#stateCb?: (s: TransportState) => void;
#state: TransportState = 'idle';
// your BLE library calls this once per GATT notification (= one frame):
#onNotification = (buf: Uint8Array) => this.#dataCb?.(buf);
async send(bytes: Uint8Array) { await this.#char.writeValue(bytes); }
onData(cb) { this.#dataCb = cb; }
onStateChange(cb) { this.#stateCb = cb; }
getState() { return this.#state; }
// call this.#setState('connected') from your connect flow, etc.
}transportState, rawPacket, channels, channelPresence, syncProgress, contacts, discovered, contactUpserted, contactRemoved, contactsSynced, contactEvicted, contactDiscovered, contactsFull, contactObserved, messages, messageUpserted, messageState, messagePathHeard, cliSendState, cliUnmatched, owner, radioSettings, repeaterStatus, repeaterTelemetry, pathLearned, deviceIdentity, autoAddConfig, telemetryPolicy, gpsConfig, deviceInfo, deviceCapabilities.
All payloads are exported types (see MeshCoreEventMap).
Messaging (sendChannelText, sendDmText, sendDmTextWithRetry), contacts & paths (getContactByKey, setContactPath, resetContactPath, addContactToRadio, removeContactFromRadio, setContactFavourite, setPathHashMode), channels (setChannel, pickFreeSlot, deriveSecret, …), radio/device settings (setRadioParams, setAdvertName, setAdvertLatLon, setOtherParams, setAutoAddConfig, setGpsConfig, reboot, …), time (getDeviceTime/setDeviceTime/syncDeviceTime), device admin & signing (exportPrivateKey, importPrivateKey, setDevicePin, factoryReset, signData), path diagnostics & raw frames (sendPathDiscoveryReq, getAdvertPath, sendRawData, …), and repeater administration (repeaterLogin, repeaterSendCli (with expectReply / timeoutMs / signal), repeaterRequestAcl, repeaterRequestNeighbours, repeaterRequestOwnerInfo, repeaterTracePath, repeaterGetLocalStats, sendStatusReq, sendTelemetryReq).
The session dispatches inbound frames through a FeatureRegistry of Feature modules (each owns the wire codes it reacts to and reads its dependencies from an injected FeatureContext). This is the library's extension model — see src/feature.ts.
Runnable examples live in examples/ — the meshcore.js example set
ported onto MeshCoreSession using the built-in serial, TCP, and BLE transports. Run
any of them with tsx (no build step):
pnpm example examples/get-contacts.ts /dev/cu.usbmodemXXXX
See examples/README.md for the full list. The
parse-packet / parse-advert examples run with no hardware.
pnpm build # tsdown → ESM + CJS + .d.ts
pnpm test # vitest
pnpm typecheck # tsc --noEmit
pnpm lint # biome
MIT