@buoy-gg/assets 7.0.16

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 (179) hide show
  1. package/LICENSE +58 -0
  2. package/README.md +55 -0
  3. package/lib/commonjs/capture/expo.js +46 -0
  4. package/lib/commonjs/capture/expo.js.map +1 -0
  5. package/lib/commonjs/capture/install.js +78 -0
  6. package/lib/commonjs/capture/install.js.map +1 -0
  7. package/lib/commonjs/capture/measure.js +186 -0
  8. package/lib/commonjs/capture/measure.js.map +1 -0
  9. package/lib/commonjs/capture/metroGraph.js +148 -0
  10. package/lib/commonjs/capture/metroGraph.js.map +1 -0
  11. package/lib/commonjs/capture/registry.js +124 -0
  12. package/lib/commonjs/capture/registry.js.map +1 -0
  13. package/lib/commonjs/components/AssetDetail.js +205 -0
  14. package/lib/commonjs/components/AssetDetail.js.map +1 -0
  15. package/lib/commonjs/components/AssetRow.js +246 -0
  16. package/lib/commonjs/components/AssetRow.js.map +1 -0
  17. package/lib/commonjs/components/AssetsFilterBar.js +92 -0
  18. package/lib/commonjs/components/AssetsFilterBar.js.map +1 -0
  19. package/lib/commonjs/components/AssetsHeader.js +66 -0
  20. package/lib/commonjs/components/AssetsHeader.js.map +1 -0
  21. package/lib/commonjs/components/AssetsIcon.js +19 -0
  22. package/lib/commonjs/components/AssetsIcon.js.map +1 -0
  23. package/lib/commonjs/components/AssetsListControls.js +94 -0
  24. package/lib/commonjs/components/AssetsListControls.js.map +1 -0
  25. package/lib/commonjs/components/AssetsModal.js +338 -0
  26. package/lib/commonjs/components/AssetsModal.js.map +1 -0
  27. package/lib/commonjs/components/BaselineBar.js +110 -0
  28. package/lib/commonjs/components/BaselineBar.js.map +1 -0
  29. package/lib/commonjs/export.js +62 -0
  30. package/lib/commonjs/export.js.map +1 -0
  31. package/lib/commonjs/format.js +59 -0
  32. package/lib/commonjs/format.js.map +1 -0
  33. package/lib/commonjs/hooks/useAssetRecords.js +22 -0
  34. package/lib/commonjs/hooks/useAssetRecords.js.map +1 -0
  35. package/lib/commonjs/index.js +260 -0
  36. package/lib/commonjs/index.js.map +1 -0
  37. package/lib/commonjs/package.json +1 -0
  38. package/lib/commonjs/preset.js +45 -0
  39. package/lib/commonjs/preset.js.map +1 -0
  40. package/lib/commonjs/remote.js +65 -0
  41. package/lib/commonjs/remote.js.map +1 -0
  42. package/lib/commonjs/state.js +51 -0
  43. package/lib/commonjs/state.js.map +1 -0
  44. package/lib/commonjs/store/assetStore.js +234 -0
  45. package/lib/commonjs/store/assetStore.js.map +1 -0
  46. package/lib/commonjs/store/baseline.js +165 -0
  47. package/lib/commonjs/store/baseline.js.map +1 -0
  48. package/lib/commonjs/store/insights.js +129 -0
  49. package/lib/commonjs/store/insights.js.map +1 -0
  50. package/lib/commonjs/sync/assetsSyncAdapter.js +157 -0
  51. package/lib/commonjs/sync/assetsSyncAdapter.js.map +1 -0
  52. package/lib/commonjs/types.js +2 -0
  53. package/lib/commonjs/types.js.map +1 -0
  54. package/lib/module/capture/expo.js +41 -0
  55. package/lib/module/capture/expo.js.map +1 -0
  56. package/lib/module/capture/install.js +72 -0
  57. package/lib/module/capture/install.js.map +1 -0
  58. package/lib/module/capture/measure.js +179 -0
  59. package/lib/module/capture/measure.js.map +1 -0
  60. package/lib/module/capture/metroGraph.js +141 -0
  61. package/lib/module/capture/metroGraph.js.map +1 -0
  62. package/lib/module/capture/registry.js +117 -0
  63. package/lib/module/capture/registry.js.map +1 -0
  64. package/lib/module/components/AssetDetail.js +201 -0
  65. package/lib/module/components/AssetDetail.js.map +1 -0
  66. package/lib/module/components/AssetRow.js +242 -0
  67. package/lib/module/components/AssetRow.js.map +1 -0
  68. package/lib/module/components/AssetsFilterBar.js +88 -0
  69. package/lib/module/components/AssetsFilterBar.js.map +1 -0
  70. package/lib/module/components/AssetsHeader.js +62 -0
  71. package/lib/module/components/AssetsHeader.js.map +1 -0
  72. package/lib/module/components/AssetsIcon.js +12 -0
  73. package/lib/module/components/AssetsIcon.js.map +1 -0
  74. package/lib/module/components/AssetsListControls.js +90 -0
  75. package/lib/module/components/AssetsListControls.js.map +1 -0
  76. package/lib/module/components/AssetsModal.js +333 -0
  77. package/lib/module/components/AssetsModal.js.map +1 -0
  78. package/lib/module/components/BaselineBar.js +106 -0
  79. package/lib/module/components/BaselineBar.js.map +1 -0
  80. package/lib/module/export.js +57 -0
  81. package/lib/module/export.js.map +1 -0
  82. package/lib/module/format.js +51 -0
  83. package/lib/module/format.js.map +1 -0
  84. package/lib/module/hooks/useAssetRecords.js +18 -0
  85. package/lib/module/hooks/useAssetRecords.js.map +1 -0
  86. package/lib/module/index.js +59 -0
  87. package/lib/module/index.js.map +1 -0
  88. package/lib/module/preset.js +40 -0
  89. package/lib/module/preset.js.map +1 -0
  90. package/lib/module/remote.js +58 -0
  91. package/lib/module/remote.js.map +1 -0
  92. package/lib/module/state.js +47 -0
  93. package/lib/module/state.js.map +1 -0
  94. package/lib/module/store/assetStore.js +219 -0
  95. package/lib/module/store/assetStore.js.map +1 -0
  96. package/lib/module/store/baseline.js +157 -0
  97. package/lib/module/store/baseline.js.map +1 -0
  98. package/lib/module/store/insights.js +125 -0
  99. package/lib/module/store/insights.js.map +1 -0
  100. package/lib/module/sync/assetsSyncAdapter.js +153 -0
  101. package/lib/module/sync/assetsSyncAdapter.js.map +1 -0
  102. package/lib/module/types.js +2 -0
  103. package/lib/module/types.js.map +1 -0
  104. package/lib/typescript/capture/expo.d.ts +18 -0
  105. package/lib/typescript/capture/expo.d.ts.map +1 -0
  106. package/lib/typescript/capture/install.d.ts +17 -0
  107. package/lib/typescript/capture/install.d.ts.map +1 -0
  108. package/lib/typescript/capture/measure.d.ts +38 -0
  109. package/lib/typescript/capture/measure.d.ts.map +1 -0
  110. package/lib/typescript/capture/metroGraph.d.ts +33 -0
  111. package/lib/typescript/capture/metroGraph.d.ts.map +1 -0
  112. package/lib/typescript/capture/registry.d.ts +45 -0
  113. package/lib/typescript/capture/registry.d.ts.map +1 -0
  114. package/lib/typescript/components/AssetDetail.d.ts +17 -0
  115. package/lib/typescript/components/AssetDetail.d.ts.map +1 -0
  116. package/lib/typescript/components/AssetRow.d.ts +32 -0
  117. package/lib/typescript/components/AssetRow.d.ts.map +1 -0
  118. package/lib/typescript/components/AssetsFilterBar.d.ts +16 -0
  119. package/lib/typescript/components/AssetsFilterBar.d.ts.map +1 -0
  120. package/lib/typescript/components/AssetsHeader.d.ts +22 -0
  121. package/lib/typescript/components/AssetsHeader.d.ts.map +1 -0
  122. package/lib/typescript/components/AssetsIcon.d.ts +9 -0
  123. package/lib/typescript/components/AssetsIcon.d.ts.map +1 -0
  124. package/lib/typescript/components/AssetsListControls.d.ts +18 -0
  125. package/lib/typescript/components/AssetsListControls.d.ts.map +1 -0
  126. package/lib/typescript/components/AssetsModal.d.ts +24 -0
  127. package/lib/typescript/components/AssetsModal.d.ts.map +1 -0
  128. package/lib/typescript/components/BaselineBar.d.ts +13 -0
  129. package/lib/typescript/components/BaselineBar.d.ts.map +1 -0
  130. package/lib/typescript/export.d.ts +8 -0
  131. package/lib/typescript/export.d.ts.map +1 -0
  132. package/lib/typescript/format.d.ts +13 -0
  133. package/lib/typescript/format.d.ts.map +1 -0
  134. package/lib/typescript/hooks/useAssetRecords.d.ts +11 -0
  135. package/lib/typescript/hooks/useAssetRecords.d.ts.map +1 -0
  136. package/lib/typescript/index.d.ts +26 -0
  137. package/lib/typescript/index.d.ts.map +1 -0
  138. package/lib/typescript/preset.d.ts +38 -0
  139. package/lib/typescript/preset.d.ts.map +1 -0
  140. package/lib/typescript/remote.d.ts +17 -0
  141. package/lib/typescript/remote.d.ts.map +1 -0
  142. package/lib/typescript/state.d.ts +41 -0
  143. package/lib/typescript/state.d.ts.map +1 -0
  144. package/lib/typescript/store/assetStore.d.ts +51 -0
  145. package/lib/typescript/store/assetStore.d.ts.map +1 -0
  146. package/lib/typescript/store/baseline.d.ts +22 -0
  147. package/lib/typescript/store/baseline.d.ts.map +1 -0
  148. package/lib/typescript/store/insights.d.ts +7 -0
  149. package/lib/typescript/store/insights.d.ts.map +1 -0
  150. package/lib/typescript/sync/assetsSyncAdapter.d.ts +83 -0
  151. package/lib/typescript/sync/assetsSyncAdapter.d.ts.map +1 -0
  152. package/lib/typescript/types.d.ts +162 -0
  153. package/lib/typescript/types.d.ts.map +1 -0
  154. package/package.json +77 -0
  155. package/src/capture/expo.ts +45 -0
  156. package/src/capture/install.ts +73 -0
  157. package/src/capture/measure.ts +167 -0
  158. package/src/capture/metroGraph.ts +146 -0
  159. package/src/capture/registry.ts +134 -0
  160. package/src/components/AssetDetail.tsx +207 -0
  161. package/src/components/AssetRow.tsx +243 -0
  162. package/src/components/AssetsFilterBar.tsx +99 -0
  163. package/src/components/AssetsHeader.tsx +75 -0
  164. package/src/components/AssetsIcon.tsx +9 -0
  165. package/src/components/AssetsListControls.tsx +101 -0
  166. package/src/components/AssetsModal.tsx +368 -0
  167. package/src/components/BaselineBar.tsx +98 -0
  168. package/src/export.ts +108 -0
  169. package/src/format.ts +53 -0
  170. package/src/hooks/useAssetRecords.ts +26 -0
  171. package/src/index.tsx +87 -0
  172. package/src/preset.tsx +35 -0
  173. package/src/remote.ts +65 -0
  174. package/src/state.ts +84 -0
  175. package/src/store/assetStore.ts +209 -0
  176. package/src/store/baseline.ts +152 -0
  177. package/src/store/insights.ts +145 -0
  178. package/src/sync/assetsSyncAdapter.ts +151 -0
  179. package/src/types.ts +160 -0
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Baseline diff — the regression-prevention half of the tool.
3
+ *
4
+ * "Save baseline" persists a slim {hash, size} snapshot of the inventory via
5
+ * shared-ui's persistentStorage (FileSystem → AsyncStorage → memory), and
6
+ * every later scan diffs against it: new assets, removed assets, grown
7
+ * assets, net bytes. Run it before and after a branch and you get "this
8
+ * branch added 2.3 MB" without any CI tooling.
9
+ *
10
+ * On dashboard mirrors the actions forward to the device (the baseline
11
+ * belongs to the app being inspected, not the dashboard) and the computed
12
+ * diff arrives back inside the synced snapshot.
13
+ */
14
+
15
+ import { persistentStorage } from "@buoy-gg/shared-ui";
16
+ import { remoteAssetsDispatch } from "../remote";
17
+ import { assetsState } from "../state";
18
+ import { getSnapshot, invalidate } from "./assetStore";
19
+ import type { AssetRecord, AssetsBaseline, AssetsDiff } from "../types";
20
+
21
+ const STORAGE_KEY = "@react_buoy/assets/baseline";
22
+
23
+ /** ≥1 KB counts as growth; below that is codec/metadata noise. */
24
+ const GROWN_THRESHOLD_BYTES = 1024;
25
+
26
+ /** Load the persisted baseline into state once; safe to call repeatedly. */
27
+ export async function loadBaseline(): Promise<AssetsBaseline | null> {
28
+ const s = assetsState();
29
+ if (s.baselineLoaded) return s.baseline;
30
+ s.baselineLoaded = true;
31
+ try {
32
+ const raw = await persistentStorage.getItem(STORAGE_KEY);
33
+ if (raw) {
34
+ const parsed = JSON.parse(raw) as AssetsBaseline;
35
+ if (parsed && typeof parsed.savedAt === "number" && parsed.entries) {
36
+ s.baseline = parsed;
37
+ invalidate();
38
+ }
39
+ }
40
+ } catch {
41
+ // unreadable baseline = no baseline
42
+ }
43
+ return s.baseline;
44
+ }
45
+
46
+ export async function saveBaseline(): Promise<AssetsBaseline> {
47
+ const remote = remoteAssetsDispatch();
48
+ if (remote) {
49
+ await remote("saveBaseline");
50
+ return assetsState().baseline ?? { savedAt: Date.now(), entries: {} };
51
+ }
52
+ const s = assetsState();
53
+ const entries: AssetsBaseline["entries"] = {};
54
+ for (const r of getSnapshot()) {
55
+ entries[r.key] = {
56
+ hash: r.hash ?? null,
57
+ sizeBytes: r.sizeSource === "measured" ? (r.sizeBytes ?? null) : null,
58
+ name: r.name,
59
+ type: r.type,
60
+ };
61
+ }
62
+ const baseline: AssetsBaseline = { savedAt: Date.now(), entries };
63
+ s.baseline = baseline;
64
+ s.baselineLoaded = true;
65
+ invalidate();
66
+ try {
67
+ await persistentStorage.setItem(STORAGE_KEY, JSON.stringify(baseline));
68
+ } catch {
69
+ // in-memory baseline still works for this session
70
+ }
71
+ return baseline;
72
+ }
73
+
74
+ export async function clearBaseline(): Promise<void> {
75
+ const remote = remoteAssetsDispatch();
76
+ if (remote) {
77
+ await remote("clearBaseline");
78
+ return;
79
+ }
80
+ const s = assetsState();
81
+ s.baseline = null;
82
+ s.baselineLoaded = true;
83
+ invalidate();
84
+ try {
85
+ await persistentStorage.removeItem(STORAGE_KEY);
86
+ } catch {
87
+ // nothing to clean up
88
+ }
89
+ }
90
+
91
+ export function computeDiff(
92
+ records: AssetRecord[],
93
+ baseline: AssetsBaseline
94
+ ): AssetsDiff {
95
+ const added: number[] = [];
96
+ const grown: Array<{ id: number; deltaBytes: number }> = [];
97
+ const seen = new Set<string>();
98
+ let currentBytes = 0;
99
+ let baselineBytes = 0;
100
+ let comparable = false;
101
+
102
+ for (const r of records) {
103
+ seen.add(r.key);
104
+ const then = baseline.entries[r.key];
105
+ if (!then) {
106
+ added.push(r.id);
107
+ continue;
108
+ }
109
+ const now = r.sizeSource === "measured" ? (r.sizeBytes ?? null) : null;
110
+ if (now != null && then.sizeBytes != null) {
111
+ comparable = true;
112
+ currentBytes += now;
113
+ baselineBytes += then.sizeBytes;
114
+ if (now - then.sizeBytes >= GROWN_THRESHOLD_BYTES) {
115
+ grown.push({ id: r.id, deltaBytes: now - then.sizeBytes });
116
+ }
117
+ }
118
+ }
119
+
120
+ const removed: AssetsDiff["removed"] = [];
121
+ for (const [key, entry] of Object.entries(baseline.entries)) {
122
+ if (!seen.has(key)) {
123
+ removed.push({ key, name: entry.name, type: entry.type });
124
+ if (entry.sizeBytes != null) baselineBytes += entry.sizeBytes;
125
+ }
126
+ }
127
+ // Added assets' measured bytes count toward the net even without a
128
+ // baseline counterpart — that's exactly the growth being reported.
129
+ for (const id of added) {
130
+ const r = records.find((rec) => rec.id === id);
131
+ if (r?.sizeSource === "measured" && r.sizeBytes != null) {
132
+ comparable = true;
133
+ currentBytes += r.sizeBytes;
134
+ }
135
+ }
136
+
137
+ return {
138
+ baselineSavedAt: baseline.savedAt,
139
+ added,
140
+ removed,
141
+ grown: grown.sort((a, b) => b.deltaBytes - a.deltaBytes),
142
+ netBytes: comparable ? currentBytes - baselineBytes : null,
143
+ };
144
+ }
145
+
146
+ /** Current diff: the synced device's on mirrors, computed locally on device. */
147
+ export function getDiff(): AssetsDiff | null {
148
+ const s = assetsState();
149
+ if (s.mirrorDiff) return s.mirrorDiff;
150
+ if (!s.baseline) return null;
151
+ return computeDiff(getSnapshot(), s.baseline);
152
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Derived insights over the asset inventory — the Lighthouse/Emerge-style
3
+ * audit findings, each pointing at concrete record ids.
4
+ */
5
+
6
+ import { assetsState } from "../state";
7
+ import { estDecodedBytes } from "./assetStore";
8
+ import type { AssetRecord, AssetsInsights, DuplicateInsight } from "../types";
9
+
10
+ /** png/jpeg at or above this measured size are flagged as WebP candidates. */
11
+ const WEBP_CANDIDATE_BYTES = 50 * 1024;
12
+ /** GIFs at or above this measured size decode into serious frame memory. */
13
+ const HEAVY_GIF_BYTES = 100 * 1024;
14
+ /** Fonts at or above this are usually unsubset icon fonts. */
15
+ const HEAVY_FONT_BYTES = 150 * 1024;
16
+ /** Flag images decoding to more pixels than this many screenfuls. */
17
+ const OVER_DECODE_SCREENS = 2;
18
+ /** A higher scale variant this much smaller than a lower one = bad export. */
19
+ const SCALE_ANOMALY_RATIO = 0.9;
20
+ /**
21
+ * Anomaly checks only apply when the smaller variant is at least this big —
22
+ * for tiny icons PNG overhead dominates and byte ordering is pure noise.
23
+ */
24
+ const SCALE_ANOMALY_MIN_BYTES = 4 * 1024;
25
+
26
+ function effectivePixelRatio(): number {
27
+ const mirror = assetsState().mirrorPixelRatio;
28
+ if (mirror != null) return mirror;
29
+ try {
30
+ return require("react-native").PixelRatio.get();
31
+ } catch {
32
+ return 2;
33
+ }
34
+ }
35
+
36
+ /** Physical pixels of the entire screen (fallback ≈ a 6.1" phone). */
37
+ function screenPixels(): number {
38
+ try {
39
+ const { Dimensions, PixelRatio } = require("react-native");
40
+ const screen = Dimensions.get("screen");
41
+ const ratio = PixelRatio.get();
42
+ return screen.width * ratio * screen.height * ratio;
43
+ } catch {
44
+ return 1170 * 2532;
45
+ }
46
+ }
47
+
48
+ /** True when the measured per-scale sizes contradict the scales (bad export). */
49
+ function hasScaleAnomaly(record: AssetRecord): boolean {
50
+ const sizes = record.sizesByScale;
51
+ if (!sizes) return false;
52
+ const ordered = Object.entries(sizes)
53
+ .map(([scale, bytes]) => ({ scale: Number(scale), bytes }))
54
+ .filter((e) => Number.isFinite(e.scale) && e.bytes > 0)
55
+ .sort((a, b) => a.scale - b.scale);
56
+ for (let i = 1; i < ordered.length; i++) {
57
+ if (
58
+ ordered[i - 1]!.bytes >= SCALE_ANOMALY_MIN_BYTES &&
59
+ ordered[i]!.bytes < ordered[i - 1]!.bytes * SCALE_ANOMALY_RATIO
60
+ ) {
61
+ return true;
62
+ }
63
+ }
64
+ return false;
65
+ }
66
+
67
+ export function computeInsights(list: AssetRecord[]): AssetsInsights {
68
+ const deviceScale = effectivePixelRatio();
69
+
70
+ const byHash = new Map<string, AssetRecord[]>();
71
+ for (const r of list) {
72
+ if (!r.hash) continue;
73
+ const group = byHash.get(r.hash);
74
+ if (group) group.push(r);
75
+ else byHash.set(r.hash, [r]);
76
+ }
77
+ const duplicates: DuplicateInsight[] = [];
78
+ for (const [hash, group] of byHash) {
79
+ if (group.length < 2) continue;
80
+ const measured = group.find((r) => r.sizeBytes != null);
81
+ duplicates.push({
82
+ hash,
83
+ ids: group.map((r) => r.id),
84
+ wastedBytes:
85
+ measured?.sizeBytes != null
86
+ ? measured.sizeBytes * (group.length - 1)
87
+ : null,
88
+ });
89
+ }
90
+
91
+ const screenPx = screenPixels();
92
+ const scaleGaps: number[] = [];
93
+ const webpCandidates: number[] = [];
94
+ const neverLoaded: number[] = [];
95
+ const overDecode: number[] = [];
96
+ const heavyGifs: number[] = [];
97
+ const heavyFonts: number[] = [];
98
+ const scaleAnomalies: number[] = [];
99
+ for (const r of list) {
100
+ if (!r.loaded) neverLoaded.push(r.id);
101
+ // Only multi-scale assets missing the device scale are flagged — the
102
+ // author clearly manages variants and missed one. Single-@1x images are
103
+ // the overwhelmingly common case and flagging them all is pure noise.
104
+ if (
105
+ r.kind === "image" &&
106
+ r.scales.length > 1 &&
107
+ !r.scales.some((s) => s >= deviceScale)
108
+ ) {
109
+ scaleGaps.push(r.id);
110
+ }
111
+ if (
112
+ (r.type === "png" || r.type === "jpg" || r.type === "jpeg") &&
113
+ r.sizeBytes != null &&
114
+ r.sizeBytes >= WEBP_CANDIDATE_BYTES
115
+ ) {
116
+ webpCandidates.push(r.id);
117
+ }
118
+ // estDecodedBytes = pixels × 4, so pixels = bytes / 4.
119
+ if (
120
+ r.kind === "image" &&
121
+ estDecodedBytes(r) / 4 > screenPx * OVER_DECODE_SCREENS
122
+ ) {
123
+ overDecode.push(r.id);
124
+ }
125
+ if (r.type === "gif" && (r.sizeBytes ?? 0) >= HEAVY_GIF_BYTES) {
126
+ heavyGifs.push(r.id);
127
+ }
128
+ if (r.kind === "font" && (r.sizeBytes ?? 0) >= HEAVY_FONT_BYTES) {
129
+ heavyFonts.push(r.id);
130
+ }
131
+ if (hasScaleAnomaly(r)) scaleAnomalies.push(r.id);
132
+ }
133
+
134
+ return {
135
+ duplicates,
136
+ scaleGaps,
137
+ webpCandidates,
138
+ neverLoaded,
139
+ overDecode,
140
+ heavyGifs,
141
+ heavyFonts,
142
+ scaleAnomalies,
143
+ };
144
+ }
145
+
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Sync adapter for the Assets tool, consumed by @buoy-gg/external-sync's
3
+ * `useExternalSync` (structurally matches its ToolSyncAdapter interface so
4
+ * this package doesn't need a dependency on it — same pattern as images).
5
+ * Exposes the asset inventory to the desktop dashboard and the MCP server.
6
+ */
7
+
8
+ import { getScanStatus, rescan } from "../capture/install";
9
+ import { measureSizes } from "../capture/measure";
10
+ import {
11
+ clearRecords,
12
+ computeStats,
13
+ estDecodedBytes,
14
+ getRecord,
15
+ getSnapshot,
16
+ subscribe,
17
+ } from "../store/assetStore";
18
+ import { clearBaseline, getDiff, saveBaseline } from "../store/baseline";
19
+ import { computeInsights } from "../store/insights";
20
+ import type { AssetKind, AssetRecord, AssetRecordWire } from "../types";
21
+
22
+ const KINDS: AssetKind[] = ["image", "font", "video", "audio", "data", "other"];
23
+
24
+ function toWire(record: AssetRecord): AssetRecordWire {
25
+ return {
26
+ id: record.id,
27
+ key: record.key,
28
+ registryId: record.registryId ?? null,
29
+ name: record.name,
30
+ type: record.type,
31
+ kind: record.kind,
32
+ hash: record.hash ?? null,
33
+ location: record.httpServerLocation ?? null,
34
+ width: record.width ?? null,
35
+ height: record.height ?? null,
36
+ scales: record.scales,
37
+ uri: record.resolvedUri ?? null,
38
+ loaded: record.loaded,
39
+ sizeBytes:
40
+ record.sizeSource === "measured"
41
+ ? (record.sizeBytes ?? null)
42
+ : estDecodedBytes(record) || null,
43
+ sizeSource: record.sizeSource ?? null,
44
+ };
45
+ }
46
+
47
+ function requireRecord(params: unknown): AssetRecord {
48
+ const id = (params as { id?: number })?.id;
49
+ if (typeof id !== "number") throw new Error("Missing numeric `id` param");
50
+ const record = getRecord(id);
51
+ if (!record) {
52
+ throw new Error(
53
+ `Asset record ${id} not found — re-run the list action for current ids.`
54
+ );
55
+ }
56
+ return record;
57
+ }
58
+
59
+ /** Largest-first is the useful default for an inventory. */
60
+ function bySizeDesc(a: AssetRecord, b: AssetRecord): number {
61
+ return (b.sizeBytes ?? estDecodedBytes(b)) - (a.sizeBytes ?? estDecodedBytes(a));
62
+ }
63
+
64
+ function overview() {
65
+ const snapshot = getSnapshot();
66
+ return {
67
+ stats: computeStats(snapshot),
68
+ scanStatus: getScanStatus(),
69
+ insights: computeInsights(snapshot),
70
+ diff: getDiff(),
71
+ records: [...snapshot].sort(bySizeDesc),
72
+ };
73
+ }
74
+
75
+ export const assetsSyncAdapter = {
76
+ version: 1,
77
+ getSnapshot: () => {
78
+ const { records, ...rest } = overview();
79
+ return { ...rest, records: records.slice(0, 200).map(toWire) };
80
+ },
81
+ subscribe: (onChange: () => void) => subscribe(onChange),
82
+ actions: {
83
+ /** List assets, largest first. Params: { limit?, kind?, loadedOnly? }. */
84
+ list: (params: unknown) => {
85
+ const p = (params ?? {}) as {
86
+ limit?: number;
87
+ kind?: string;
88
+ loadedOnly?: boolean;
89
+ };
90
+ const limit = Math.min(Math.max(p.limit ?? 50, 1), 500);
91
+ const { records, ...rest } = overview();
92
+ let filtered = records;
93
+ if (p.kind) {
94
+ if (!KINDS.includes(p.kind as AssetKind)) {
95
+ throw new Error(`kind must be one of: ${KINDS.join(", ")}`);
96
+ }
97
+ filtered = filtered.filter((r) => r.kind === p.kind);
98
+ }
99
+ if (p.loadedOnly) filtered = filtered.filter((r) => r.loaded);
100
+ return {
101
+ ...rest,
102
+ total: filtered.length,
103
+ records: filtered.slice(0, limit).map(toWire),
104
+ };
105
+ },
106
+ /** Full detail for one asset. Params: { id }. */
107
+ getDetail: (params: unknown) => {
108
+ const record = requireRecord(params);
109
+ return {
110
+ ...toWire(record),
111
+ origin: record.origin,
112
+ fileSystemLocation: record.fileSystemLocation ?? null,
113
+ files: record.files ?? null,
114
+ sizesByScale: record.sizesByScale ?? null,
115
+ estDecodedBytes: estDecodedBytes(record) || null,
116
+ };
117
+ },
118
+ /** Re-walk the registry, merge the Metro graph, refresh fonts, measure sizes. */
119
+ rescan: async () => {
120
+ const status = await rescan();
121
+ return { ok: true, scanStatus: status };
122
+ },
123
+ /** (Re-)measure byte sizes from the dev server. */
124
+ measureSizes: async () => {
125
+ const result = await measureSizes();
126
+ return { ok: result.measured > 0, ...result };
127
+ },
128
+ /** Persist the current inventory as the comparison baseline. */
129
+ saveBaseline: async () => {
130
+ const baseline = await saveBaseline();
131
+ return {
132
+ ok: true,
133
+ savedAt: baseline.savedAt,
134
+ assets: Object.keys(baseline.entries).length,
135
+ };
136
+ },
137
+ /** Drop the saved baseline. */
138
+ clearBaseline: async () => {
139
+ await clearBaseline();
140
+ return { ok: true, message: "Baseline cleared" };
141
+ },
142
+ /** Change vs the saved baseline (null when no baseline exists). */
143
+ getDiff: () => getDiff(),
144
+ /** Wipe the store (next rescan rebuilds it). */
145
+ clearRecords: () => {
146
+ clearRecords();
147
+ return { ok: true, message: "Inventory cleared — rescan to rebuild" };
148
+ },
149
+ getScanStatus: () => getScanStatus(),
150
+ },
151
+ };
package/src/types.ts ADDED
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Types for the Assets tool.
3
+ *
4
+ * An AssetRecord is one bundled asset (all of its scale variants together —
5
+ * icon.png + icon@2x.png + icon@3x.png is ONE record, mirroring how Metro
6
+ * registers it). Records come from two places:
7
+ *
8
+ * - "registry": the runtime @react-native/assets-registry — assets whose
9
+ * module has actually been evaluated (`loaded: true`). Works in dev AND
10
+ * release, bare RN AND Expo.
11
+ * - "graph": the Metro dev server's `<entry>.assets` endpoint — every asset
12
+ * in the dependency graph, including ones never required at runtime.
13
+ * Dev-only. Graph-only records are the "shipped but never loaded" set.
14
+ */
15
+
16
+ export type AssetKind = "image" | "font" | "video" | "audio" | "data" | "other";
17
+
18
+ export type AssetOrigin = "registry" | "graph";
19
+
20
+ export interface AssetRecord {
21
+ id: number;
22
+ /** Stable identity across sources: `${httpServerLocation}/${name}.${type}`. */
23
+ key: string;
24
+ /** Runtime asset id in @react-native/assets-registry (set once evaluated). */
25
+ registryId?: number;
26
+ name: string;
27
+ /** File extension without the dot (png, ttf, mp4, …). */
28
+ type: string;
29
+ kind: AssetKind;
30
+ /** Metro's md5 over ALL scale variants concatenated. */
31
+ hash?: string;
32
+ httpServerLocation?: string;
33
+ /** Logical (scale-divided) dimensions — images only. */
34
+ width?: number;
35
+ height?: number;
36
+ scales: number[];
37
+ /** resolveAssetSource().uri for this device/environment. */
38
+ resolvedUri?: string;
39
+ resolvedScale?: number;
40
+ /** True once the asset module was evaluated (present in the runtime registry). */
41
+ loaded: boolean;
42
+ origin: AssetOrigin;
43
+ /** Dev-only fields from the Metro graph (stripped from runtime records). */
44
+ fileSystemLocation?: string;
45
+ files?: string[];
46
+ /** Per-scale md5s (Expo graph) — the key into ExpoUpdates.localAssets. */
47
+ fileHashes?: string[];
48
+ /** Measured bytes per scale ("1", "2", …) — dev server or file system. */
49
+ sizesByScale?: Record<string, number>;
50
+ /** Total measured bytes across all scale variants. */
51
+ sizeBytes?: number;
52
+ sizeSource?: "measured" | "estimate";
53
+ firstSeenAt: number;
54
+ }
55
+
56
+ /** How the tool learned its inventory + what enrichment is available. */
57
+ export interface AssetsScanStatus {
58
+ registryAvailable: boolean;
59
+ /** registerAsset wrapper installed (live updates). */
60
+ patched: boolean;
61
+ registryCount: number;
62
+ /** Metro dev server reachable (graph + size measurement possible). */
63
+ graphSupported: boolean;
64
+ /** Assets in the full Metro graph; null until fetched. */
65
+ graphCount: number | null;
66
+ graphError: string | null;
67
+ lastScanAt: number | null;
68
+ measuring: boolean;
69
+ measuredCount: number;
70
+ /** Loaded font families (expo-font native module; [] when unavailable). */
71
+ fontFamilies: string[];
72
+ /** Entries in ExpoUpdates.localAssets; null when not an updates env. */
73
+ localAssetCount: number | null;
74
+ }
75
+
76
+ export interface AssetsStats {
77
+ total: number;
78
+ loaded: number;
79
+ neverLoaded: number;
80
+ byKind: Record<AssetKind, { count: number; bytes: number }>;
81
+ /** Sum of measured bytes only (see measuredCount for coverage). */
82
+ totalBytes: number;
83
+ measuredCount: number;
84
+ fontFamilies: number;
85
+ }
86
+
87
+ export interface DuplicateInsight {
88
+ hash: string;
89
+ /** Record ids sharing the hash. */
90
+ ids: number[];
91
+ /** Bytes that could be saved by deduping (null until sizes measured). */
92
+ wastedBytes: number | null;
93
+ }
94
+
95
+ export interface AssetsInsights {
96
+ duplicates: DuplicateInsight[];
97
+ /** Image records whose scale variants can't serve this device's PixelRatio. */
98
+ scaleGaps: number[];
99
+ /** png/jpeg records over the size threshold — WebP conversion candidates. */
100
+ webpCandidates: number[];
101
+ /** Records in the bundle graph that no runtime code ever required. */
102
+ neverLoaded: number[];
103
+ /** Images that decode to more pixels than 2× this device's entire screen. */
104
+ overDecode: number[];
105
+ /** GIFs big enough to hurt (every frame decodes into memory). */
106
+ heavyGifs: number[];
107
+ /** Font files over the icon-font-bloat threshold. */
108
+ heavyFonts: number[];
109
+ /** Scale variants whose measured bytes contradict their scale (bad export). */
110
+ scaleAnomalies: number[];
111
+ }
112
+
113
+ /** Persisted snapshot of the inventory for before/after comparison. */
114
+ export interface AssetsBaseline {
115
+ savedAt: number;
116
+ entries: Record<
117
+ string,
118
+ { hash: string | null; sizeBytes: number | null; name: string; type: string }
119
+ >;
120
+ }
121
+
122
+ export interface AssetsDiff {
123
+ baselineSavedAt: number;
124
+ /** Current record ids not present in the baseline. */
125
+ added: number[];
126
+ /** Baseline assets no longer in the inventory. */
127
+ removed: Array<{ key: string; name: string; type: string }>;
128
+ /** Current record ids whose measured size grew ≥1 KB vs the baseline. */
129
+ grown: Array<{ id: number; deltaBytes: number }>;
130
+ /** Sum of measured sizes now minus then; null when sizes are unmeasured. */
131
+ netBytes: number | null;
132
+ }
133
+
134
+ /** Slim wire form of a record (desktop dashboard + MCP). */
135
+ export interface AssetRecordWire {
136
+ id: number;
137
+ key: string;
138
+ registryId: number | null;
139
+ name: string;
140
+ type: string;
141
+ kind: AssetKind;
142
+ hash: string | null;
143
+ location: string | null;
144
+ width: number | null;
145
+ height: number | null;
146
+ scales: number[];
147
+ uri: string | null;
148
+ loaded: boolean;
149
+ sizeBytes: number | null;
150
+ sizeSource: "measured" | "estimate" | null;
151
+ }
152
+
153
+ export interface AssetsSnapshotPayload {
154
+ stats: AssetsStats;
155
+ scanStatus: AssetsScanStatus;
156
+ insights: AssetsInsights;
157
+ /** Change vs the saved baseline; null when no baseline exists. */
158
+ diff: AssetsDiff | null;
159
+ records: AssetRecordWire[];
160
+ }