Skip to content

Commit 00ed88d

Browse files
committed
feat(js/client): add expose() + ZhubExposure (JS port of Python expose mode)
Phase 7.0 device-only registration: announce capabilities on a hub without pairing to any one AI. The full feature has been in Python's zhub.client since the start (zhub.expose, ZhubExposure) but the JS port had no equivalent — JS callers wanting to wire a smart-home device, camera, or any non-AI capability provider had no way to use the exposure surface from JS at all. Mirrors the Python contract: - same reconnect+backoff loop publish()/connect() already use on JS - exposureId + deviceKey populated on the 'exposure-registered' envelope; reuse deviceKey to keep the same exposureId across hub restarts - invoke-request → capability handler → invoke-result, with handler exceptions surfaced as {ok:false, error: <message>} - stop() + runForever() lifecycle in line with the prior parity port - allowPublishers access policy (undefined = any / list = whitelist / [] = kill switch) sent inline on the manifest
1 parent 1ff2284 commit 00ed88d

2 files changed

Lines changed: 190 additions & 2 deletions

File tree

js/src/client.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
Envelope,
1616
registerPublisher,
1717
registerConnection,
18+
registerExposure,
1819
chatRequest,
1920
chatChunk,
2021
invokeRequest,
@@ -733,3 +734,190 @@ export function connect(opts: ConnectOptions): ZhubConnection {
733734
conn.start();
734735
return conn;
735736
}
737+
738+
// ---- expose -------------------------------------------------------------
739+
740+
export interface ExposeOptions {
741+
name: string;
742+
capabilities: Record<string, [Record<string, unknown>, CapabilityHandler]>;
743+
hubUrl: string;
744+
description?: string;
745+
publicListing?: boolean;
746+
operator?: string;
747+
/** Re-registration: pass back the previous device_key to keep the same
748+
* exposure_id across hub restarts. */
749+
deviceKey?: string;
750+
/** Optional access policy (Phase 15.0). Distinguishes three states:
751+
* - undefined → any registered publisher's bearer key can invoke
752+
* (backwards-compatible default).
753+
* - non-empty list → only those publisher names may invoke; others get 403.
754+
* - empty list `[]` → kill switch. Nobody can invoke. Useful for
755+
* temporarily quarantining a device without unregistering it. */
756+
allowPublishers?: string[];
757+
}
758+
759+
/**
760+
* Returned by expose(). A device-only registration: not paired with any
761+
* specific AI; any AI on the hub can invoke this exposure's capabilities via
762+
* `/exposures/<id>/invoke`. Mirror of Python's ZhubExposure.
763+
*/
764+
export class ZhubExposure {
765+
name: string;
766+
hubUrl: string;
767+
/** Set on the first `exposure-registered` envelope. */
768+
exposureId = '';
769+
/** Set on first register; reuse to keep the same `exposureId` across hub
770+
* restarts. */
771+
deviceKey = '';
772+
private manifest: Manifest;
773+
private capabilities: Record<string, CapabilityHandler>;
774+
private allowPublishers: string[] | undefined;
775+
private initialDeviceKey: string | undefined;
776+
private ws: WebSocket | null = null;
777+
private stopped = false;
778+
private stopResolvers: Array<() => void> = [];
779+
780+
constructor(opts: ExposeOptions, manifest: Manifest) {
781+
this.name = opts.name;
782+
this.hubUrl = opts.hubUrl;
783+
this.manifest = manifest;
784+
this.allowPublishers = opts.allowPublishers;
785+
this.initialDeviceKey = opts.deviceKey;
786+
this.capabilities = Object.fromEntries(
787+
Object.entries(opts.capabilities).map(([n, [, h]]) => [n, h]),
788+
);
789+
}
790+
791+
async stop(): Promise<void> {
792+
this.stopped = true;
793+
this.ws?.close();
794+
const resolvers = this.stopResolvers.splice(0);
795+
for (const r of resolvers) r();
796+
}
797+
798+
/** Block until stop() is called — mirror of Python's ZhubExposure.run_forever(). */
799+
runForever(): Promise<void> {
800+
if (this.stopped) return Promise.resolve();
801+
return new Promise<void>((resolve) => {
802+
this.stopResolvers.push(resolve);
803+
});
804+
}
805+
806+
/** Internal — call from expose(). */
807+
start(): void {
808+
void this.runReconnectLoop();
809+
}
810+
811+
private async runReconnectLoop(): Promise<void> {
812+
let backoff = 1.0;
813+
while (!this.stopped) {
814+
try {
815+
await this.serveOneSession();
816+
backoff = 1.0;
817+
} catch (err) {
818+
if (err instanceof AuthError) return;
819+
}
820+
if (this.stopped) return;
821+
await new Promise((r) => setTimeout(r, backoff * 1000));
822+
backoff = Math.min(backoff * 2, 60);
823+
}
824+
}
825+
826+
private serveOneSession(): Promise<void> {
827+
const url = toWsUrl(this.hubUrl, '/ws/expose');
828+
return new Promise((resolve, reject) => {
829+
const ws = new WebSocketImpl(url) as WebSocket;
830+
this.ws = ws;
831+
ws.onopen = () => {
832+
const manifestDict: Record<string, unknown> = {
833+
...(this.manifest as unknown as Record<string, unknown>),
834+
};
835+
if (this.allowPublishers !== undefined) {
836+
manifestDict.allow_publishers = [...this.allowPublishers];
837+
}
838+
const registerKey = this.deviceKey || this.initialDeviceKey;
839+
ws.send(JSON.stringify(registerExposure(this.name, manifestDict, registerKey ?? null)));
840+
};
841+
ws.onmessage = (msg) => {
842+
let env: Envelope;
843+
try {
844+
env = JSON.parse(typeof msg.data === 'string' ? msg.data : String(msg.data));
845+
} catch {
846+
return;
847+
}
848+
void this.handleMessage(ws, env, reject);
849+
};
850+
ws.onerror = () => {};
851+
ws.onclose = () => {
852+
this.ws = null;
853+
resolve();
854+
};
855+
});
856+
}
857+
858+
private async handleMessage(
859+
ws: WebSocket,
860+
env: Envelope,
861+
reject: (e: Error) => void,
862+
): Promise<void> {
863+
switch (env.type) {
864+
case 'exposure-registered': {
865+
this.exposureId = String(env.payload.exposure_id ?? '');
866+
const newKey = String(env.payload.device_key ?? '');
867+
if (newKey) this.deviceKey = newKey;
868+
return;
869+
}
870+
case 'invoke-request': {
871+
const capability = String(env.payload.capability ?? '');
872+
const args = (env.payload.args as Record<string, unknown>) ?? {};
873+
const handler = this.capabilities[capability];
874+
if (!handler) {
875+
ws.send(JSON.stringify(invokeResult(env.request_id, false, undefined, `capability '${capability}' not exposed`)));
876+
return;
877+
}
878+
try {
879+
const out = await Promise.resolve(handler(args));
880+
ws.send(JSON.stringify(invokeResult(env.request_id, true, out)));
881+
} catch (e) {
882+
ws.send(JSON.stringify(invokeResult(env.request_id, false, undefined, (e as Error).message)));
883+
}
884+
return;
885+
}
886+
case 'error': {
887+
if (env.payload.code === 'register_failed') {
888+
ws.close();
889+
reject(new AuthError(String(env.payload.message ?? 'register failed')));
890+
}
891+
return;
892+
}
893+
}
894+
}
895+
}
896+
897+
/**
898+
* Register device capabilities on a hub WITHOUT pairing to any one AI.
899+
* Returns a ZhubExposure with `exposureId` and `deviceKey` populated after the
900+
* WS handshake. Mirror of Python's expose().
901+
*/
902+
export function expose(opts: ExposeOptions): ZhubExposure {
903+
const capabilities: Capability[] = Object.entries(opts.capabilities).map(([name, [schema]]) => ({
904+
name,
905+
description: '',
906+
schema,
907+
}));
908+
const manifest: Manifest = {
909+
schema_version: '0.1',
910+
name: opts.name,
911+
description: opts.description ?? `device: ${opts.name}`,
912+
operator: opts.operator ?? '',
913+
capabilities,
914+
auth: { type: 'bearer' },
915+
rate_limit: '60/min',
916+
public: opts.publicListing ?? true,
917+
contact: '',
918+
extensions: {},
919+
};
920+
const exp = new ZhubExposure(opts, manifest);
921+
exp.start();
922+
return exp;
923+
}

js/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
export { publish, connect, ZhubPublication, ZhubConnection } from './client.js';
1+
export { publish, connect, expose, ZhubPublication, ZhubConnection, ZhubExposure } from './client.js';
22
export type {
3-
PublishOptions, ConnectOptions, ChatHandler, ChatResult, CapabilityHandler, ConnectionEventHandler, ConnectionEventKind,
3+
PublishOptions, ConnectOptions, ExposeOptions, ChatHandler, ChatResult, CapabilityHandler, ConnectionEventHandler, ConnectionEventKind,
44
} from './client.js';
55
export type { Manifest, Capability } from './manifest.js';
66
export { chatOnlyManifest } from './manifest.js';

0 commit comments

Comments
 (0)