@camstack/system 1.2.66 → 1.2.68

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.
Files changed (55) hide show
  1. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  2. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  3. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/alerts/alerts.addon.js +1 -1
  6. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  7. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  8. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  9. package/dist/builtins/console-logging/index.js +1 -1
  10. package/dist/builtins/console-logging/index.mjs +1 -1
  11. package/dist/builtins/core-blocks/blocks-integration.d.ts +24 -0
  12. package/dist/builtins/core-blocks/core-blocks.addon.d.ts +14 -0
  13. package/dist/builtins/core-blocks/core-blocks.addon.js +99 -2
  14. package/dist/builtins/core-blocks/core-blocks.addon.mjs +99 -2
  15. package/dist/builtins/device-manager/device-manager.addon.js +3 -1
  16. package/dist/builtins/device-manager/device-manager.addon.mjs +3 -1
  17. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  18. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  19. package/dist/builtins/hub-forwarder/index.js +1 -1
  20. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  21. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  22. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  23. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  24. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  25. package/dist/builtins/local-network/local-network.addon.js +1 -1
  26. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  27. package/dist/builtins/loki-logging/index.js +1 -1
  28. package/dist/builtins/loki-logging/index.mjs +1 -1
  29. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  30. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  31. package/dist/builtins/platform-probe/index.js +1 -1
  32. package/dist/builtins/platform-probe/index.mjs +1 -1
  33. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  34. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  35. package/dist/builtins/snapshot/index.js +339 -2
  36. package/dist/builtins/snapshot/index.mjs +339 -2
  37. package/dist/builtins/snapshot/snapshot-link-url.d.ts +66 -0
  38. package/dist/builtins/snapshot/snapshot.addon.d.ts +79 -0
  39. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  40. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  41. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +1 -1
  42. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +1 -1
  43. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  44. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  45. package/dist/builtins/system-config/system-config.addon.js +1 -1
  46. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  47. package/dist/builtins/winston-logging/index.js +1 -1
  48. package/dist/builtins/winston-logging/index.mjs +1 -1
  49. package/dist/{dist-DqPyKwVB.mjs → dist-D2J8RJ7m.mjs} +1959 -1492
  50. package/dist/{dist-CIkGOAcz.js → dist-Dhpte164.js} +1965 -1486
  51. package/dist/index.js +28 -4
  52. package/dist/index.mjs +28 -4
  53. package/dist/kernel/cap-router-builder.d.ts +19 -0
  54. package/dist/kernel/capability-registry.d.ts +14 -1
  55. package/package.json +1 -1
@@ -3,7 +3,10 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-CIkGOAcz.js");
6
+ const require_dist = require("../../dist-Dhpte164.js");
7
+ let _camstack_types_node = require("@camstack/types/node");
8
+ let zod = require("zod");
9
+ let node_crypto = require("node:crypto");
7
10
  let node_child_process = require("node:child_process");
8
11
  //#region src/builtins/snapshot/snapshot-coalescing.ts
