mirror of
https://github.com/meshtastic/web.git
synced 2026-07-31 15:06:27 -04:00
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:
@@ -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";
|
||||
|
||||
|
||||
8
packages/sdk-react/src/adapters/useActiveClient.ts
Normal file
8
packages/sdk-react/src/adapters/useActiveClient.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
11
packages/sdk-react/src/adapters/useClientById.ts
Normal file
11
packages/sdk-react/src/adapters/useClientById.ts
Normal 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;
|
||||
}
|
||||
15
packages/sdk-react/src/adapters/useMeshRegistry.ts
Normal file
15
packages/sdk-react/src/adapters/useMeshRegistry.ts
Normal 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);
|
||||
}
|
||||
4
packages/sdk-react/src/provider/MeshRegistryContext.ts
Normal file
4
packages/sdk-react/src/provider/MeshRegistryContext.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { MeshRegistry } from "@meshtastic/sdk";
|
||||
import { createContext } from "react";
|
||||
|
||||
export const MeshRegistryContext = createContext<MeshRegistry | undefined>(undefined);
|
||||
17
packages/sdk-react/src/provider/MeshRegistryProvider.tsx
Normal file
17
packages/sdk-react/src/provider/MeshRegistryProvider.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
56
packages/sdk/src/core/registry/MeshRegistry.test.ts
Normal file
56
packages/sdk/src/core/registry/MeshRegistry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
95
packages/sdk/src/core/registry/MeshRegistry.ts
Normal file
95
packages/sdk/src/core/registry/MeshRegistry.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
2
packages/sdk/src/core/registry/index.ts
Normal file
2
packages/sdk/src/core/registry/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { MeshRegistry } from "./MeshRegistry.ts";
|
||||
export type { ConnectionId, RegistryEntry } from "./MeshRegistry.ts";
|
||||
Reference in New Issue
Block a user