@@ -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+ }
0 commit comments