@camstack/addon-export-alexa 1.2.22 → 1.2.23

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.
@@ -19376,7 +19376,16 @@ targets: array(object({
19376
19376
  /** A sleeping battery camera: the frame is deliberately stale and will
19377
19377
  * NOT refresh in the background. A surface should say so rather than
19378
19378
  * present it as current. */
19379
- sleeping: boolean()
19379
+ sleeping: boolean(),
19380
+ /** Current device state rendered over the cached frame. State images
19381
+ * remain authoritative even when their photographic background is
19382
+ * old; null means the link must carry a current camera frame. */
19383
+ stateReason: _enum([
19384
+ "disabled",
19385
+ "sleeping",
19386
+ "unreachable",
19387
+ "waking"
19388
+ ]).nullable()
19380
19389
  })));
19381
19390
  /**
19382
19391
  * `sso-bridge` — internal hub-only cap that lets SSO-style auth
@@ -34340,6 +34349,9 @@ function errorEnvelope(header, type, message) {
34340
34349
  * route to the right cap on the right device.
34341
34350
  */
34342
34351
  var discoveryPayloadSchema = DiscoveryDirectiveSchema.shape.payload;
34352
+ /** Devices built at once in `handleDiscovery` — latency-bound tRPC reads, so
34353
+ * six lanes hide the round-trips without stampeding a booting hub. */
34354
+ var DISCOVERY_BUILD_CONCURRENCY = 6;
34343
34355
  var CAP_DOORBELL = "doorbell";
34344
34356
  var CAP_INTERCOM = "intercom";
34345
34357
  var CAP_MOTION = "motion";
