@glasshome/sync-layer 0.1.16 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ import type { HaBridge } from "../worker/bridge-client";
2
+ /**
3
+ * Wire an HA bridge (worker-backed connection) into the store. The bridge's
4
+ * facade rides the same load/subscribe pipeline as a direct socket; only the
5
+ * lifecycle signals differ — they arrive as worker messages instead of
6
+ * Connection events.
7
+ *
8
+ * Call after bridge.connect() resolved. Returns when initial data is loaded.
9
+ */
10
+ export declare function attachBridgeToStore(bridge: HaBridge, hassUrl: string): Promise<void>;
11
+ /** Map worker connection-state messages onto the store. */
12
+ export declare function applyBridgeConnState(state: "connected" | "disconnected" | "reconnecting"): void;
13
+ /** Re-sync after the worker reports a completed reconnect. */
14
+ export declare function reloadAfterBridgeReconnect(bridge: HaBridge): Promise<void>;
15
+ /** Detach on shutdown; the caller terminates the worker itself. */
16
+ export declare function detachBridgeFromStore(): void;
@@ -7,6 +7,7 @@
7
7
  * @packageDocumentation
8
8
  */
9
9
  import { type Auth, type Connection, type ConnectionOptions as HAConnectionOptions } from "home-assistant-js-websocket";
10
+ import { type HaLink } from "../core/store";
10
11
  import { type OAuthOptions } from "./auth";
11
12
  /**
12
13
  * Options for initializing a connection
@@ -29,9 +30,9 @@ export declare function initConnection(options: ConnectionOptions): Promise<Conn
29
30
  */
30
31
  export declare function disconnect(): void;
31
32
  /**
32
- * Get current connection
33
+ * Get current connection link
33
34
  */
34
- export declare function getConnection(): Connection | null;
35
+ export declare function getConnection(): HaLink | null;
35
36
  /**
36
37
  * Check if currently connected
37
38
  */
@@ -43,6 +44,7 @@ export declare function isConnected(): boolean;
43
44
  * `isConnected()` which reflects the manager's internal lifecycle state.
44
45
  */
45
46
  export declare function getConnectionState(): boolean;
47
+ export declare function loadInitialData(conn: Pick<HaLink, "sendMessagePromise">): Promise<void>;
46
48
  /**
47
49
  * Refresh all data
48
50
  */
@@ -1,5 +1,4 @@
1
1
  /** Central reactive state store for sync-layer using SolidJS createStore. */
2
- import type { Connection } from "home-assistant-js-websocket";
3
2
  import type { SetStoreFunction, Store } from "solid-js/store";
4
3
  import type { CameraStreamData } from "../camera/types";
5
4
  import type { EntityHistoryData } from "../history/types";