9
12
  /**
@@ -645,7 +648,146 @@ var SnapshotSubscriptions = class {
645
648
  }
646
649
  };
647
650
  //#endregion
651
+ //#region src/builtins/snapshot/snapshot-link-url.ts
652
+ /**
653
+ * Signed, expiring links to a CLIENT-SIZED snapshot frame.
654
+ *
655
+ * ## Why a link plane exists at all
656
+ *
657
+ * The authenticated `/addon/snapshot/media/<id>.jpg` plane works, and it is not
658
+ * going away. What it cannot do is guarantee that a client asking for a tile
659
+ * actually REACHES the server — and that turned out to be the whole bug.
660
+ *
661
+ * Under D93 the image URL is versioned by the frame identity, and an image
662
+ * request is what enrols a camera in the keep-warm loop. Both halves are
663
+ * satisfied by the client's own image cache: `expo-image` is URL-keyed and never
664
+ * revalidates, so a URL the app painted in a previous session is served from
665
+ * disk with **zero network**. Measured on the live hub, reopening the app after
666
+ * two minutes idle painted 15 of 16 tiles from disk — frames **168 s old**, with
667
+ * not one HTTP request, therefore not one enrolment, therefore nothing warming.
668
+ * The operator's report ("gli snapshot sono vecchi, devo aggiornare più volte")
669
+ * is that measurement.
670
+ *
671
+ * A minted link breaks the loop from both ends. It is produced by an RPC —
672
+ * `snapshot.getSnapshotLinks` — which no image cache can answer, so the demand
673
+ * signal always lands; and it carries the capture identity the RPC just WAITED
674
+ * for, rather than one a cache-only poll happened to be holding.
675
+ *
676
+ * ## What is signed, and what is only a cache key
677
+ *
678
+ * The signature covers `"<deviceId>:<width>"` and the expiry. The width is
679
+ * inside it deliberately: a leaked 240 px tile link must not be replayable as a
680
+ * request for the full 4 K frame. `v` (the capture identity) is NOT signed — it
681
+ * exists only to key the client's image cache, so an unchanged frame is a
682
+ * byte-identical URL and costs no bytes, and a new frame is a new URL and costs
683
+ * exactly one fetch.
684
+ *
685
+ * `exp` is bucketed rather than exact. A URL that were unique per mint would
686
+ * defeat the client cache completely — correct for freshness, and it would make
687
+ * a phone re-download every tile on every 5 s poll whether or not anything
688
+ * changed. Bucketing means the URL moves when the FRAME moves, and otherwise at
689
+ * most once per bucket.
690
+ *
691
+ * Pure and side-effect-free; the addon owns the secret and the clock. Unit
692
+ * tested in `__tests__/snapshot-link-url.spec.ts`.
693
+ */
694
+ /**
695
+ * How long a minted link stays valid.
696
+ *
697
+ * A snapshot is a live view of the operator's home, so this is short on purpose
698
+ * — the exposure of a leaked link is bounded by it. Two minutes is long enough
699
+ * that a page renders, re-renders and survives a brief backgrounding on the URL
700
+ * it was given, and short enough that a link pasted somewhere is dead before it
701
+ * is useful. The client re-mints on every overview poll (5 s), so it never
702
+ * depends on the tail of this window.
703
+ */
704
+ var SNAPSHOT_LINK_TTL_MS = 12e4;
705
+ /**
706
+ * Quantum the expiry is rounded UP to, so a link is stable between mints.
707
+ *
708
+ * Without it every mint produces a different `exp`, hence a different URL, hence
709
+ * a full re-download of an unchanged frame on every poll. With it the URL is a
710
+ * pure function of (device, width, frame, bucket) — so a tile fetches when its
711
+ * frame moves, and at most once more per bucket.
712
+ */
713
+ var SNAPSHOT_LINK_EXP_BUCKET_MS = 3e4;
714
+ /** The token the signature is computed over. Width is part of the identity so a
715
+ * tile link cannot be escalated into a full-frame request. */
716
+ function snapshotLinkId(deviceId, width) {
717
+ return `${String(deviceId)}:${width === void 0 ? "full" : String(width)}`;
718
+ }
719
+ /** The bucketed expiry for a link minted at `nowMs`. Always ≥ `nowMs + TTL`. */
720
+ function snapshotLinkExpiry(nowMs, ttlMs = SNAPSHOT_LINK_TTL_MS, bucketMs = SNAPSHOT_LINK_EXP_BUCKET_MS) {
721
+ return Math.ceil((nowMs + ttlMs) / bucketMs) * bucketMs;
722
+ }
723
+ /**
724
+ * The link, as a ROOT-RELATIVE path.
725
+ *
726
+ * Deliberately not absolute. The artifact and HA planes must mint absolute URLs
727
+ * because the fetcher is a phone or a notifier backend that has no idea where
728
+ * the hub is — and picking that base is the `hubUrl: localhost` trap the Alexa
729
+ * work paid for. Here the fetcher is a client that is already connected to the
730
+ * hub and holds its own `serverUrl`, so the correct base is the one it used to
731
+ * make the call. Returning a path makes it impossible to hand a client a link
732
+ * pointing somewhere it cannot reach.
733
+ */
734
+ function buildSnapshotLinkUrl(input) {
735
+ const base = (input.routePrefix.startsWith("/") ? input.routePrefix : `/${input.routePrefix}`).replace(/\/+$/, "");
736
+ const id = snapshotLinkId(input.deviceId, input.width);
737
+ const sig = (0, _camstack_types_node.signExpiringUrl)(input.secret, id, input.expMs);
738
+ const params = new URLSearchParams();
739
+ if (input.width !== void 0) params.set("w", String(input.width));
740
+ if (input.capturedAt !== null) params.set("v", String(input.capturedAt));
741
+ params.set("exp", String(input.expMs));
742
+ params.set("sig", sig);
743
+ return `${base}/${String(input.deviceId)}.jpg?${params.toString()}`;
744
+ }
745
+ /**
746
+ * Parse and VERIFY a link request in one step, so a caller cannot accidentally
747
+ * use the device id before checking the signature. Null = refuse (404/403);
748
+ * there is deliberately no way to distinguish "bad signature" from "expired"
749
+ * from "malformed" at this boundary, so a public route cannot be probed.
750
+ */
751
+ function parseVerifiedSnapshotLink(input) {
752
+ const qIdx = input.url.indexOf("?");
753
+ const rawPath = qIdx === -1 ? input.url : input.url.slice(0, qIdx);
754
+ const query = qIdx === -1 ? "" : input.url.slice(qIdx + 1);
755
+ const segment = rawPath.replace(/^\/+/, "");
756
+ if (segment.length === 0 || segment.includes("/")) return null;
757
+ const idPart = segment.replace(/\.jpe?g$/i, "");
758
+ if (!/^\d+$/.test(idPart)) return null;
759
+ const deviceId = Number.parseInt(idPart, 10);
760
+ if (!Number.isSafeInteger(deviceId) || deviceId <= 0) return null;
761
+ const params = new URLSearchParams(query);
762
+ const rawWidth = params.get("w");
763
+ if (rawWidth !== null && !/^\d+$/.test(rawWidth)) return null;
764
+ const width = rawWidth === null ? void 0 : Number.parseInt(rawWidth, 10);
765
+ if (width !== void 0 && (!Number.isSafeInteger(width) || width <= 0)) return null;
766
+ return (0, _camstack_types_node.verifyExpiringUrl)({
767
+ secret: input.secret,
768
+ id: snapshotLinkId(deviceId, width),
769
+ exp: params.get("exp") ?? void 0,
770
+ sig: params.get("sig") ?? void 0,
771
+ nowMs: input.nowMs
772
+ }) ? {
773
+ deviceId,
774
+ width
775
+ } : null;
776
+ }
777
+ //#endregion
648
778
  //#region src/builtins/snapshot/snapshot.addon.ts