@@ -34352,16 +34364,28 @@ var CAP_PRIVACY_MASK = "privacy-mask";
34352
34364
  async function handleDiscovery(header, payloadRaw, deps, callerIdentity) {
34353
34365
  if (!discoveryPayloadSchema.safeParse(payloadRaw).success) return errorEnvelope(header, "INVALID_DIRECTIVE", "Discovery payload shape unexpected");
34354
34366
  const exposed = await deps.listExposed();
34355
- const endpoints = [];
34356
- for (const entry of exposed) try {
34357
- const built = await buildEndpointsForExposedDevice(entry.deviceId, deps);
34358
- endpoints.push(...built);
34359
- } catch (err) {
34360
- deps.logger.warn("alexa discovery (on-demand): skipping device — build failed", {
34361
- tags: { deviceId: entry.deviceId },
34362
- meta: { error: err instanceof Error ? err.message : String(err) }
34363
- });
34364
- }
34367
+ const built = new Array(exposed.length).fill(null);
34368
+ const queue = exposed.map((entry, index) => ({
34369
+ entry,
34370
+ index
34371
+ }));
34372
+ const lane = async () => {
34373
+ for (;;) {
34374
+ const item = queue.shift();
34375
+ if (item === void 0) return;
34376
+ try {
34377
+ built[item.index] = await buildEndpointsForExposedDevice(item.entry.deviceId, deps);
34378
+ } catch (err) {
34379
+ deps.logger.warn("alexa discovery (on-demand): skipping device — build failed", {
34380
+ tags: { deviceId: item.entry.deviceId },
34381
+ meta: { error: err instanceof Error ? err.message : String(err) }
34382
+ });
34383
+ }
34384
+ }
34385
+ };
34386
+ const lanes = Math.min(DISCOVERY_BUILD_CONCURRENCY, Math.max(1, queue.length));
34387
+ await Promise.all(Array.from({ length: lanes }, lane));
34388
+ const endpoints = built.flatMap((b) => b ?? []);
34365
34389
  deps.logger.info("alexa discovery returned endpoints", { meta: {
34366
34390
  count: endpoints.length,
34367
34391
  userId: callerIdentity.userId
@@ -36726,8 +36750,30 @@ function endpointsSignature(endpoints) {
36726
36750
  }
36727
36751
  //#endregion
36728
36752
  //#region src/sync-engine.ts
36753
+ /**
36754
+ * Devices reconciled at once in {@link SyncEngine.reconcileAllExposed}. Each
36755
+ * lane's device build is a handful of cross-process tRPC calls — latency, not
36756
+ * CPU — so four lanes hide most of it without turning a boot-time cap
36757
+ * registration burst into a second stampede.
36758
+ */
36759
+ var RECONCILE_ALL_CONCURRENCY = 4;
36729
36760
  var SyncEngine = class {
36730
36761
  deps;
36762
+ /**
36763
+ * Serializes the `recordEndpoints` read-modify-write. The build/push half of
36764
+ * a reconcile runs parallel across devices, but the persisted maps are
36765
+ * whole-object snapshots: two concurrent `get → mutate → set` cycles would
36766
+ * silently drop one device's ids/signature (last writer wins).
36767
+ */
36768
+ recordChain = Promise.resolve();
36769
+ /**
36770
+ * One in-flight reconcile per device. At boot the 60 s full pass and the
36771
+ * debounced bindings-change reconciles overlap, and each one rebuilt the
36772
+ * same device serially — the live hub built camera 615's endpoint shape
36773
+ * four times inside one boot (2026-08-15). Joining the running pass costs
36774
+ * the duplicate nothing.
36775
+ */
36776
+ reconcileFlights = /* @__PURE__ */ new Map();
36731
36777
  constructor(deps) {
36732
36778
  this.deps = deps;
36733
36779
  }
@@ -36838,6 +36884,15 @@ var SyncEngine = class {
36838
36884
  * re-push a shape Alexa already has.
36839
36885
  */
36840
36886
  async reconcileDevice(deviceId) {
36887
+ const inFlight = this.reconcileFlights.get(deviceId);
36888
+ if (inFlight) return inFlight;
36889
+ const flight = this.reconcileDeviceNow(deviceId).finally(() => {
36890
+ if (this.reconcileFlights.get(deviceId) === flight) this.reconcileFlights.delete(deviceId);
36891
+ });
36892
+ this.reconcileFlights.set(deviceId, flight);
36893
+ return flight;
36894
+ }
36895
+ async reconcileDeviceNow(deviceId) {
36841
36896
  if (!this.deps.listExposed().some((e) => e.deviceId === deviceId)) return {
36842
36897
  deviceId,
36843
36898
  pushed: false
@@ -36878,26 +36933,42 @@ var SyncEngine = class {
36878
36933
  async reconcileAllExposed() {
36879
36934
  const checkedDeviceIds = [];
36880
36935
  const pushedDeviceIds = [];
36881
- for (const { deviceId } of this.deps.listExposed()) {
36882
- checkedDeviceIds.push(deviceId);
36883
- if ((await this.reconcileDevice(deviceId)).pushed) pushedDeviceIds.push(deviceId);
36884
- }
36936
+ const queue = [...this.deps.listExposed()];
36937
+ const lane = async () => {
36938
+ for (;;) {
36939
+ const entry = queue.shift();
36940
+ if (entry === void 0) return;
36941
+ checkedDeviceIds.push(entry.deviceId);
36942
+ if ((await this.reconcileDevice(entry.deviceId)).pushed) pushedDeviceIds.push(entry.deviceId);
36943
+ }
36944
+ };
36945
+ const lanes = Math.min(RECONCILE_ALL_CONCURRENCY, Math.max(1, queue.length));
36946
+ await Promise.all(Array.from({ length: lanes }, lane));
36885
36947
  return {
36886
36948
  checkedDeviceIds,
36887
36949
  pushedDeviceIds
36888
36950
  };
36889
36951
  }
36890
36952
  /** Persist the endpoint ids AND the shape signature for a device.
36891
- * `signature === null` forgets both (delete / empty build). */
36953
+ * `signature === null` forgets both (delete / empty build).
36954
+ *
36955
+ * Serialized through `recordChain`: the maps are whole-object snapshots
36956
+ * (`get` → spread → `set`), so parallel reconciles would otherwise race —
36957
+ * two devices' writes computed off the same stale snapshot, the second
36958
+ * `set` erasing the first's entry. */
36892
36959
  async recordEndpoints(deviceId, ids, signature) {
36893
- const next = { ...this.deps.getExportedEndpoints() };
36894
- if (ids.length > 0) next[deviceId] = ids;
36895
- else delete next[deviceId];
36896
- await this.deps.setExportedEndpoints(next);
36897
- const nextSignatures = { ...this.deps.getExportedSignatures() };
36898
- if (signature !== null) nextSignatures[deviceId] = signature;
36899
- else delete nextSignatures[deviceId];
36900
- await this.deps.setExportedSignatures(nextSignatures);
36960
+ const write = this.recordChain.then(async () => {
36961
+ const next = { ...this.deps.getExportedEndpoints() };
36962
+ if (ids.length > 0) next[deviceId] = ids;
36963
+ else delete next[deviceId];
36964
+ await this.deps.setExportedEndpoints(next);
36965
+ const nextSignatures = { ...this.deps.getExportedSignatures() };
36966
+ if (signature !== null) nextSignatures[deviceId] = signature;
36967
+ else delete nextSignatures[deviceId];
36968
+ await this.deps.setExportedSignatures(nextSignatures);
36969
+ });
36970
+ this.recordChain = write.then(() => void 0, () => void 0);
36971
+ return write;
36901
36972
  }
36902
36973
  };
36903
36974
  //#endregion
@@ -19349,7 +19349,16 @@ targets: array(object({
19349
19349
  /** A sleeping battery camera: the frame is deliberately stale and will
19350
19350
  * NOT refresh in the background. A surface should say so rather than
19351
19351
  * present it as current. */
19352
- sleeping: boolean()
19352
+ sleeping: boolean(),
19353
+ /** Current device state rendered over the cached frame. State images
19354
+ * remain authoritative even when their photographic background is
19355
+ * old; null means the link must carry a current camera frame. */
19356
+ stateReason: _enum([
19357
+ "disabled",
19358
+ "sleeping",
19359
+ "unreachable",
19360
+ "waking"
19361
+ ]).nullable()
19353
19362
  })));
19354
19363
  /**
19355
19364
  * `sso-bridge` — internal hub-only cap that lets SSO-style auth
@@ -34313,6 +34322,9 @@ function errorEnvelope(header, type, message) {
34313
34322
  * route to the right cap on the right device.
34314
34323
  */
34315
34324
  var discoveryPayloadSchema = DiscoveryDirectiveSchema.shape.payload;
34325
+ /** Devices built at once in `handleDiscovery` — latency-bound tRPC reads, so
34326
+ * six lanes hide the round-trips without stampeding a booting hub. */
34327
+ var DISCOVERY_BUILD_CONCURRENCY = 6;
34316
34328
  var CAP_DOORBELL = "doorbell";
34317
34329
  var CAP_INTERCOM = "intercom";
34318
34330
  var CAP_MOTION = "motion";
@@ -34325,16 +34337,28 @@ var CAP_PRIVACY_MASK = "privacy-mask";
34325
34337
  async function handleDiscovery(header, payloadRaw, deps, callerIdentity) {
34326
34338
  if (!discoveryPayloadSchema.safeParse(payloadRaw).success) return errorEnvelope(header, "INVALID_DIRECTIVE", "Discovery payload shape unexpected");
34327
34339
  const exposed = await deps.listExposed();
34328
- const endpoints = [];
34329
- for (const entry of exposed) try {
34330
- const built = await buildEndpointsForExposedDevice(entry.deviceId, deps);
34331
- endpoints.push(...built);
34332
- } catch (err) {
34333
- deps.logger.warn("alexa discovery (on-demand): skipping device — build failed", {
34334
- tags: { deviceId: entry.deviceId },
34335
- meta: { error: err instanceof Error ? err.message : String(err) }
34336
- });
34337
- }
34340
+ const built = new Array(exposed.length).fill(null);
34341
+ const queue = exposed.map((entry, index) => ({
34342
+ entry,
34343
+ index
34344
+ }));
34345
+ const lane = async () => {
34346
+ for (;;) {
34347
+ const item = queue.shift();
34348
+ if (item === void 0) return;
34349
+ try {
34350
+ built[item.index] = await buildEndpointsForExposedDevice(item.entry.deviceId, deps);
34351
+ } catch (err) {
34352
+ deps.logger.warn("alexa discovery (on-demand): skipping device — build failed", {
34353
+ tags: { deviceId: item.entry.deviceId },
34354
+ meta: { error: err instanceof Error ? err.message : String(err) }
34355
+ });
34356
+ }
34357
+ }
34358
+ };
34359
+ const lanes = Math.min(DISCOVERY_BUILD_CONCURRENCY, Math.max(1, queue.length));
34360
+ await Promise.all(Array.from({ length: lanes }, lane));
34361
+ const endpoints = built.flatMap((b) => b ?? []);
34338
34362
  deps.logger.info("alexa discovery returned endpoints", { meta: {
34339
34363
  count: endpoints.length,
34340
34364
  userId: callerIdentity.userId
@@ -36699,8 +36723,30 @@ function endpointsSignature(endpoints) {
36699
36723
  }
36700
36724
  //#endregion
36701
36725
  //#region src/sync-engine.ts
36726
+ /**
36727
+ * Devices reconciled at once in {@link SyncEngine.reconcileAllExposed}. Each
36728
+ * lane's device build is a handful of cross-process tRPC calls — latency, not
36729
+ * CPU — so four lanes hide most of it without turning a boot-time cap
36730
+ * registration burst into a second stampede.
36731
+ */
36732
+ var RECONCILE_ALL_CONCURRENCY = 4;
36702
36733
  var SyncEngine = class {
36703
36734
  deps;
36735
+ /**
36736
+ * Serializes the `recordEndpoints` read-modify-write. The build/push half of
36737
+ * a reconcile runs parallel across devices, but the persisted maps are
36738
+ * whole-object snapshots: two concurrent `get → mutate → set` cycles would
36739
+ * silently drop one device's ids/signature (last writer wins).
36740
+ */
36741
+ recordChain = Promise.resolve();
36742
+ /**
36743
+ * One in-flight reconcile per device. At boot the 60 s full pass and the
36744
+ * debounced bindings-change reconciles overlap, and each one rebuilt the
36745
+ * same device serially — the live hub built camera 615's endpoint shape
36746
+ * four times inside one boot (2026-08-15). Joining the running pass costs
36747
+ * the duplicate nothing.
36748
+ */
36749
+ reconcileFlights = /* @__PURE__ */ new Map();
36704
36750
  constructor(deps) {
36705
36751
  this.deps = deps;
36706
36752
  }
@@ -36811,6 +36857,15 @@ var SyncEngine = class {
36811
36857
  * re-push a shape Alexa already has.
36812
36858
  */
36813
36859
  async reconcileDevice(deviceId) {
36860
+ const inFlight = this.reconcileFlights.get(deviceId);
36861
+ if (inFlight) return inFlight;
36862
+ const flight = this.reconcileDeviceNow(deviceId).finally(() => {
36863
+ if (this.reconcileFlights.get(deviceId) === flight) this.reconcileFlights.delete(deviceId);
36864
+ });
36865
+ this.reconcileFlights.set(deviceId, flight);
36866
+ return flight;
36867
+ }
36868
+ async reconcileDeviceNow(deviceId) {
36814
36869
  if (!this.deps.listExposed().some((e) => e.deviceId === deviceId)) return {
36815
36870
  deviceId,
36816
36871
  pushed: false
@@ -36851,26 +36906,42 @@ var SyncEngine = class {
36851
36906
  async reconcileAllExposed() {
36852
36907
  const checkedDeviceIds = [];
36853
36908
  const pushedDeviceIds = [];
36854
- for (const { deviceId } of this.deps.listExposed()) {
36855
- checkedDeviceIds.push(deviceId);
36856
- if ((await this.reconcileDevice(deviceId)).pushed) pushedDeviceIds.push(deviceId);
36857
- }
36909
+ const queue = [...this.deps.listExposed()];
36910
+ const lane = async () => {
36911
+ for (;;) {
36912
+ const entry = queue.shift();
36913
+ if (entry === void 0) return;
36914
+ checkedDeviceIds.push(entry.deviceId);
36915
+ if ((await this.reconcileDevice(entry.deviceId)).pushed) pushedDeviceIds.push(entry.deviceId);
36916
+ }
36917
+ };
36918
+ const lanes = Math.min(RECONCILE_ALL_CONCURRENCY, Math.max(1, queue.length));
36919
+ await Promise.all(Array.from({ length: lanes }, lane));
36858
36920
  return {
36859
36921
  checkedDeviceIds,
36860
36922
  pushedDeviceIds
36861
36923
  };
36862
36924
  }
36863
36925
  /** Persist the endpoint ids AND the shape signature for a device.
36864
- * `signature === null` forgets both (delete / empty build). */
36926
+ * `signature === null` forgets both (delete / empty build).
36927
+ *
36928
+ * Serialized through `recordChain`: the maps are whole-object snapshots
36929
+ * (`get` → spread → `set`), so parallel reconciles would otherwise race —
36930
+ * two devices' writes computed off the same stale snapshot, the second
36931
+ * `set` erasing the first's entry. */
36865
36932
  async recordEndpoints(deviceId, ids, signature) {
36866
- const next = { ...this.deps.getExportedEndpoints() };
36867
- if (ids.length > 0) next[deviceId] = ids;
36868
- else delete next[deviceId];
36869
- await this.deps.setExportedEndpoints(next);
36870
- const nextSignatures = { ...this.deps.getExportedSignatures() };
36871
- if (signature !== null) nextSignatures[deviceId] = signature;
36872
- else delete nextSignatures[deviceId];
36873
- await this.deps.setExportedSignatures(nextSignatures);
36933
+ const write = this.recordChain.then(async () => {
36934
+ const next = { ...this.deps.getExportedEndpoints() };
36935
+ if (ids.length > 0) next[deviceId] = ids;
36936
+ else delete next[deviceId];
36937
+ await this.deps.setExportedEndpoints(next);
36938
+ const nextSignatures = { ...this.deps.getExportedSignatures() };
36939
+ if (signature !== null) nextSignatures[deviceId] = signature;
36940
+ else delete nextSignatures[deviceId];
36941
+ await this.deps.setExportedSignatures(nextSignatures);
36942
+ });
36943
+ this.recordChain = write.then(() => void 0, () => void 0);
36944
+ return write;
36874
36945
  }
36875
36946
  };
36876
36947
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-export-alexa",
3
- "version": "1.2.22",
3
+ "version": "1.2.23",
4
4
  "description": "Alexa Smart Home Skill export — hub-side OAuth provider + directive handler. The companion AWS Lambda forwards Alexa directives to this addon's /addon/export-alexa/directive endpoint.",
5
5
  "keywords": [
6
6
  "camstack",