@@ -14,9 +13,19 @@ import type { AreaId, AreaRegistryEntry, ConnectionState, DeviceId, DeviceRegist
14
13
  * NOTE: No EntityIndices field -- indices are derived reactively in Plan 03.
15
14
  * NOTE: No _activeSubscriptions -- managed externally.
16
15
  */
16
+ /**
17
+ * The structural surface every conn consumer actually uses. Satisfied by a
18
+ * real home-assistant-js-websocket Connection, the bridge facade (traffic
19
+ * tunneled through the HA worker), and mock connections.
20
+ */
21
+ export interface HaLink {
22
+ sendMessagePromise<T>(message: unknown): Promise<T>;
23
+ subscribeMessage<T>(callback: (message: T) => void, message: unknown): Promise<() => Promise<void>>;
24
+ subscribeEvents(callback: (event: unknown) => void, eventType?: string): Promise<() => void | Promise<void>>;
25
+ }
17
26
  export interface GlassHomeState {
18
- /** WebSocket connection instance */
19
- conn: Connection | null;
27
+ /** Live HA link (real socket, worker bridge facade, or mock) */
28
+ conn: HaLink | null;
20
29
  /** Current connection state */
21
30
  connectionState: ConnectionState;
22
31
  /** Connection error (if any) */
package/dist/index.d.ts CHANGED
@@ -43,4 +43,8 @@ export { fetchForecast, fetchForecastData, fetchForecasts } from "./weather/fetc
43
43
  export { getForecast, getForecasts, refreshForecast, refreshForecasts } from "./weather/query";
44
44
  export type { EntityForecastQueryOptions, ForecastQueryOptions, ForecastResult, ForecastsResult, ForecastType, WeatherForecast, WeatherForecastData, WeatherForecasts, WeatherForecastsData, } from "./weather/types";
45
45
  export { applyDemoServiceCall, createDemoFixtures, isDemoMode, loadDemoData, startDemoEnergyTicker, stopDemoEnergyTicker, unloadDemoData, } from "./demo/demo-provider";
46
- export declare const VERSION = "0.1.0";
46
+ export declare const VERSION = "0.2.1";
47
+ export { applyBridgeConnState, attachBridgeToStore, detachBridgeFromStore, reloadAfterBridgeReconnect, } from "./connection/bridged";
48
+ export { BridgeNeedsAuthError, createHaBridge, type BridgeConnectOptions, type BridgeEvents, type HaBridge, } from "./worker/bridge-client";
49
+ export type { AuthMode, ConnState, OAuthTokenData } from "./worker/protocol";
50
+ export { runHaBridgeWorker, type WorkerScope } from "./worker/worker-main";
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { ENERGY_ENTITY_IDS, createDemoFixtures, demoEnergyPreferences, energyEntityValue, formatEnergyState, isEnergyEntity, isSunUp, resetStore, setState, simulateEnergy, state, sunEvents, synthesizeEnergyHistory, synthesizeEnergyStatistics } from "./demo-data-CavpxBw3.js";
2
- import { callService as callService$1, createConnection, createLongLivedTokenAuth, getAuth } from "home-assistant-js-websocket";
2
+ import { runHaBridgeWorker } from "./worker-main-C93MR-AO.js";
3
+ import { createConnection, createLongLivedTokenAuth, getAuth } from "home-assistant-js-websocket";
3
4
  import { produce, reconcile } from "solid-js/store";
4
5
 
5
6
  //#region src/connection/adapter.ts
@@ -663,7 +664,13 @@ async function callService(domain, service, serviceData = {}, target = {}) {
663
664
  try {
664
665
  const mockConnection = connection;
665
666
  if (mockConnection && typeof mockConnection.callService === "function") await mockConnection.callService(domain, service, serviceData, target);
666
- else await callService$1(connection, domain, service, serviceData, target);
667
+ else await connection.sendMessagePromise({
668
+ type: "call_service",
669
+ domain,
670
+ service,
671
+ service_data: serviceData,
672
+ target
673
+ });
667
674
  } catch (error) {
668
675
  throw new Error(`Service call failed: ${error.message || String(error)}`);
669
676
  }
@@ -1244,6 +1251,7 @@ function applyStateDiff(s, entityId, diff) {
1244
1251
  const HEARTBEAT_INTERVAL_MS = 3e4;
1245
1252
  const HEARTBEAT_TIMEOUT_MS = 15e3;
1246
1253
  const SUSPEND_DELAY_MS = 5 * 60 * 1e3;
1254
+ let activeConn = null;
1247
1255
  let heartbeatInterval = null;
1248
1256
  let suspendTimeout = null;
1249
1257
  let visiblePromiseResolve = null;
@@ -1338,6 +1346,7 @@ async function initConnection(options) {
1338
1346
  ...options.createSocket ? { createSocket: options.createSocket } : {}
1339
1347
  });
1340
1348
  const wrappedConn = wrapHAConnection(conn$1);
1349
+ activeConn = conn$1;
1341
1350
  setState(produce((s) => {
1342
1351
  s.conn = conn$1;
1343
1352
  s.hassUrl = options.url.replace(/\/$/, "");
@@ -1364,17 +1373,17 @@ async function initConnection(options) {
1364
1373
  * Disconnect from Home Assistant
1365
1374
  */
1366
1375
  function disconnect() {
1367
- const conn$1 = state.conn;
1368
1376
  stopHeartbeat();
1369
1377
  teardownLifecycleListeners();
1370
- if (conn$1) conn$1.close();
1378
+ activeConn?.close();
1379
+ activeConn = null;
1371
1380
  setState(produce((s) => {
1372
1381
  s.conn = null;
1373
1382
  s.connectionState = "disconnected";
1374
1383
  }));
1375
1384
  }
1376
1385
  /**
1377
- * Get current connection
1386
+ * Get current connection link
1378
1387
  */
1379
1388
  function getConnection() {
1380
1389
  return state.conn;
@@ -1392,8 +1401,7 @@ function isConnected() {
1392
1401
  * `isConnected()` which reflects the manager's internal lifecycle state.
1393
1402
  */
1394
1403
  function getConnectionState() {
1395
- const conn$1 = state.conn;
1396
- return conn$1 ? conn$1.connected : false;
1404
+ return activeConn ? activeConn.connected : state.connectionState === "connected";
1397
1405
  }
1398
1406
  function setupEventHandlers(conn$1, options) {
1399
1407
  conn$1.addEventListener("disconnected", () => {
@@ -2702,9 +2710,212 @@ async function refreshForecasts(entityId, types) {
2702
2710
  return getForecasts(entityId, types);
2703
2711
  }
2704
2712
 
2713
+ //#endregion
2714
+ //#region src/connection/bridged.ts
2715
+ /**
2716
+ * Wire an HA bridge (worker-backed connection) into the store. The bridge's
2717
+ * facade rides the same load/subscribe pipeline as a direct socket; only the
2718
+ * lifecycle signals differ — they arrive as worker messages instead of
2719
+ * Connection events.
2720
+ *
2721
+ * Call after bridge.connect() resolved. Returns when initial data is loaded.
2722
+ */
2723
+ async function attachBridgeToStore(bridge, hassUrl) {
2724
+ setState(produce((s) => {
2725
+ s.conn = bridge.conn;
2726
+ s.hassUrl = hassUrl.replace(/\/$/, "");
2727
+ s.connectionState = "connected";
2728
+ s.connectionError = null;
2729
+ }));
2730
+ await loadInitialData(bridge.conn);
2731
+ await subscribeToUpdates(bridge.conn);
2732
+ }
2733
+ /** Map worker connection-state messages onto the store. */
2734
+ function applyBridgeConnState(state$1) {
2735
+ setState(produce((s) => {
2736
+ s.connectionState = state$1;
2737
+ if (state$1 === "connected") s.connectionError = null;
2738
+ }));
2739
+ }
2740
+ /** Re-sync after the worker reports a completed reconnect. */
2741
+ async function reloadAfterBridgeReconnect(bridge) {
2742
+ await loadInitialData(bridge.conn);
2743
+ await forceResubscribe();
2744
+ }
2745
+ /** Detach on shutdown; the caller terminates the worker itself. */
2746
+ function detachBridgeFromStore() {
2747
+ setState(produce((s) => {
2748
+ s.conn = null;
2749
+ s.connectionState = "disconnected";
2750
+ }));
2751
+ }
2752
+
2753
+ //#endregion
2754
+ //#region src/worker/bridge-client.ts
2755
+ var BridgeNeedsAuthError = class extends Error {
2756
+ constructor() {
2757
+ super("No stored Home Assistant tokens; interactive sign-in required");
2758
+ }
2759
+ };
2760
+ function createHaBridge(worker, events = {}) {
2761
+ let nextId = 1;
2762
+ let connState = "disconnected";
2763
+ const pendingSends = /* @__PURE__ */ new Map();
2764
+ const subCallbacks = /* @__PURE__ */ new Map();
2765
+ const rawListeners = /* @__PURE__ */ new Set();
2766
+ let pendingConnect = null;
2767
+ const post = (msg, transfer) => transfer ? worker.postMessage(msg, transfer) : worker.postMessage(msg);
2768
+ const failAllPending = (reason) => {
2769
+ const err = new Error(reason);
2770
+ for (const { reject } of pendingSends.values()) reject(err);
2771
+ pendingSends.clear();
2772
+ pendingConnect?.reject(err);
2773
+ pendingConnect = null;
2774
+ };
2775
+ worker.onerror = () => failAllPending("HA bridge worker crashed");
2776
+ worker.onmessage = (ev) => {
2777
+ const msg = ev.data;
2778
+ switch (msg.k) {
2779
+ case "connect_result":
2780
+ if (msg.ok) pendingConnect?.resolve();
2781
+ else pendingConnect?.reject(msg.needsAuth ? new BridgeNeedsAuthError() : new Error(msg.error ?? "Connect failed"));
2782
+ pendingConnect = null;
2783
+ break;
2784
+ case "conn":
2785
+ connState = msg.state;
2786
+ events.onConnState?.(msg.state);
2787
+ break;
2788
+ case "ready_after_reconnect":
2789
+ events.onReadyAfterReconnect?.();
2790
+ break;
2791
+ case "result": {
2792
+ const pending = pendingSends.get(msg.id);
2793
+ pendingSends.delete(msg.id);
2794
+ if (!pending) break;
2795
+ if (msg.ok) pending.resolve(msg.result);
2796
+ else pending.reject(new Error(msg.error ?? "Request failed"));
2797
+ break;
2798
+ }
2799
+ case "event": {
2800
+ subCallbacks.get(msg.id)?.(msg.payload);
2801
+ for (const listener of rawListeners) listener(msg.payload);
2802
+ break;
2803
+ }
2804
+ case "sub_end":
2805
+ subCallbacks.delete(msg.id);
2806
+ break;
2807
+ case "denial":
2808
+ events.onDenial?.(msg);
2809
+ break;
2810
+ }
2811
+ };
2812
+ function sendMessagePromise(message) {
2813
+ const id = nextId++;
2814
+ return new Promise((resolve, reject) => {
2815
+ pendingSends.set(id, {
2816
+ resolve,
2817
+ reject
2818
+ });
2819
+ post({
2820
+ k: "send",
2821
+ id,
2822
+ message
2823
+ });
2824
+ });
2825
+ }
2826
+ function subscribe(kind, callback, eventType, message) {
2827
+ const id = nextId++;
2828
+ subCallbacks.set(id, callback);
2829
+ post({
2830
+ k: "sub",
2831
+ id,
2832
+ kind,
2833
+ eventType,
2834
+ message
2835
+ });
2836
+ return async () => {
2837
+ subCallbacks.delete(id);
2838
+ post({
2839
+ k: "unsub",
2840
+ id
2841
+ });
2842
+ };
2843
+ }
2844
+ const conn$1 = {
2845
+ connect: () => Promise.resolve(),
2846
+ disconnect: () => post({ k: "disconnect" }),
2847
+ get connected$() {
2848
+ return connState === "connected";
2849
+ },
2850
+ authenticate: () => Promise.resolve(),
2851
+ get authState() {
2852
+ return connState === "connected" ? "authenticated" : "pending";
2853
+ },
2854
+ subscribeEvents: (callback, eventType) => Promise.resolve(subscribe("events", callback, eventType)),
2855
+ subscribeMessage: (callback, message) => Promise.resolve(subscribe("message", callback, void 0, message)),
2856
+ sendMessagePromise,
2857
+ addEventListener: (handler) => {
2858
+ rawListeners.add(handler);
2859
+ return () => rawListeners.delete(handler);
2860
+ }
2861
+ };
2862
+ return {
2863
+ connect(opts) {
2864
+ return new Promise((resolve, reject) => {
2865
+ pendingConnect = {
2866
+ resolve,
2867
+ reject
2868
+ };
2869
+ post({
2870
+ k: "connect",
2871
+ url: opts.url,
2872
+ mode: opts.mode,
2873
+ proxyWsPath: opts.proxyWsPath
2874
+ });
2875
+ });
2876
+ },
2877
+ disconnect: () => post({ k: "disconnect" }),
2878
+ reconnect: () => post({ k: "reconnect" }),
2879
+ clearTokens: () => post({ k: "clear_tokens" }),
2880
+ conn: conn$1,
2881
+ registerWidget(widgetId, caps) {
2882
+ const channel = new MessageChannel();
2883
+ post({
2884
+ k: "register_widget",
2885
+ widgetId,
2886
+ caps
2887
+ }, [channel.port2]);
2888
+ return channel.port1;
2889
+ },
2890
+ unregisterWidget: (widgetId) => post({
2891
+ k: "unregister_widget",
2892
+ widgetId
2893
+ }),
2894
+ forwardLifecycle() {
2895
+ const onVisibility = () => post({
2896
+ k: "visibility",
2897
+ hidden: document.visibilityState === "hidden"
2898
+ });
2899
+ const onPageshow = (event) => {
2900
+ if (event.persisted) post({ k: "pageshow_persisted" });
2901
+ };
2902
+ document.addEventListener("visibilitychange", onVisibility);
2903
+ window.addEventListener("pageshow", onPageshow);
2904
+ return () => {
2905
+ document.removeEventListener("visibilitychange", onVisibility);
2906
+ window.removeEventListener("pageshow", onPageshow);
2907
+ };
2908
+ },
2909
+ terminate() {
2910
+ failAllPending("HA bridge terminated");
2911
+ worker.terminate();
2912
+ }
2913
+ };
2914
+ }
2915
+
2705
2916
  //#endregion
2706
2917
  //#region src/index.ts
2707
- const VERSION = "0.1.0";
2918
+ const VERSION = "0.2.1";
2708
2919
 
2709
2920
  //#endregion
2710
- export { DOMAINS_USE_LAST_UPDATED, LINE_ATTRIBUTES_TO_KEEP, MAX_HISTORY_POINTS, NEED_ATTRIBUTE_DOMAINS, VERSION, addDebugIncomingMessageListener, addDebugOutgoingMessageListener, appendHistoryPoint, applyDemoServiceCall, area, areas, authenticateWithOAuth, authenticateWithToken, batchEntityUpdates, batchServiceCalls, buildAreaView, buildDeviceView, buildEntityView, bulkAppendHistoryPoints, callService, createDemoFixtures, disconnect, entities, entitiesByAreaAndDomain, entitiesWhere, entity, entityIdHistoryNeedsAttributes, entityViewEquals, extractDomain, fetchEnergyPreferences, fetchEntityHistory, fetchForecast, fetchForecastData, fetchForecasts, fetchHistory, fetchStatisticsDuringPeriod, fetchStream, fetchStreamData, getAllEntityViews, getAreaView, getAreaViews, getConnection, getConnectionState, getEntityView, getEntityViews, getForecast, getForecasts, getStream, getWebRtcClientConfig, historyStateToTimeline, initConnection, isAuthValid, isConnected, isDemoMode, isHistoryTracked, loadDemoData, normalizeStatisticTime, refreshAuth, refreshForecast, refreshForecasts, refreshStream, registerEntity, resetStore, searchEntities, sendCommand, sendWebRtcCandidate, setState, startDemoEnergyTicker, startWebRtcSession, state, stopDemoEnergyTicker, toggle, trackEntityHistory, turnOff, turnOn, unloadDemoData, untrackEntityHistory, updateEntity };
2921
+ export { BridgeNeedsAuthError, DOMAINS_USE_LAST_UPDATED, LINE_ATTRIBUTES_TO_KEEP, MAX_HISTORY_POINTS, NEED_ATTRIBUTE_DOMAINS, VERSION, addDebugIncomingMessageListener, addDebugOutgoingMessageListener, appendHistoryPoint, applyBridgeConnState, applyDemoServiceCall, area, areas, attachBridgeToStore, authenticateWithOAuth, authenticateWithToken, batchEntityUpdates, batchServiceCalls, buildAreaView, buildDeviceView, buildEntityView, bulkAppendHistoryPoints, callService, createDemoFixtures, createHaBridge, detachBridgeFromStore, disconnect, entities, entitiesByAreaAndDomain, entitiesWhere, entity, entityIdHistoryNeedsAttributes, entityViewEquals, extractDomain, fetchEnergyPreferences, fetchEntityHistory, fetchForecast, fetchForecastData, fetchForecasts, fetchHistory, fetchStatisticsDuringPeriod, fetchStream, fetchStreamData, getAllEntityViews, getAreaView, getAreaViews, getConnection, getConnectionState, getEntityView, getEntityViews, getForecast, getForecasts, getStream, getWebRtcClientConfig, historyStateToTimeline, initConnection, isAuthValid, isConnected, isDemoMode, isHistoryTracked, loadDemoData, normalizeStatisticTime, refreshAuth, refreshForecast, refreshForecasts, refreshStream, registerEntity, reloadAfterBridgeReconnect, resetStore, runHaBridgeWorker, searchEntities, sendCommand, sendWebRtcCandidate, setState, startDemoEnergyTicker, startWebRtcSession, state, stopDemoEnergyTicker, toggle, trackEntityHistory, turnOff, turnOn, unloadDemoData, untrackEntityHistory, updateEntity };
@@ -0,0 +1,46 @@
1
+ import type { CapabilityGrant } from "@glasshome/widget-contract";
2
+ import type { SyncLayerConnection } from "../connection/types";
3
+ import type { AuthMode, ConnState } from "./protocol";
4
+ /**
5
+ * Main-thread side of the HA bridge. Wraps the Worker and exposes:
6
+ * - a SyncLayerConnection facade so the existing store/subscription/command
7
+ * code runs unchanged, with all HA traffic tunneled through the worker
8
+ * - registerWidget(), which mints the per-widget MessagePort the SDK's
9
+ * service hooks call through (capability-checked in the worker)
10
+ *
11
+ * The Worker reference and its privileged channel live in this module's
12
+ * closures only.
13
+ */
14
+ export interface BridgeConnectOptions {
15
+ url: string;
16
+ mode: AuthMode;
17
+ proxyWsPath?: string;
18
+ }
19
+ export interface BridgeEvents {
20
+ onConnState?: (state: ConnState) => void;
21
+ onReadyAfterReconnect?: () => void;
22
+ onDenial?: (denial: {
23
+ widgetId: string;
24
+ domain: string;
25
+ service: string;
26
+ entityIds: string[];
27
+ message: string;
28
+ }) => void;
29
+ }
30
+ export interface HaBridge {
31
+ /** Resolves on socket-up; rejects with BridgeNeedsAuthError when the worker has no stored OAuth tokens. */
32
+ connect(opts: BridgeConnectOptions): Promise<void>;
33
+ disconnect(): void;
34
+ reconnect(): void;
35
+ clearTokens(): void;
36
+ conn: SyncLayerConnection;
37
+ registerWidget(widgetId: string, caps: CapabilityGrant[]): MessagePort;
38
+ unregisterWidget(widgetId: string): void;
39
+ /** Forward visibility/pageshow so the worker can drive suspend/reconnect. Returns teardown. */
40
+ forwardLifecycle(): () => void;
41
+ terminate(): void;
42
+ }
43
+ export declare class BridgeNeedsAuthError extends Error {
44
+ constructor();
45
+ }
46
+ export declare function createHaBridge(worker: Worker, events?: BridgeEvents): HaBridge;
@@ -0,0 +1,53 @@
1
+ import { type CapabilityGrant } from "@glasshome/widget-contract";
2
+ /**
3
+ * Capability enforcement for widget service calls. Pure and registry-backed:
4
+ * the worker feeds it its own mirror of the HA registries, so expansion never
5
+ * trusts anything computed in the widget's realm.
6
+ */
7
+ export interface EntityFacts {
8
+ deviceId: string | null;
9
+ areaId: string | null;
10
+ labels: string[];
11
+ }
12
+ export interface DeviceFacts {
13
+ areaId: string | null;
14
+ labels: string[];
15
+ }
16
+ export interface ServiceCallShape {
17
+ domain: string;
18
+ service: string;
19
+ data?: Record<string, unknown>;
20
+ target?: Record<string, unknown>;
21
+ }
22
+ export declare class RegistryMirror {
23
+ readonly entities: Map<string, EntityFacts>;
24
+ readonly devices: Map<string, DeviceFacts>;
25
+ replace(entityRegistry: {
26
+ entity_id: string;
27
+ device_id: string | null;
28
+ area_id: string | null;
29
+ labels?: string[];
30
+ }[], deviceRegistry: {
31
+ id: string;
32
+ area_id: string | null;
33
+ labels?: string[];
34
+ }[]): void;
35
+ entityAreaId(entityId: string): string | null;
36
+ entityLabels(entityId: string): string[];
37
+ }
38
+ /**
39
+ * Expand a service call to the concrete entity ids it can touch. Reads BOTH
40
+ * `target` and the legacy id fields inside `data` — HA honors entity_id (and
41
+ * friends) in service data, so ignoring it would let a call bypass entity
42
+ * narrowing.
43
+ */
44
+ export declare function expandTargets(call: ServiceCallShape, registry: RegistryMirror): string[];
45
+ export type EnforcementVerdict = {
46
+ allowed: true;
47
+ entityIds: string[];
48
+ } | {
49
+ allowed: false;
50
+ entityIds: string[];
51
+ message: string;
52
+ };
53
+ export declare function enforceServiceCall(caps: readonly CapabilityGrant[], call: ServiceCallShape, registry: RegistryMirror): EnforcementVerdict;
@@ -0,0 +1,120 @@
1
+ import type { CapabilityGrant } from "@glasshome/widget-contract";
2
+ /**
3
+ * Message protocol between the main thread and the HA bridge worker.
4
+ *
5
+ * Two channels with different privilege:
6
+ * - The worker's own message port (privileged): full tunnel to the HA socket.
7
+ * Held in closure by the bridge client; never handed to widget code.
8
+ * - Per-widget MessagePorts: exactly one verb (call_service), validated
9
+ * against the widget's granted capabilities inside the worker.
10
+ */
11
+ export type AuthMode = {
12
+ kind: "token";
13
+ token: string;
14
+ } | {
15
+ kind: "brokered";
16
+ mintUrl: string;
17
+ } | {
18
+ kind: "oauth";
19
+ data?: OAuthTokenData;
20
+ };
21
+ /** Serializable subset of home-assistant-js-websocket AuthData. */
22
+ export interface OAuthTokenData {
23
+ hassUrl: string;
24
+ clientId: string | null;
25
+ access_token: string;
26
+ refresh_token: string;
27
+ expires: number;
28
+ expires_in: number;
29
+ }
30
+ export type MainToWorker = {
31
+ k: "connect";
32
+ url: string;
33
+ mode: AuthMode;
34
+ /** Same-origin WS path to use instead of the HA host (tunnel proxy). */
35
+ proxyWsPath?: string;
36
+ } | {
37
+ k: "send";
38
+ id: number;
39
+ message: unknown;
40
+ } | {
41
+ k: "sub";
42
+ id: number;
43
+ kind: "events" | "message";
44
+ eventType?: string;
45
+ message?: unknown;
46
+ } | {
47
+ k: "unsub";
48
+ id: number;
49
+ } | {
50
+ k: "visibility";
51
+ hidden: boolean;
52
+ } | {
53
+ k: "pageshow_persisted";
54
+ } | {
55
+ k: "reconnect";
56
+ } | {
57
+ k: "disconnect";
58
+ } | {
59
+ k: "clear_tokens";
60
+ } | {
61
+ k: "register_widget";
62
+ widgetId: string;
63
+ caps: CapabilityGrant[];
64
+ } | {
65
+ k: "unregister_widget";
66
+ widgetId: string;
67
+ };
68
+ export type ConnState = "connected" | "disconnected" | "reconnecting";
69
+ export type WorkerToMain = {
70
+ k: "connect_result";
71
+ ok: boolean;
72
+ error?: string;
73
+ /** OAuth mode with no stored tokens: main thread must run interactive auth. */
74
+ needsAuth?: boolean;
75
+ } | {
76
+ k: "conn";
77
+ state: ConnState;
78
+ }
79
+ /** Fired after a reconnect completes; main thread reloads data + resubscribes. */
80
+ | {
81
+ k: "ready_after_reconnect";
82
+ } | {
83
+ k: "result";
84
+ id: number;
85
+ ok: boolean;
86
+ result?: unknown;
87
+ error?: string;
88
+ } | {
89
+ k: "event";
90
+ id: number;
91
+ payload: unknown;
92
+ } | {
93
+ k: "sub_end";
94
+ id: number;
95
+ error?: string;
96
+ } | {
97
+ k: "denial";
98
+ widgetId: string;
99
+ domain: string;
100
+ service: string;
101
+ entityIds: string[];
102
+ message: string;
103
+ };
104
+ /** Widget port: request. The only verb widget code can reach. */
105
+ export interface WidgetServiceCall {
106
+ id: number;
107
+ domain: string;
108
+ service: string;
109
+ data?: Record<string, unknown>;
110
+ target?: Record<string, unknown>;
111
+ }
112
+ export type WidgetServiceResult = {
113
+ id: number;
114
+ ok: true;
115
+ } | {
116
+ id: number;
117
+ ok: false;
118
+ code: "CAPABILITY_DENIED" | "UNAVAILABLE" | "ERROR";
119
+ message: string;
120
+ };
@@ -0,0 +1,3 @@
1
+ import type { OAuthTokenData } from "./protocol";
2
+ export declare function loadTokens(): Promise<OAuthTokenData | null>;
3
+ export declare function saveTokens(data: OAuthTokenData | null): Promise<void>;
@@ -0,0 +1,10 @@
1
+ /** Structural worker-global surface; the package compiles against DOM libs. */
2
+ export interface WorkerScope {
3
+ postMessage(message: unknown): void;
4
+ onmessage: ((ev: MessageEvent) => void) | null;
5
+ location: {
6
+ protocol: string;
7
+ host: string;
8
+ };
9
+ }
10
+ export declare function runHaBridgeWorker(scope: WorkerScope): void;
@@ -0,0 +1,3 @@
1
+ import { runHaBridgeWorker } from "../worker-main-C93MR-AO.js";
2
+
3
+ export { runHaBridgeWorker };
@@ -0,0 +1,482 @@
1
+ import { Auth, createConnection, createSocket, genExpires } from "home-assistant-js-websocket";
2
+ import { matchesCapability } from "@glasshome/widget-contract";
3
+
4
+ //#region src/worker/enforcement.ts
5
+ var RegistryMirror = class {
6
+ entities = /* @__PURE__ */ new Map();
7
+ devices = /* @__PURE__ */ new Map();
8
+ replace(entityRegistry, deviceRegistry) {
9
+ this.entities.clear();
10
+ this.devices.clear();
11
+ for (const e of entityRegistry) this.entities.set(e.entity_id, {
12
+ deviceId: e.device_id,
13
+ areaId: e.area_id,
14
+ labels: e.labels ?? []
15
+ });
16
+ for (const d of deviceRegistry) this.devices.set(d.id, {
17
+ areaId: d.area_id,
18
+ labels: d.labels ?? []
19
+ });
20
+ }
21
+ entityAreaId(entityId) {
22
+ const e = this.entities.get(entityId);
23
+ if (!e) return null;
24
+ if (e.areaId) return e.areaId;
25
+ return e.deviceId ? this.devices.get(e.deviceId)?.areaId ?? null : null;
26
+ }
27
+ entityLabels(entityId) {
28
+ const e = this.entities.get(entityId);
29
+ if (!e) return [];
30
+ const fromDevice = e.deviceId ? this.devices.get(e.deviceId)?.labels ?? [] : [];
31
+ return [...e.labels, ...fromDevice];
32
+ }
33
+ };
34
+ const asArray = (v) => typeof v === "string" ? [v] : Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
35
+ /**
36
+ * Expand a service call to the concrete entity ids it can touch. Reads BOTH
37
+ * `target` and the legacy id fields inside `data` — HA honors entity_id (and
38
+ * friends) in service data, so ignoring it would let a call bypass entity
39
+ * narrowing.
40
+ */
41
+ function expandTargets(call, registry) {
42
+ const sources = [call.target ?? {}, call.data ?? {}];
43
+ const ids = /* @__PURE__ */ new Set();
44
+ for (const src of sources) {
45
+ for (const id of asArray(src.entity_id)) ids.add(id);
46
+ for (const deviceId of asArray(src.device_id)) for (const [entityId, facts] of registry.entities) if (facts.deviceId === deviceId) ids.add(entityId);
47
+ for (const areaId of asArray(src.area_id)) for (const [entityId] of registry.entities) if (registry.entityAreaId(entityId) === areaId) ids.add(entityId);
48
+ for (const labelId of asArray(src.label_id)) for (const [entityId] of registry.entities) if (registry.entityLabels(entityId).includes(labelId)) ids.add(entityId);
49
+ }
50
+ return [...ids];
51
+ }
52
+ function enforceServiceCall(caps, call, registry) {
53
+ const entityIds = expandTargets(call, registry);
54
+ if (matchesCapability(caps, {
55
+ domain: call.domain,
56
+ service: call.service,
57
+ entityIds
58
+ })) return {
59
+ allowed: true,
60
+ entityIds
61
+ };
62
+ return {
63
+ allowed: false,
64
+ entityIds,
65
+ message: `Call to ${call.domain}.${call.service} is not covered by the widget's granted capabilities`
66
+ };
67
+ }
68
+
69
+ //#endregion
70
+ //#region src/worker/token-store.ts
71
+ /**
72
+ * OAuth token persistence inside the worker. IndexedDB is the only storage
73
+ * a worker has; keeping tokens here (and deleting the old localStorage copy
74
+ * on the main thread) is what makes "widgets never see the token" hold.
75
+ */
76
+ const DB_NAME = "glasshome-ha-bridge";
77
+ const STORE = "auth";
78
+ const KEY = "oauth";
79
+ function openDb() {
80
+ return new Promise((resolve, reject) => {
81
+ const req = indexedDB.open(DB_NAME, 1);
82
+ req.onupgradeneeded = () => {
83
+ req.result.createObjectStore(STORE);
84
+ };
85
+ req.onsuccess = () => resolve(req.result);
86
+ req.onerror = () => reject(req.error);
87
+ });
88
+ }
89
+ function settle(req) {
90
+ return new Promise((resolve, reject) => {
91
+ req.onsuccess = () => resolve(req.result);
92
+ req.onerror = () => reject(req.error);
93
+ });
94
+ }
95
+ async function loadTokens() {
96
+ try {
97
+ const db = await openDb();
98
+ const store = db.transaction(STORE, "readonly").objectStore(STORE);
99
+ const data = await settle(store.get(KEY));
100
+ db.close();
101
+ return data ?? null;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+ async function saveTokens(data) {
107
+ try {
108
+ const db = await openDb();
109
+ const store = db.transaction(STORE, "readwrite").objectStore(STORE);
110
+ await (data ? settle(store.put(data, KEY)) : settle(store.delete(KEY)));
111
+ db.close();
112
+ } catch {}
113
+ }
114
+
115
+ //#endregion
116
+ //#region src/worker/worker-main.ts
117
+ /**
118
+ * HA bridge worker. Owns the socket and the auth tokens; neither ever
119
+ * reaches the main thread. The privileged channel (the worker's own port)
120
+ * tunnels arbitrary HA traffic for the host. Widget MessagePorts get exactly
121
+ * one verb — call_service — validated against the widget's granted
122
+ * capabilities with the worker's own registry mirror, so enforcement does
123
+ * not trust anything computed in the widget's realm.
124
+ */
125
+ const HEARTBEAT_INTERVAL_MS = 3e4;
126
+ const HEARTBEAT_TIMEOUT_MS = 15e3;
127
+ const SUSPEND_DELAY_MS = 5 * 60 * 1e3;
128
+ function isHaWireMessage(value) {
129
+ return typeof value === "object" && value !== null && typeof value.type === "string";
130
+ }
131
+ var BrokeredAuth = class extends Auth {
132
+ constructor(mintUrl, data) {
133
+ super(data);
134
+ this.mintUrl = mintUrl;
135
+ }
136
+ async refreshAccessToken() {
137
+ const res = await fetch(this.mintUrl, {
138
+ credentials: "same-origin",
139
+ headers: { accept: "application/json" }
140
+ });
141
+ if (!res.ok) throw new Error(`Token broker responded ${res.status}`);
142
+ if (!res.headers.get("content-type")?.includes("application/json")) throw new Error(`Token broker did not return JSON (is ${this.mintUrl} reachable?)`);
143
+ const broker = await res.json();
144
+ this.data = {
145
+ ...this.data,
146
+ access_token: broker.accessToken,
147
+ expires_in: broker.expiresInSec,
148
+ expires: genExpires(broker.expiresInSec),
149
+ refresh_token: "",
150
+ hassUrl: broker.haUrl,
151
+ clientId: broker.haClientId
152
+ };
153
+ }
154
+ };
155
+ function runHaBridgeWorker(scope) {
156
+ let conn = null;
157
+ const widgets = /* @__PURE__ */ new Map();
158
+ const subs = /* @__PURE__ */ new Map();
159
+ const registry = new RegistryMirror();
160
+ let suspendTimeout = null;
161
+ let resumeVisible = null;
162
+ let heartbeat = null;
163
+ const post = (msg) => scope.postMessage(msg);
164
+ async function loadRegistryMirror(c) {
165
+ const [entityRegistry, deviceRegistry] = await Promise.all([c.sendMessagePromise({ type: "config/entity_registry/list" }), c.sendMessagePromise({ type: "config/device_registry/list" })]);
166
+ registry.replace(entityRegistry, deviceRegistry);
167
+ }
168
+ function watchRegistryMirror(c) {
169
+ const refresh = () => {
170
+ loadRegistryMirror(c).catch(() => {});
171
+ };
172
+ c.subscribeEvents(refresh, "entity_registry_updated");
173
+ c.subscribeEvents(refresh, "device_registry_updated");
174
+ c.subscribeEvents(refresh, "area_registry_updated");
175
+ }
176
+ function attachWidgetPort(widgetId, caps, port) {
177
+ widgets.get(widgetId)?.port.close();
178
+ widgets.set(widgetId, {
179
+ port,
180
+ caps
181
+ });
182
+ port.onmessage = async (ev) => {
183
+ const call = ev.data;
184
+ const reply = (msg) => port.postMessage(msg);
185
+ const verdict = enforceServiceCall(caps, call, registry);
186
+ if (!verdict.allowed) {
187
+ post({
188
+ k: "denial",
189
+ widgetId,
190
+ domain: call.domain,
191
+ service: call.service,
192
+ entityIds: verdict.entityIds,
193
+ message: verdict.message
194
+ });
195
+ reply({
196
+ id: call.id,
197
+ ok: false,
198
+ code: "CAPABILITY_DENIED",
199
+ message: verdict.message
200
+ });
201
+ return;
202
+ }
203
+ if (!conn) {
204
+ reply({
205
+ id: call.id,
206
+ ok: false,
207
+ code: "UNAVAILABLE",
208
+ message: "Not connected"
209
+ });
210
+ return;
211
+ }
212
+ try {
213
+ await conn.sendMessagePromise({
214
+ type: "call_service",
215
+ domain: call.domain,
216
+ service: call.service,
217
+ service_data: call.data ?? {},
218
+ ...call.target ? { target: call.target } : {}
219
+ });
220
+ reply({
221
+ id: call.id,
222
+ ok: true
223
+ });
224
+ } catch (err) {
225
+ reply({
226
+ id: call.id,
227
+ ok: false,
228
+ code: "ERROR",
229
+ message: err instanceof Error ? err.message : String(err)
230
+ });
231
+ }
232
+ };
233
+ port.start?.();
234
+ }
235
+ function startHeartbeat(c) {
236
+ stopHeartbeat();
237
+ heartbeat = setInterval(() => {
238
+ const timer = new Promise((_, reject) => setTimeout(() => reject(new Error("ping timeout")), HEARTBEAT_TIMEOUT_MS));
239
+ Promise.race([c.ping(), timer]).catch(() => c.reconnect());
240
+ }, HEARTBEAT_INTERVAL_MS);
241
+ }
242
+ function stopHeartbeat() {
243
+ if (heartbeat !== null) {
244
+ clearInterval(heartbeat);
245
+ heartbeat = null;
246
+ }
247
+ }
248
+ function onVisibility(hidden) {
249
+ if (!conn) return;
250
+ if (hidden) {
251
+ conn.suspendReconnectUntil(new Promise((resolve) => {
252
+ resumeVisible = resolve;
253
+ }));
254
+ suspendTimeout ??= setTimeout(() => {
255
+ suspendTimeout = null;
256
+ conn?.suspend();
257
+ }, SUSPEND_DELAY_MS);
258
+ } else {
259
+ if (suspendTimeout !== null) {
260
+ clearTimeout(suspendTimeout);
261
+ suspendTimeout = null;
262
+ }
263
+ resumeVisible?.();
264
+ resumeVisible = null;
265
+ }
266
+ }
267
+ async function buildAuth(url, mode) {
268
+ switch (mode.kind) {
269
+ case "token": return new Auth({
270
+ hassUrl: url,
271
+ clientId: null,
272
+ access_token: mode.token,
273
+ refresh_token: "",
274
+ expires: Date.now() + 365 * 24 * 3600 * 1e3,
275
+ expires_in: 365 * 24 * 3600
276
+ });
277
+ case "brokered": return new BrokeredAuth(mode.mintUrl, {
278
+ hassUrl: url,
279
+ clientId: null,
280
+ access_token: "",
281
+ refresh_token: "",
282
+ expires: 0,
283
+ expires_in: 0
284
+ });
285
+ case "oauth": {
286
+ const data = mode.data ?? await loadTokens();
287
+ if (!data) return "needs-auth";
288
+ if (mode.data) await saveTokens(mode.data);
289
+ return new Auth(data, (updated) => {
290
+ saveTokens(updated);
291
+ });
292
+ }
293
+ }
294
+ }
295
+ async function connect(url, mode, proxyWsPath) {
296
+ const auth = await buildAuth(url, mode);
297
+ if (auth === "needs-auth") {
298
+ post({
299
+ k: "connect_result",
300
+ ok: false,
301
+ needsAuth: true
302
+ });
303
+ return;
304
+ }
305
+ if (mode.kind === "brokered") try {
306
+ await auth.refreshAccessToken();
307
+ } catch (err) {
308
+ post({
309
+ k: "connect_result",
310
+ ok: false,
311
+ error: err instanceof Error ? err.message : String(err)
312
+ });
313
+ return;
314
+ }
315
+ const socketFactory = proxyWsPath ? (options) => {
316
+ const proto = scope.location.protocol === "https:" ? "wss:" : "ws:";
317
+ const proxied = new Proxy(options.auth, { get: (target, prop) => prop === "wsUrl" ? `${proto}//${scope.location.host}${proxyWsPath}` : Reflect.get(target, prop) });
318
+ return createSocket({
319
+ ...options,
320
+ auth: proxied
321
+ });
322
+ } : void 0;
323
+ try {
324
+ conn = await createConnection({
325
+ auth,
326
+ ...socketFactory ? { createSocket: socketFactory } : {}
327
+ });
328
+ } catch (err) {
329
+ post({
330
+ k: "connect_result",
331
+ ok: false,
332
+ error: err instanceof Error ? err.message : String(err)
333
+ });
334
+ return;
335
+ }
336
+ conn.addEventListener("disconnected", () => post({
337
+ k: "conn",
338
+ state: "disconnected"
339
+ }));
340
+ conn.addEventListener("reconnect-error", () => post({
341
+ k: "conn",
342
+ state: "reconnecting"
343
+ }));
344
+ let everReady = false;
345
+ conn.addEventListener("ready", () => {
346
+ post({
347
+ k: "conn",
348
+ state: "connected"
349
+ });
350
+ loadRegistryMirror(conn).catch(() => {});
351
+ if (everReady) post({ k: "ready_after_reconnect" });
352
+ everReady = true;
353
+ });
354
+ await loadRegistryMirror(conn);
355
+ watchRegistryMirror(conn);
356
+ startHeartbeat(conn);
357
+ everReady = true;
358
+ post({
359
+ k: "connect_result",
360
+ ok: true
361
+ });
362
+ post({
363
+ k: "conn",
364
+ state: "connected"
365
+ });
366
+ }
367
+ scope.onmessage = async (ev) => {
368
+ const msg = ev.data;
369
+ switch (msg.k) {
370
+ case "connect":
371
+ await connect(msg.url, msg.mode, msg.proxyWsPath);
372
+ break;
373
+ case "send": {
374
+ if (!conn || !isHaWireMessage(msg.message)) {
375
+ post({
376
+ k: "result",
377
+ id: msg.id,
378
+ ok: false,
379
+ error: conn ? "Malformed message" : "Not connected"
380
+ });
381
+ return;
382
+ }
383
+ try {
384
+ const result = await conn.sendMessagePromise(msg.message);
385
+ post({
386
+ k: "result",
387
+ id: msg.id,
388
+ ok: true,
389
+ result
390
+ });
391
+ } catch (err) {
392
+ post({
393
+ k: "result",
394
+ id: msg.id,
395
+ ok: false,
396
+ error: err instanceof Error ? err.message : String(err)
397
+ });
398
+ }
399
+ break;
400
+ }
401
+ case "sub": {
402
+ if (!conn) {
403
+ post({
404
+ k: "sub_end",
405
+ id: msg.id,
406
+ error: "Not connected"
407
+ });
408
+ return;
409
+ }
410
+ try {
411
+ const forward = (payload) => post({
412
+ k: "event",
413
+ id: msg.id,
414
+ payload
415
+ });
416
+ let unsub;
417
+ if (msg.kind === "events") unsub = await conn.subscribeEvents(forward, msg.eventType);
418
+ else {
419
+ if (!isHaWireMessage(msg.message)) {
420
+ post({
421
+ k: "sub_end",
422
+ id: msg.id,
423
+ error: "Malformed message"
424
+ });
425
+ return;
426
+ }
427
+ unsub = await conn.subscribeMessage(forward, msg.message);
428
+ }
429
+ subs.set(msg.id, unsub);
430
+ } catch (err) {
431
+ post({
432
+ k: "sub_end",
433
+ id: msg.id,
434
+ error: err instanceof Error ? err.message : String(err)
435
+ });
436
+ }
437
+ break;
438
+ }
439
+ case "unsub": {
440
+ const unsub = subs.get(msg.id);
441
+ subs.delete(msg.id);
442
+ try {
443
+ await unsub?.();
444
+ } catch {}
445
+ break;
446
+ }
447
+ case "visibility":
448
+ onVisibility(msg.hidden);
449
+ break;
450
+ case "pageshow_persisted":
451
+ conn?.reconnect();
452
+ break;
453
+ case "reconnect":
454
+ conn?.reconnect();
455
+ break;
456
+ case "disconnect":
457
+ stopHeartbeat();
458
+ conn?.close();
459
+ conn = null;
460
+ post({
461
+ k: "conn",
462
+ state: "disconnected"
463
+ });
464
+ break;
465
+ case "clear_tokens":
466
+ await saveTokens(null);
467
+ break;
468
+ case "register_widget": {
469
+ const port = ev.ports[0];
470
+ if (port) attachWidgetPort(msg.widgetId, msg.caps, port);
471
+ break;
472
+ }
473
+ case "unregister_widget":
474
+ widgets.get(msg.widgetId)?.port.close();
475
+ widgets.delete(msg.widgetId);
476
+ break;
477
+ }
478
+ };
479
+ }
480
+
481
+ //#endregion
482
+ export { runHaBridgeWorker };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glasshome/sync-layer",
3
- "version": "0.1.16",
3
+ "version": "0.2.1",
4
4
  "description": "Type-safe, reactive state synchronization layer for Home Assistant",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,6 +26,11 @@
26
26
  "types": "./dist/testing/index.d.ts",
27
27
  "import": "./dist/testing/index.js"
28
28
  },
29
+ "./worker": {
30
+ "@glasshome/source": "./src/worker/worker-main.ts",
31
+ "types": "./dist/worker/worker-main.d.ts",
32
+ "import": "./dist/worker/worker-main.js"
33
+ },
29
34
  "./package.json": "./package.json"
30
35
  },
31
36
  "peerDependencies": {
@@ -50,7 +55,8 @@
50
55
  "lint": "tsc --noEmit"
51
56
  },
52
57
  "dependencies": {
53
- "home-assistant-js-websocket": "^9.6.0"
58
+ "home-assistant-js-websocket": "^9.6.0",
59
+ "@glasshome/widget-contract": "0.1.0"
54
60
  },
55
61
  "devDependencies": {
56
62
  "@glasshome/ha-types": "0.1.2",