feat(sdk): add MeshRegistry + multi-client React providers

Phase B prep for web migration. Web holds multiple simultaneous device
connections keyed by ConnectionId, so per-slice hook migrations need a
registry-aware provider.

packages/sdk
- MeshRegistry: Map<ConnectionId, MeshClient> with signals for list/active/activeId.
  create()/get()/has()/remove()/setActive().
- First-created client auto-activates.
- remove() disconnects the client and rotates active to another entry.
- 4 vitest cases covering create, auto-activate, duplicate rejection, and remove.

packages/sdk-react
- MeshRegistryProvider + MeshRegistryContext.
- useMeshRegistry, useOptionalMeshRegistry, useActiveClient, useClientById(id).
- useClient now falls back to the registry's active client when no direct
  <MeshProvider> is present, so existing hooks work unchanged under a
  registry-backed app.

No web-facing changes in this commit; used by follow-up slice migrations.
This commit is contained in:
Dan Ditomaso
2026-04-23 22:14:32 -04:00
parent 763df030e3
commit e2874ba16a
11 changed files with 232 additions and 2 deletions

View File

@@ -1,8 +1,14 @@
export { MeshProvider } from "./src/provider/MeshProvider.tsx";
export type { MeshProviderProps } from "./src/provider/MeshProvider.tsx";
export { MeshContext } from "./src/provider/MeshContext.ts";
export { MeshRegistryProvider } from "./src/provider/MeshRegistryProvider.tsx";
export type { MeshRegistryProviderProps } from "./src/provider/MeshRegistryProvider.tsx";
export { MeshRegistryContext } from "./src/provider/MeshRegistryContext.ts";
export { useClient } from "./src/adapters/useClient.ts";
export { useClientById } from "./src/adapters/useClientById.ts";
export { useActiveClient } from "./src/adapters/useActiveClient.ts";
export { useMeshRegistry, useOptionalMeshRegistry } from "./src/adapters/useMeshRegistry.ts";
export { useSignal } from "./src/adapters/useSignal.ts";
export { useSignalValue } from "./src/adapters/useSignalValue.ts";

View File

@@ -0,0 +1,8 @@
import type { MeshClient } from "@meshtastic/sdk";
import { useMeshRegistry } from "./useMeshRegistry.ts";
import { useSignal } from "./useSignal.ts";
export function useActiveClient(): MeshClient | undefined {
const registry = useMeshRegistry();
return useSignal(registry.active);
}

View File