779
+ /**
780
+ * How long `getSnapshotLinks` waits for the capture it triggered before minting
781
+ * the link over whatever frame it has.
782
+ *
783
+ * Sized against the paint budget, not against the slowest camera. A cold fleet
784
+ * is paced by `MAX_CONCURRENT_GRABS`, so waiting for the last of sixteen would
785
+ * hold the whole grid for tens of seconds — and the frame that lands late is not
786
+ * lost, it populates the cache and the client's next 5 s poll picks it up. The
787
+ * value matches `SWR_STALE_WAIT_MS`'s intent: long enough that a warm broker
788
+ * answers inside it, short enough that a dead camera cannot stall the batch.
789
+ */
790
+ var LINK_MINT_DEADLINE_MS = 2500;
649
791
  /** Default cache window for non-battery cams (seconds). 10s feels live. */
650
792
  var NON_BATTERY_DEFAULT_MAX_AGE_S = 10;
651
793
  /** Default cache window for battery cams (seconds). 1h ≈ "don't wake the cam unless asked". */
@@ -712,6 +854,17 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
712
854
  */
713
855
  mediaDataPlane = null;
714
856
  /**
857
+ * The PUBLIC, signature-authenticated twin of the media plane. Null when the
858
+ * host has no `ctx.dataPlane` facility, or before the per-install secret has
859
+ * been minted — in which case `getSnapshotLinks` reports `url: null` and the
860
+ * client keeps using the authenticated plane exactly as before.
861
+ */
862
+ linkDataPlane = null;
863
+ /** Per-install HMAC secret for link signatures. Minted once and persisted: a
864
+ * secret that rolled on every boot would invalidate every link a client is
865
+ * currently holding, which is a fleet-wide blank grid after each restart. */
866
+ linkSecret = null;
867
+ /**
715
868
  * Who is being looked at, inferred from data-plane traffic. The keep-warm
716
869
  * loop's entire bound — see `snapshot-keep-warm.ts`.
717
870
  */
