From e2874ba16a8bd166409fd3a345e160b03dd64d39 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Thu, 23 Apr 2026 22:14:32 -0400 Subject: [PATCH] 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 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 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. --- packages/sdk-react/mod.ts | 6 ++ .../sdk-react/src/adapters/useActiveClient.ts | 8 ++ packages/sdk-react/src/adapters/useClient.ts | 18 +++- .../sdk-react/src/adapters/useClientById.ts | 11 +++ .../sdk-react/src/adapters/useMeshRegistry.ts | 15 +++ .../src/provider/MeshRegistryContext.ts | 4 + .../src/provider/MeshRegistryProvider.tsx | 17 ++++ packages/sdk/mod.ts | 2 + .../src/core/registry/MeshRegistry.test.ts | 56 +++++++++++ .../sdk/src/core/registry/MeshRegistry.ts | 95 +++++++++++++++++++ packages/sdk/src/core/registry/index.ts | 2 + 11 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 packages/sdk-react/src/adapters/useActiveClient.ts create mode 100644 packages/sdk-react/src/adapters/useClientById.ts create mode 100644 packages/sdk-react/src/adapters/useMeshRegistry.ts create mode 100644 packages/sdk-react/src/provider/MeshRegistryContext.ts create mode 100644 packages/sdk-react/src/provider/MeshRegistryProvider.tsx create mode 100644 packages/sdk/src/core/registry/MeshRegistry.test.ts create mode 100644 packages/sdk/src/core/registry/MeshRegistry.ts create mode 100644 packages/sdk/src/core/registry/index.ts diff --git a/packages/sdk-react/mod.ts b/packages/sdk-react/mod.ts index 246f923c..38d57f30 100644 --- a/packages/sdk-react/mod.ts +++ b/packages/sdk-react/mod.ts @@ -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"; diff --git a/packages/sdk-react/src/adapters/useActiveClient.ts b/packages/sdk-react/src/adapters/useActiveClient.ts new file mode 100644 index 00000000..7f615d5d --- /dev/null +++ b/packages/sdk-react/src/adapters/useActiveClient.ts @@ -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); +} diff --git a/packages/sdk-react/src/adapters/useClient.ts b/packages/sdk-react/src/adapters/useClient.ts index 3b4da003..8208cd0d 100644 --- a/packages/sdk-react/src/adapters/useClient.ts +++ b/packages/sdk-react/src/adapters/useClient.ts @@ -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 ``. + * 2. The active client of the nearest ``. + * + * 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 . Did you forget to wrap your component tree?", + "useClient must be called inside a or a with an active client.", ); } return client; diff --git a/packages/sdk-react/src/adapters/useClientById.ts b/packages/sdk-react/src/adapters/useClientById.ts new file mode 100644 index 00000000..43cb8c7d --- /dev/null +++ b/packages/sdk-react/src/adapters/useClientById.ts @@ -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; +} diff --git a/packages/sdk-react/src/adapters/useMeshRegistry.ts b/packages/sdk-react/src/adapters/useMeshRegistry.ts new file mode 100644 index 00000000..5b64a1c7 --- /dev/null +++ b/packages/sdk-react/src/adapters/useMeshRegistry.ts @@ -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 ."); + } + return registry; +} + +export function useOptionalMeshRegistry(): MeshRegistry | undefined { + return useContext(MeshRegistryContext); +} diff --git a/packages/sdk-react/src/provider/MeshRegistryContext.ts b/packages/sdk-react/src/provider/MeshRegistryContext.ts new file mode 100644 index 00000000..dbf1db14 --- /dev/null +++ b/packages/sdk-react/src/provider/MeshRegistryContext.ts @@ -0,0 +1,4 @@ +import type { MeshRegistry } from "@meshtastic/sdk"; +import { createContext } from "react"; + +export const MeshRegistryContext = createContext(undefined); diff --git a/packages/sdk-react/src/provider/MeshRegistryProvider.tsx b/packages/sdk-react/src/provider/MeshRegistryProvider.tsx new file mode 100644 index 00000000..4548dc56 --- /dev/null +++ b/packages/sdk-react/src/provider/MeshRegistryProvider.tsx @@ -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 + * ``. + */ +export function MeshRegistryProvider({ registry, children }: MeshRegistryProviderProps) { + return {children}; +} diff --git a/packages/sdk/mod.ts b/packages/sdk/mod.ts index cd6e20ca..5c62d372 100644 --- a/packages/sdk/mod.ts +++ b/packages/sdk/mod.ts @@ -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"; diff --git a/packages/sdk/src/core/registry/MeshRegistry.test.ts b/packages/sdk/src/core/registry/MeshRegistry.test.ts new file mode 100644 index 00000000..a38e7c44 --- /dev/null +++ b/packages/sdk/src/core/registry/MeshRegistry.test.ts @@ -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); + }); +}); diff --git a/packages/sdk/src/core/registry/MeshRegistry.ts b/packages/sdk/src/core/registry/MeshRegistry.ts new file mode 100644 index 00000000..bb9a0a5d --- /dev/null +++ b/packages/sdk/src/core/registry/MeshRegistry.ts @@ -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(); + private readonly backing: Signal>; + private readonly activeSignal: Signal; + private readonly activeIdSignal: Signal; + + public readonly list: ReadonlySignal>; + public readonly active: ReadonlySignal; + public readonly activeId: ReadonlySignal; + + constructor() { + this.backing = signal>([]); + this.activeSignal = signal(undefined); + this.activeIdSignal = signal(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 { + 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, + })); + } +} diff --git a/packages/sdk/src/core/registry/index.ts b/packages/sdk/src/core/registry/index.ts new file mode 100644 index 00000000..690d0f24 --- /dev/null +++ b/packages/sdk/src/core/registry/index.ts @@ -0,0 +1,2 @@ +export { MeshRegistry } from "./MeshRegistry.ts"; +export type { ConnectionId, RegistryEntry } from "./MeshRegistry.ts";