@@ -1,12 +1,26 @@
import type { MeshClient } from "@meshtastic/sdk";
import { useContext } from "react";
import { MeshContext } from "../provider/MeshContext.ts";
import { MeshRegistryContext } from "../provider/MeshRegistryContext.ts";
import { useSignal } from "./useSignal.ts";
/**
* Returns the `MeshClient` for the current tree. Resolves in this order:
* 1. The nearest `<MeshProvider client={...}>`.
* 2. The active client of the nearest `<MeshRegistryProvider registry={...}>`.
*
* Throws if neither is present or the registry has no active client.
*/
export function useClient(): MeshClient {
const client = useContext(MeshContext);
const direct = useContext(MeshContext);
const registry = useContext(MeshRegistryContext);
const active = useSignal(
registry?.active ?? { value: undefined, peek: () => undefined, subscribe: () => () => {} },
);
const client = direct ?? active;
if (!client) {
throw new Error(
"useClient must be called inside a <MeshProvider>. Did you forget to wrap your component tree?",
"useClient must be called inside a <MeshProvider> or a <MeshRegistryProvider> with an active client.",
);
}
return client;

View File

@@ -0,0 +1,11 @@
import type { ConnectionId, MeshClient } from "@meshtastic/sdk";
import { useMeshRegistry } from "./useMeshRegistry.ts";
export function useClientById(id: ConnectionId): MeshClient {
const registry = useMeshRegistry();
const client = registry.get(id);
if (!client) {
throw new Error(`No MeshClient registered for id ${id}`);
}
return client;
}

View File

@@ -0,0 +1,15 @@
import type { MeshRegistry } from "@meshtastic/sdk";
import { useContext } from "react";
import { MeshRegistryContext } from "../provider/MeshRegistryContext.ts";
export function useMeshRegistry(): MeshRegistry {
const registry = useContext(MeshRegistryContext);
if (!registry) {
throw new Error("useMeshRegistry must be called inside a <MeshRegistryProvider>.");
}
return registry;
}
export function useOptionalMeshRegistry(): MeshRegistry | undefined {
return useContext(MeshRegistryContext);
}

View File

@@ -0,0 +1,4 @@
import type { MeshRegistry } from "@meshtastic/sdk";
import { createContext } from "react";
export const MeshRegistryContext = createContext<MeshRegistry | undefined>(undefined);

View File

@@ -0,0 +1,17 @@
import type { MeshRegistry } from "@meshtastic/sdk";
import type { ReactNode } from "react";
import { MeshRegistryContext } from "./MeshRegistryContext.ts";
export interface MeshRegistryProviderProps {
registry: MeshRegistry;
children: ReactNode;
}
/**
* Makes a MeshRegistry available to descendant hooks. Use when the app holds
* more than one connected device at a time. For single-client apps, prefer
* `<MeshProvider client={...}>`.
*/
export function MeshRegistryProvider({ registry, children }: MeshRegistryProviderProps) {
return <MeshRegistryContext.Provider value={registry}>{children}</MeshRegistryContext.Provider>;
}

View File

@@ -1,6 +1,8 @@
// Main entry
export { MeshClient } from "./src/core/client/MeshClient.ts";
export type { MeshClientOptions } from "./src/core/client/MeshClient.ts";
export { MeshRegistry } from "./src/core/registry/MeshRegistry.ts";
export type { ConnectionId, RegistryEntry } from "./src/core/registry/MeshRegistry.ts";
// Constants & errors
export { Constants } from "./src/core/constants/index.ts";

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { createFakeTransport } from "../testing/createFakeTransport.ts";
import { MeshRegistry } from "./MeshRegistry.ts";
describe("MeshRegistry", () => {
it("creates clients keyed by id and emits a snapshot", () => {
const reg = new MeshRegistry();
const seen: number[] = [];
reg.list.subscribe((entries) => seen.push(entries.length));
const { transport: t1 } = createFakeTransport();
const { transport: t2 } = createFakeTransport();
reg.create(1, { transport: t1 });
reg.create(2, { transport: t2 });
expect(reg.size).toBe(2);
expect(reg.get(1)).toBeDefined();
expect(reg.get(42)).toBeUndefined();
expect(seen).toEqual([1, 2]);
});
it("auto-activates the first client and allows switching", () => {
const reg = new MeshRegistry();
expect(reg.activeId.value).toBeNull();
const { transport: t1 } = createFakeTransport();
const { transport: t2 } = createFakeTransport();
reg.create(1, { transport: t1 });
expect(reg.activeId.value).toBe(1);
reg.create(2, { transport: t2 });
expect(reg.activeId.value).toBe(1);
reg.setActive(2);
expect(reg.activeId.value).toBe(2);
expect(reg.active.value).toBe(reg.get(2));
});
it("rejects duplicate ids", () => {
const reg = new MeshRegistry();
const { transport } = createFakeTransport();
reg.create(1, { transport });
expect(() => reg.create(1, { transport: createFakeTransport().transport })).toThrow();
});
it("remove disconnects the client and falls back to another active id", async () => {
const reg = new MeshRegistry();
reg.create(1, { transport: createFakeTransport().transport });
reg.create(2, { transport: createFakeTransport().transport });
reg.setActive(1);
await reg.remove(1);
expect(reg.size).toBe(1);
expect(reg.activeId.value).toBe(2);
});
});

View File

@@ -0,0 +1,95 @@
import { type Signal, signal } from "@preact/signals-core";
import { MeshClient, type MeshClientOptions } from "../client/MeshClient.ts";
import { type ReadonlySignal, toReadonly } from "../signals/createStore.ts";
export type ConnectionId = number;
export interface RegistryEntry {
readonly id: ConnectionId;
readonly client: MeshClient;
}
/**
* Manages multiple `MeshClient` instances keyed by connection id.
*
* Use this when an application holds more than one device connection at a
* time (e.g. multi-radio UIs). Single-device consumers can ignore this and
* instantiate `MeshClient` directly.
*/
export class MeshRegistry {
private readonly clients = new Map<ConnectionId, MeshClient>();
private readonly backing: Signal<ReadonlyArray<RegistryEntry>>;
private readonly activeSignal: Signal<MeshClient | undefined>;
private readonly activeIdSignal: Signal<ConnectionId | null>;
public readonly list: ReadonlySignal<ReadonlyArray<RegistryEntry>>;
public readonly active: ReadonlySignal<MeshClient | undefined>;
public readonly activeId: ReadonlySignal<ConnectionId | null>;
constructor() {
this.backing = signal<ReadonlyArray<RegistryEntry>>([]);
this.activeSignal = signal<MeshClient | undefined>(undefined);
this.activeIdSignal = signal<ConnectionId | null>(null);
this.list = toReadonly(this.backing);
this.active = toReadonly(this.activeSignal);
this.activeId = toReadonly(this.activeIdSignal);
}
public create(id: ConnectionId, options: MeshClientOptions): MeshClient {
if (this.clients.has(id)) {
throw new Error(`MeshRegistry already has a client for id ${id}`);
}
const client = new MeshClient(options);
this.clients.set(id, client);
this.snapshot();
if (this.activeIdSignal.value === null) {
this.setActive(id);
}
return client;
}
public get(id: ConnectionId): MeshClient | undefined {
return this.clients.get(id);
}
public has(id: ConnectionId): boolean {
return this.clients.has(id);
}
public setActive(id: ConnectionId | null): void {
if (id === null) {
this.activeIdSignal.value = null;
this.activeSignal.value = undefined;
return;
}
const client = this.clients.get(id);
if (!client) {
throw new Error(`MeshRegistry has no client for id ${id}`);
}
this.activeIdSignal.value = id;
this.activeSignal.value = client;
}
public async remove(id: ConnectionId): Promise<void> {
const client = this.clients.get(id);
if (!client) return;
await client.disconnect().catch(() => {});
this.clients.delete(id);
if (this.activeIdSignal.value === id) {
const next = this.clients.keys().next();
this.setActive(next.done ? null : next.value);
}
this.snapshot();
}
public get size(): number {
return this.clients.size;
}
private snapshot(): void {
this.backing.value = Array.from(this.clients.entries()).map(([id, client]) => ({
id,
client,
}));
}
}

View File

@@ -0,0 +1,2 @@
export { MeshRegistry } from "./MeshRegistry.ts";
export type { ConnectionId, RegistryEntry } from "./MeshRegistry.ts";