@glasshome/sync-layer 0.2.1 → 0.3.0

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.
@@ -7,7 +7,7 @@ import type { HaBridge } from "../worker/bridge-client";
7
7
  *
8
8
  * Call after bridge.connect() resolved. Returns when initial data is loaded.
9
9
  */
10
- export declare function attachBridgeToStore(bridge: HaBridge, hassUrl: string): Promise<void>;
10
+ export declare function attachBridgeToStore(bridge: HaBridge, hassUrl: string, mediaProxyBase?: string): Promise<void>;
11
11
  /** Map worker connection-state messages onto the store. */
12
12
  export declare function applyBridgeConnState(state: "connected" | "disconnected" | "reconnecting"): void;
13
13
  /** Re-sync after the worker reports a completed reconnect. */
@@ -32,6 +32,9 @@ export interface GlassHomeState {
32
32
  connectionError: Error | null;
33
33
  /** Home Assistant base URL */
34
34
  hassUrl: string | null;
35
+ /** Same-origin prefix for the HA media proxy (e.g. "/ha-proxy/media"), or null
36
+ * to resolve media directly against hassUrl. Set by the host at connect. */
37
+ mediaProxyBase: string | null;
35
38
  /** Home Assistant core config (unit system, locale, currency); null until loaded */
36
39
  config: import("@glasshome/ha-types").HassConfig | null;
37
40
  /** Live entity state (keyed by entity_id) */
@@ -6,6 +6,7 @@ const initialState = {
6
6
  connectionState: "disconnected",
7
7
  connectionError: null,
8
8
  hassUrl: null,
9
+ mediaProxyBase: null,
9
10
  config: null,
10
11
  entities: {},
11
12
  entityRegistry: {},
package/dist/index.d.ts CHANGED
@@ -27,6 +27,7 @@ export type { CallServiceRequest, CoreEventType, Domain, EntityCategory, EntityR
27
27
  export { fetchStream, fetchStreamData } from "./camera/fetch";
28
28
  export { getStream, refreshStream } from "./camera/query";
29
29
  export { fetchEnergyPreferences } from "./energy/prefs";
30
+ export { hassMediaUrl } from "./media/url";
30
31
  export type { EnergyDeviceConsumption, EnergyPreferences, EnergySource, } from "./energy/prefs";
31
32
  export type { CameraStream, CameraStreamData, CameraStreams, EntityStreamQueryOptions, StreamFormat, StreamQueryOptions, StreamResult, } from "./camera/types";
32
33
  export { getWebRtcClientConfig, sendWebRtcCandidate, startWebRtcSession, } from "./camera/webrtc";
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
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 { runHaBridgeWorker } from "./worker-main-C93MR-AO.js";
1
+ import { ENERGY_ENTITY_IDS, createDemoFixtures, demoEnergyPreferences, energyEntityValue, formatEnergyState, isEnergyEntity, isSunUp, resetStore, setState, simulateEnergy, state, sunEvents, synthesizeEnergyHistory, synthesizeEnergyStatistics } from "./demo-data-DiGSP4EH.js";
2
+ import { runHaBridgeWorker } from "./worker-main-BYGgbA7k.js";
3
3
  import { createConnection, createLongLivedTokenAuth, getAuth } from "home-assistant-js-websocket";
4
4
  import { produce, reconcile } from "solid-js/store";
5
5
 
@@ -2298,6 +2298,30 @@ async function fetchEnergyPreferences(connection) {
2298
2298
  };
2299
2299
  }
2300
2300
 
2301
+ //#endregion
2302
+ //#region src/media/url.ts
2303
+ /**
2304
+ * Resolve an HA media reference (e.g. an entity's `entity_picture`, a camera
2305
+ * proxy path) to a URL the page can actually load.
2306
+ *
2307
+ * HA gives these as origin-relative paths (`/api/media_player_proxy/...?token=`)
2308
+ * carrying their own signed token. A bare `<img src>` would resolve them against
2309
+ * the dash origin and 404. When the host set `mediaProxyBase`, route through the
2310
+ * same-origin media proxy so the page CSP (`img-src 'self'`) covers it in every
2311
+ * mode, LAN and remote. Otherwise fall back to prefixing `hassUrl` directly
2312
+ * (the LAN-only path, used until a transport sets the proxy base).
2313
+ *
2314
+ * Absolute URLs and non-path values pass through untouched.
2315
+ */
2316
+ function hassMediaUrl(path) {
2317
+ if (!path) return void 0;
2318
+ if (/^https?:\/\//i.test(path)) return path;
2319
+ if (!path.startsWith("/")) return path;
2320
+ const base = state.mediaProxyBase;
2321
+ if (base) return `${base}?path=${encodeURIComponent(path)}`;
2322
+ return state.hassUrl ? `${state.hassUrl}${path}` : path;
2323
+ }
2324
+
2301
2325
  //#endregion
2302
2326
  //#region src/camera/webrtc.ts
2303
2327
  /**
@@ -2720,10 +2744,11 @@ async function refreshForecasts(entityId, types) {
2720
2744
  *
2721
2745
  * Call after bridge.connect() resolved. Returns when initial data is loaded.
2722
2746
  */
2723
- async function attachBridgeToStore(bridge, hassUrl) {
2747
+ async function attachBridgeToStore(bridge, hassUrl, mediaProxyBase) {
2724
2748
  setState(produce((s) => {
2725
2749
  s.conn = bridge.conn;
2726
2750
  s.hassUrl = hassUrl.replace(/\/$/, "");
2751
+ s.mediaProxyBase = mediaProxyBase ?? null;
2727
2752
  s.connectionState = "connected";
2728
2753
  s.connectionError = null;
2729
2754
  }));
@@ -2870,7 +2895,8 @@ function createHaBridge(worker, events = {}) {
2870
2895
  k: "connect",
2871
2896
  url: opts.url,
2872
2897
  mode: opts.mode,
2873
- proxyWsPath: opts.proxyWsPath
2898
+ proxyWsPath: opts.proxyWsPath,
2899
+ proxyTicket: opts.proxyTicket
2874
2900
  });
2875
2901
  });
2876
2902
  },
@@ -2918,4 +2944,4 @@ function createHaBridge(worker, events = {}) {
2918
2944
  const VERSION = "0.2.1";
2919
2945
 
2920
2946
  //#endregion
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 };
2947
+ 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, hassMediaUrl, 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,14 @@
1
+ /**
2
+ * Resolve an HA media reference (e.g. an entity's `entity_picture`, a camera
3
+ * proxy path) to a URL the page can actually load.
4
+ *
5
+ * HA gives these as origin-relative paths (`/api/media_player_proxy/...?token=`)
6
+ * carrying their own signed token. A bare `<img src>` would resolve them against
7
+ * the dash origin and 404. When the host set `mediaProxyBase`, route through the
8
+ * same-origin media proxy so the page CSP (`img-src 'self'`) covers it in every
9
+ * mode, LAN and remote. Otherwise fall back to prefixing `hassUrl` directly
10
+ * (the LAN-only path, used until a transport sets the proxy base).
11
+ *
12
+ * Absolute URLs and non-path values pass through untouched.
13
+ */
14
+ export declare function hassMediaUrl(path: string | null | undefined): string | undefined;
@@ -1,4 +1,4 @@
1
- import { createDemoFixtures, state } from "../demo-data-CavpxBw3.js";
1
+ import { createDemoFixtures, state } from "../demo-data-DiGSP4EH.js";
2
2
 
3
3
  //#region src/testing/mock-behaviors/base.ts
4
4
  /**
@@ -15,6 +15,7 @@ export interface BridgeConnectOptions {
15
15
  url: string;
16
16
  mode: AuthMode;
17
17
  proxyWsPath?: string;
18
+ proxyTicket?: string;
18
19
  }
19
20
  export interface BridgeEvents {
20
21
  onConnState?: (state: ConnState) => void;
@@ -31,8 +31,13 @@ export type MainToWorker = {
31
31
  k: "connect";
32
32
  url: string;
33
33
  mode: AuthMode;
34
- /** Same-origin WS path to use instead of the HA host (tunnel proxy). */
34
+ /** Proxy WS endpoint to use instead of the HA host. A path (web, resolved
35
+ * same-origin) or an absolute ws(s)/http(s) URL (native, where the worker
36
+ * origin is localhost, not dash-server). */
35
37
  proxyWsPath?: string;
38
+ /** Short-lived ticket authorizing the proxy WS + broker mint when a
39
+ * cross-origin cookie/bearer can't ride them (native). */
40
+ proxyTicket?: string;
36
41
  } | {
37
42
  k: "send";
38
43
  id: number;
@@ -1,3 +1,3 @@
1
- import { runHaBridgeWorker } from "../worker-main-C93MR-AO.js";
1
+ import { runHaBridgeWorker } from "../worker-main-BYGgbA7k.js";
2
2
 
3
3
  export { runHaBridgeWorker };
@@ -129,14 +129,18 @@ function isHaWireMessage(value) {
129
129
  return typeof value === "object" && value !== null && typeof value.type === "string";
130
130
  }
131
131
  var BrokeredAuth = class extends Auth {
132
- constructor(mintUrl, data) {
132
+ constructor(mintUrl, data, ticket) {
133
133
  super(data);
134
134
  this.mintUrl = mintUrl;
135
+ this.ticket = ticket;
135
136
  }
136
137
  async refreshAccessToken() {
137
138
  const res = await fetch(this.mintUrl, {
138
- credentials: "same-origin",
139
- headers: { accept: "application/json" }
139
+ credentials: "include",
140
+ headers: {
141
+ accept: "application/json",
142
+ ...this.ticket ? { "x-proxy-ticket": this.ticket } : {}
143
+ }
140
144
  });
141
145
  if (!res.ok) throw new Error(`Token broker responded ${res.status}`);
142
146
  if (!res.headers.get("content-type")?.includes("application/json")) throw new Error(`Token broker did not return JSON (is ${this.mintUrl} reachable?)`);
@@ -152,6 +156,19 @@ var BrokeredAuth = class extends Auth {
152
156
  };
153
157
  }
154
158
  };
159
+ /** Resolve the proxy WS endpoint. Accepts a same-origin path (web) or an
160
+ * absolute ws(s)/http(s) URL (native), and appends the auth ticket if given. */
161
+ function buildProxyWsUrl(scope, proxyWsPath, ticket) {
162
+ let base;
163
+ if (/^wss?:\/\//i.test(proxyWsPath)) base = proxyWsPath;
164
+ else if (/^https?:\/\//i.test(proxyWsPath)) base = proxyWsPath.replace(/^http/i, "ws");
165
+ else {
166
+ const proto = scope.location.protocol === "https:" ? "wss:" : "ws:";
167
+ base = `${proto}//${scope.location.host}${proxyWsPath}`;
168
+ }
169
+ if (ticket) base += `${base.includes("?") ? "&" : "?"}ticket=${encodeURIComponent(ticket)}`;
170
+ return base;
171
+ }
155
172
  function runHaBridgeWorker(scope) {
156
173
  let conn = null;
157
174
  const widgets = /* @__PURE__ */ new Map();
@@ -264,7 +281,7 @@ function runHaBridgeWorker(scope) {
264
281
  resumeVisible = null;
265
282
  }
266
283
  }
267
- async function buildAuth(url, mode) {
284
+ async function buildAuth(url, mode, ticket) {
268
285
  switch (mode.kind) {
269
286
  case "token": return new Auth({
270
287
  hassUrl: url,
@@ -281,7 +298,7 @@ function runHaBridgeWorker(scope) {
281
298
  refresh_token: "",
282
299
  expires: 0,
283
300
  expires_in: 0
284
- });
301
+ }, ticket);
285
302
  case "oauth": {
286
303
  const data = mode.data ?? await loadTokens();
287
304
  if (!data) return "needs-auth";
@@ -292,8 +309,8 @@ function runHaBridgeWorker(scope) {
292
309
  }
293
310
  }
294
311
  }
295
- async function connect(url, mode, proxyWsPath) {
296
- const auth = await buildAuth(url, mode);
312
+ async function connect(url, mode, proxyWsPath, proxyTicket) {
313
+ const auth = await buildAuth(url, mode, proxyTicket);
297
314
  if (auth === "needs-auth") {
298
315
  post({
299
316
  k: "connect_result",
@@ -312,9 +329,9 @@ function runHaBridgeWorker(scope) {
312
329
  });
313
330
  return;
314
331
  }
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) });
332
+ const proxyWsUrl = proxyWsPath ? buildProxyWsUrl(scope, proxyWsPath, proxyTicket) : null;
333
+ const socketFactory = proxyWsUrl ? (options) => {
334
+ const proxied = new Proxy(options.auth, { get: (target, prop) => prop === "wsUrl" ? proxyWsUrl : Reflect.get(target, prop) });
318
335
  return createSocket({
319
336
  ...options,
320
337
  auth: proxied
@@ -368,7 +385,7 @@ function runHaBridgeWorker(scope) {
368
385
  const msg = ev.data;
369
386
  switch (msg.k) {
370
387
  case "connect":
371
- await connect(msg.url, msg.mode, msg.proxyWsPath);
388
+ await connect(msg.url, msg.mode, msg.proxyWsPath, msg.proxyTicket);
372
389
  break;
373
390
  case "send": {
374
391
  if (!conn || !isHaWireMessage(msg.message)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glasshome/sync-layer",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Type-safe, reactive state synchronization layer for Home Assistant",
5
5
  "repository": {
6
6
  "type": "git",