@@ -743,9 +896,11 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
743
896
  getDeviceLiveContribution: async () => null,
744
897
  applyDeviceSettingsPatch: (input) => this.saveDeviceSettingsPatch(input.deviceId, input.patch),
745
898
  getStatus: async (input) => this.getStatus(input.deviceId),
746
- getSnapshotOverview: (input) => this.getSnapshotOverview(input)
899
+ getSnapshotOverview: (input) => this.getSnapshotOverview(input),
900
+ getSnapshotLinks: (input) => this.getSnapshotLinks(input)
747
901
  };
748
902
  await this.serveMediaDataPlane();
903
+ await this.serveLinkDataPlane();
749
904
  this.scheduleWarmTick();
750
905
  return [{
751
906
  capability: require_dist.snapshotCapability,
@@ -779,6 +934,184 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
779
934
  }
780
935
  }
781
936
  /**
937
+ * Serve the PUBLIC link plane: `GET /addon/snapshot/link/<deviceId>.jpg?w&v&exp&sig`.
938
+ *
939
+ * `access: 'public'` is deliberate and is the only interesting decision here.
940
+ * The authority is the HMAC, exactly as for the notification artifact plane
941
+ * and the Home Assistant media plane — but unlike those, what sits behind it
942
+ * is a LIVE view of the operator's home, so the compensating choices are
943
+ * explicit: a short TTL (`SNAPSHOT_LINK_TTL_MS`), the width inside the signed
944
+ * identity so a leaked tile cannot be escalated to the full frame, and
945
+ * **`force` is not honoured on this route at all** — a public URL must never
946
+ * be able to wake a battery camera.
947
+ *
948
+ * A leaked link therefore grants: one camera, at one thumbnail width, for at
949
+ * most the TTL. No token, no other device, no session.
950
+ */
951
+ async serveLinkDataPlane() {
952
+ try {
953
+ const secret = await this.ensureLinkSecret();
954
+ if (secret === null) return;
955
+ const prefix = this.linkRoutePrefix();
956
+ const handler = createSnapshotMediaHandler({
957
+ getMedia: (deviceId, streamId, _force, width) => this.resolveSnapshotMedia(deviceId, streamId, false, width),
958
+ peekFresh: (deviceId, streamId) => this.peekFreshFrame(deviceId, streamId)
959
+ });
960
+ this.linkDataPlane = await this.ctx.dataPlane?.serve({
961
+ prefix: "link",
962
+ access: "public",
963
+ handler: (req, res) => this.handleLinkRequest(req, res, secret, handler)
964
+ }) ?? null;
965
+ this.ctx.logger.info("snapshot link data-plane served", { meta: {
966
+ served: this.linkDataPlane !== null,
967
+ prefix
968
+ } });
969
+ } catch (err) {
970
+ this.ctx.logger.warn("snapshot link data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
971
+ this.linkDataPlane = null;
972
+ }
973
+ }
974
+ /**
975
+ * Verify first, then hand the request to the ordinary media handler with a
976
+ * REWRITTEN url carrying only the signed `(deviceId, width)`.
977
+ *
978
+ * Rewriting rather than passing the original through is what stops an unsigned
979
+ * query parameter reaching the capture path: `force`, `streamId` and any other
980
+ * lever the authenticated plane honours are simply not present in what the
981
+ * handler parses.
982
+ */
983
+ async handleLinkRequest(req, res, secret, inner) {
984
+ const verified = parseVerifiedSnapshotLink({
985
+ url: req.url ?? "/",
986
+ secret,
987
+ nowMs: Date.now()
988
+ });
989
+ if (verified === null) {
990
+ res.writeHead(404).end();
991
+ return;
992
+ }
993
+ const rewritten = `/${String(verified.deviceId)}.jpg${verified.width === void 0 ? "" : `?w=${String(verified.width)}`}`;
994
+ const original = req.url;
995
+ try {
996
+ req.url = rewritten;
997
+ await inner(req, res);
998
+ } finally {
999
+ req.url = original;
1000
+ }
1001
+ }
1002
+ linkRoutePrefix() {
1003
+ return `/addon/${require_dist.bareAddonId(this.ctx.id)}/link`;
1004
+ }
1005
+ /** Mint-once-and-persist the signing secret. Null if it cannot be stored — a
1006
+ * secret held only in memory would break every outstanding link on restart,
1007
+ * so no plane is better than a plane whose links die unpredictably. */
1008
+ async ensureLinkSecret() {
1009
+ if (this.linkSecret !== null) return this.linkSecret;
1010
+ try {
1011
+ const state = this.state("snapshotLinkSecret", zod.z.string(), "");
1012
+ let secret = await state.get();
1013
+ if (secret === "") {
1014
+ secret = (0, node_crypto.randomUUID)().replace(/-/g, "");
1015
+ await state.set(secret);
1016
+ }
1017
+ this.linkSecret = secret;
1018
+ return secret;
1019
+ } catch (err) {
1020
+ this.ctx.logger.warn("snapshot: could not establish link signing secret", { meta: { error: require_dist.errMsg(err) } });
1021
+ return null;
1022
+ }
1023
+ }
1024
+ /**
1025
+ * Mint signed links for the tiles a surface is rendering — and, in the same
1026
+ * call, make those tiles CURRENT.
1027
+ *
1028
+ * This is the demand signal the design was missing. Under D93 enrolment rode
1029
+ * on the image request, and D93/D94/D95 each fixed a different way for that
1030
+ * request not to happen; none of them could fix the last one, because the
1031
+ * client's own image cache answers a repeated URL with no network at all. An
1032
+ * RPC has no such cache in front of it.
1033
+ *
1034
+ * So: note every target (creating a subscription, which `getSnapshotOverview`
1035
+ * deliberately cannot), then join the ordinary non-forced capture path and
1036
+ * wait a BOUNDED time for it. What comes back is the identity of the frame the
1037
+ * link will actually serve.
1038
+ *
1039
+ * The bound matters more than its exact value. A cold fleet is paced by
1040
+ * `MAX_CONCURRENT_GRABS`, so sixteen cameras cannot all capture at once and
1041
+ * the last of them would hold the whole batch well past any sensible paint
1042
+ * budget. Devices that miss the bound return their real, older `capturedAt` —
1043
+ * honest rather than blocking — and the client's next poll carries them
1044
+ * forward.
1045
+ */
1046
+ async getSnapshotLinks(input) {
1047
+ const secret = await this.ensureLinkSecret();
1048
+ const now = Date.now();
1049
+ const expMs = snapshotLinkExpiry(now, SNAPSHOT_LINK_TTL_MS);
1050
+ const served = this.linkDataPlane !== null && secret !== null;
1051
+ const prefix = this.linkRoutePrefix();
1052
+ const rows = await Promise.all(input.targets.map(async (target) => {
1053
+ const { deviceId } = target;
1054
+ const width = target.width === void 0 ? void 0 : snapSnapshotWidth(target.width);
1055
+ this.subscriptions.note(deviceId, void 0, width, now);
1056
+ const sleeping = await this.isSleepingBatteryDevice(deviceId);
1057
+ if (!sleeping) await this.awaitFreshEnough(deviceId);
1058
+ const capturedAt = this.cache.latest(deviceId)?.ts ?? null;
1059
+ const answeredAt = Date.now();
1060
+ return {
1061
+ deviceId,
1062
+ url: served && secret !== null ? buildSnapshotLinkUrl({
1063
+ routePrefix: prefix,
1064
+ deviceId,
1065
+ width,
1066
+ capturedAt,
1067
+ secret,
1068
+ expMs
1069
+ }) : null,
1070
+ capturedAt,
1071
+ ageMs: capturedAt === null ? null : answeredAt - capturedAt,
1072
+ expiresAt: served ? expMs : null,
1073
+ width: width ?? null,
1074
+ neverCaptured: capturedAt === null,
1075
+ sleeping
1076
+ };
1077
+ }));
1078
+ this.ctx.logger.debug("snapshot: minted links", { meta: {
1079
+ targets: input.targets.length,
1080
+ served,
1081
+ stale: rows.filter((r) => r.ageMs !== null && r.ageMs > 15e3).length
1082
+ } });
1083
+ return rows;
1084
+ }
1085
+ /**
1086
+ * Join the ordinary capture path for one device and wait, bounded, for it.
1087
+ *
1088
+ * Deliberately `getSnapshot` with `force: false` and nothing else: every gate
1089
+ * that already owns this decision — the per-device max-age, the single flight,
1090
+ * the grab pool, the sleeping-battery refusal — applies unchanged. A fresh
1091
+ * cache returns immediately; a stale one starts the refresh. There is no
1092
+ * second freshness policy here, which is the mistake D93 called out.
1093
+ */
1094
+ async awaitFreshEnough(deviceId) {
1095
+ if (!(await raceForResult(this.getSnapshot({
1096
+ deviceId,
1097
+ force: false
1098
+ }).catch(() => null), LINK_MINT_DEADLINE_MS)).settled) this.ctx.logger.debug("snapshot: link mint gave up waiting; serving the older frame", {
1099
+ tags: { deviceId },
1100
+ meta: { deadlineMs: LINK_MINT_DEADLINE_MS }
1101
+ });
1102
+ }
1103
+ /** True only for a BATTERY device that is currently asleep. Kept as one
1104
+ * question so the link path cannot accidentally treat "battery" as "asleep"
1105
+ * and stop refreshing a camera that is awake and streaming. */
1106
+ async isSleepingBatteryDevice(deviceId) {
1107
+ try {
1108
+ if ((await this.lookupDeviceMeta(deviceId))?.isBattery !== true) return false;
1109
+ return await this.isDeviceSleeping(deviceId);
1110
+ } catch {
1111
+ return false;
1112
+ }
1113
+ }
1114
+ /**
782
1115
  * Resolve a device (+ optional stream, + force) to renderable JPEG bytes for
783
1116
  * the HTTP endpoint. Runs the same coalesced capture ladder as the tRPC
784
1117
  * `getSnapshot` (so every consumer shares one upstream pull), then decodes the
@@ -975,6 +1308,10 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
975
1308
  await this.mediaDataPlane.dispose().catch(() => void 0);
976
1309
  this.mediaDataPlane = null;
977
1310
  }
1311
+ if (this.linkDataPlane) {
1312
+ await this.linkDataPlane.dispose().catch(() => void 0);
1313
+ this.linkDataPlane = null;
1314
+ }
978
1315
  this.cache.clear();
979
1316
  this.variants.clear();
980
1317
  this.captureFlight.clear();