@hyperframes/studio 0.7.22 → 0.7.24

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.22",
3
+ "version": "0.7.24",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,11 +46,11 @@
46
46
  "gsap": "^3.13.0",
47
47
  "marked": "^14.1.4",
48
48
  "mediabunny": "^1.45.3",
49
- "@hyperframes/core": "0.7.22",
50
- "@hyperframes/parsers": "0.7.22",
51
- "@hyperframes/sdk": "0.7.22",
52
- "@hyperframes/player": "0.7.22",
53
- "@hyperframes/studio-server": "0.7.22"
49
+ "@hyperframes/core": "0.7.24",
50
+ "@hyperframes/sdk": "0.7.24",
51
+ "@hyperframes/parsers": "0.7.24",
52
+ "@hyperframes/player": "0.7.24",
53
+ "@hyperframes/studio-server": "0.7.24"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/react": "19",
@@ -65,7 +65,7 @@
65
65
  "vite": "^6.4.2",
66
66
  "vitest": "^3.2.4",
67
67
  "zustand": "^5.0.0",
68
- "@hyperframes/producer": "0.7.22"
68
+ "@hyperframes/producer": "0.7.24"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "react": "19",
@@ -0,0 +1,277 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { shouldUseSdkCutover } from "../utils/sdkCutover";
6
+ import type { PatchOperation } from "../utils/sourcePatcher";
7
+ import type { Composition } from "@hyperframes/sdk";
8
+ import type { UseDomEditSessionParams } from "./useDomEditSession";
9
+
10
+ const styleOp = (property: string, value: string): PatchOperation => ({
11
+ type: "inline-style",
12
+ property,
13
+ value,
14
+ });
15
+
16
+ const attrOp = (property: string, value: string): PatchOperation => ({
17
+ type: "attribute",
18
+ property,
19
+ value,
20
+ });
21
+
22
+ describe("shouldUseSdkCutover", () => {
23
+ it("returns false when flag is disabled", () => {
24
+ expect(shouldUseSdkCutover(false, true, "hf-abc", [styleOp("color", "red")])).toBe(false);
25
+ });
26
+
27
+ it("returns false when no SDK session", () => {
28
+ expect(shouldUseSdkCutover(true, false, "hf-abc", [styleOp("color", "red")])).toBe(false);
29
+ });
30
+
31
+ it("returns false when selection has no hfId", () => {
32
+ expect(shouldUseSdkCutover(true, true, null, [styleOp("color", "red")])).toBe(false);
33
+ expect(shouldUseSdkCutover(true, true, undefined, [styleOp("color", "red")])).toBe(false);
34
+ });
35
+
36
+ it("returns false when ops array is empty", () => {
37
+ expect(shouldUseSdkCutover(true, true, "hf-abc", [])).toBe(false);
38
+ });
39
+
40
+ it("returns true when all conditions met with supported op types", () => {
41
+ expect(shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red")])).toBe(true);
42
+ expect(
43
+ shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red"), attrOp("data-x", "1")]),
44
+ ).toBe(true);
45
+ });
46
+ });
47
+
48
+ // ── onReorderShadow source filter (Fix 3) ────────────────────────────────────
49
+ //
50
+ // useDomEditSession composes ~9 sub-hooks; the only one relevant to this fix is
51
+ // useDomEditCommits, which receives the onReorderShadow callback built inline.
52
+ // Every other sub-hook is stubbed to a minimal shape so the hook under test can
53
+ // render without pulling in unrelated preview/GSAP/selection machinery.
54
+
55
+ const recordResolverParity = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
56
+ const capturedOnReorderShadow: { fn: ((targets: string[]) => void) | undefined } = {
57
+ fn: undefined,
58
+ };
59
+
60
+ vi.mock("../utils/sdkResolverShadow", () => ({
61
+ runResolverShadow: vi.fn(),
62
+ recordResolverParity: (...args: unknown[]) => recordResolverParity(...args),
63
+ }));
64
+ vi.mock("./useDomEditCommits", () => ({
65
+ useDomEditCommits: (params: { onReorderShadow?: (targets: string[]) => void }) => {
66
+ capturedOnReorderShadow.fn = params.onReorderShadow;
67
+ return {
68
+ resolveImportedFontAsset: vi.fn(),
69
+ handleDomStyleCommit: vi.fn(),
70
+ handleDomAttributeCommit: vi.fn(),
71
+ handleDomAttributeLiveCommit: vi.fn(),
72
+ handleDomHtmlAttributeCommit: vi.fn(),
73
+ handleDomTextCommit: vi.fn(),
74
+ handleDomTextFieldStyleCommit: vi.fn(),
75
+ handleDomAddTextField: vi.fn(),
76
+ handleDomRemoveTextField: vi.fn(),
77
+ handleDomBoxSizeCommit: vi.fn(),
78
+ handleDomManualEditsReset: vi.fn(),
79
+ handleDomEditElementDelete: vi.fn(),
80
+ handleDomZIndexReorderCommit: vi.fn(),
81
+ };
82
+ },
83
+ }));
84
+ vi.mock("./useDomSelection", () => ({
85
+ useDomSelection: () => ({
86
+ domEditSelection: null,
87
+ domEditGroupSelections: [],
88
+ domEditHoverSelection: null,
89
+ activeGroupElement: null,
90
+ domEditSelectionRef: { current: null },
91
+ domEditGroupSelectionsRef: { current: [] },
92
+ setActiveGroupElement: vi.fn(),
93
+ applyDomSelection: vi.fn(),
94
+ clearDomSelection: vi.fn(),
95
+ buildDomSelectionFromTarget: vi.fn(),
96
+ resolveDomSelectionFromPreviewPoint: vi.fn(),
97
+ resolveAllDomSelectionsFromPreviewPoint: vi.fn(),
98
+ updateDomEditHoverSelection: vi.fn(),
99
+ buildDomSelectionForTimelineElement: vi.fn(),
100
+ handleTimelineElementSelect: vi.fn(),
101
+ refreshDomEditSelectionFromPreview: vi.fn(),
102
+ applyMarqueeSelection: vi.fn(),
103
+ }),
104
+ }));
105
+ vi.mock("./useAskAgentModal", () => ({
106
+ useAskAgentModal: () => ({
107
+ agentModalOpen: false,
108
+ agentModalAnchorPoint: null,
109
+ copiedAgentPrompt: null,
110
+ agentPromptSelectionContext: null,
111
+ setAgentModalOpen: vi.fn(),
112
+ setAgentPromptSelectionContext: vi.fn(),
113
+ setAgentModalAnchorPoint: vi.fn(),
114
+ handleAskAgent: vi.fn(),
115
+ handleAgentModalSubmit: vi.fn(),
116
+ }),
117
+ }));
118
+ vi.mock("./useStudioSelectionPublisher", () => ({
119
+ useStudioSelectionPublisher: () => {},
120
+ }));
121
+ vi.mock("./useGsapTweenCache", () => ({
122
+ useGsapCacheVersion: () => ({ version: 0, bump: vi.fn() }),
123
+ }));
124
+ vi.mock("./useGsapScriptCommits", () => ({
125
+ useGsapScriptCommits: () => ({
126
+ commitMutation: vi.fn(),
127
+ updateGsapProperty: vi.fn(),
128
+ updateGsapMeta: vi.fn(),
129
+ deleteGsapAnimation: vi.fn(),
130
+ deleteAllForSelector: vi.fn(),
131
+ addGsapAnimation: vi.fn(),
132
+ addGsapProperty: vi.fn(),
133
+ removeGsapProperty: vi.fn(),
134
+ updateGsapFromProperty: vi.fn(),
135
+ addGsapFromProperty: vi.fn(),
136
+ removeGsapFromProperty: vi.fn(),
137
+ addKeyframe: vi.fn(),
138
+ addKeyframeBatch: vi.fn(),
139
+ removeKeyframe: vi.fn(),
140
+ moveKeyframe: vi.fn(),
141
+ resizeKeyframedTween: vi.fn(),
142
+ convertToKeyframes: vi.fn(),
143
+ removeAllKeyframes: vi.fn(),
144
+ setArcPath: vi.fn(),
145
+ updateArcSegment: vi.fn(),
146
+ }),
147
+ }));
148
+ vi.mock("./useGroupCommits", () => ({
149
+ useGroupCommits: () => ({
150
+ groupSelection: vi.fn(),
151
+ ungroupSelection: vi.fn(),
152
+ }),
153
+ }));
154
+ vi.mock("./useDomEditWiring", () => ({
155
+ useDomEditWiring: () => ({
156
+ onClickToSource: vi.fn(),
157
+ selectedGsapAnimations: [],
158
+ gsapMultipleTimelines: false,
159
+ gsapUnsupportedTimelinePattern: false,
160
+ trackGsapInteractionFailure: vi.fn(),
161
+ makeFetchFallback: vi.fn(),
162
+ handleGsapUpdateProperty: vi.fn(),
163
+ handleGsapUpdateMeta: vi.fn(),
164
+ handleGsapDeleteAnimation: vi.fn(),
165
+ handleGsapDeleteAllForElement: vi.fn(),
166
+ handleGsapAddAnimation: vi.fn(),
167
+ handleGsapAddProperty: vi.fn(),
168
+ handleGsapRemoveProperty: vi.fn(),
169
+ handleGsapUpdateFromProperty: vi.fn(),
170
+ handleGsapAddFromProperty: vi.fn(),
171
+ handleGsapRemoveFromProperty: vi.fn(),
172
+ handleGsapAddKeyframe: vi.fn(),
173
+ handleGsapAddKeyframeBatch: vi.fn(),
174
+ handleGsapRemoveKeyframe: vi.fn(),
175
+ handleGsapMoveKeyframeToPlayhead: vi.fn(),
176
+ handleGsapMoveKeyframe: vi.fn(),
177
+ handleGsapResizeKeyframedTween: vi.fn(),
178
+ handleGsapConvertToKeyframes: vi.fn(),
179
+ handleGsapRemoveAllKeyframes: vi.fn(),
180
+ handleResetSelectedElementKeyframes: vi.fn(),
181
+ }),
182
+ }));
183
+ vi.mock("./usePreviewInteraction", () => ({
184
+ usePreviewInteraction: () => ({
185
+ handlePreviewCanvasMouseDown: vi.fn(),
186
+ handlePreviewCanvasPointerMove: vi.fn(),
187
+ handlePreviewCanvasPointerLeave: vi.fn(),
188
+ handleBlockedDomMove: vi.fn(),
189
+ handleDomManualDragStart: vi.fn(),
190
+ }),
191
+ }));
192
+ vi.mock("./useGsapAwareEditing", () => ({
193
+ useGsapAwareEditing: () => ({
194
+ handleGsapAwarePathOffsetCommit: vi.fn(),
195
+ handleGsapAwareGroupPathOffsetCommit: vi.fn(),
196
+ handleGsapAwareBoxSizeCommit: vi.fn(),
197
+ handleGsapAwareRotationCommit: vi.fn(),
198
+ commitAnimatedProperty: vi.fn(),
199
+ commitAnimatedProperties: vi.fn(),
200
+ handleSetArcPath: vi.fn(),
201
+ handleUpdateArcSegment: vi.fn(),
202
+ handleUnroll: vi.fn(),
203
+ commitMutation: vi.fn(),
204
+ }),
205
+ }));
206
+
207
+ // Tell React this is an act-capable environment so act(...) flushes effects.
208
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
209
+
210
+ describe("onReorderShadow source filter", () => {
211
+ it("passes a readSource function as the 4th recordResolverParity argument when sdkSession and activeCompPath are set", async () => {
212
+ const { useDomEditSession } = await import("./useDomEditSession");
213
+ const readProjectFile = vi.fn(async (path: string) => `content of ${path}`);
214
+ // Minimal opaque test double: no sub-hook under test actually calls into the
215
+ // session (useDomEditCommits and recordResolverParity are both mocked above),
216
+ // so it only needs to flow through as a reference.
217
+ const sdkSession = {} as unknown as Composition;
218
+
219
+ function Probe() {
220
+ const params: UseDomEditSessionParams = {
221
+ projectId: "proj-1",
222
+ activeCompPath: "index.html",
223
+ isMasterView: false,
224
+ compIdToSrc: new Map(),
225
+ captionEditMode: false,
226
+ compositionLoading: false,
227
+ previewIframeRef: { current: null },
228
+ timelineElements: [],
229
+ setSelectedTimelineElementId: vi.fn(),
230
+ setRightCollapsed: vi.fn(),
231
+ setRightPanelTab: vi.fn(),
232
+ showToast: vi.fn(),
233
+ refreshPreviewDocumentVersion: vi.fn(),
234
+ queueDomEditSave: vi.fn(async (save: () => Promise<void>) => save()),
235
+ readProjectFile,
236
+ writeProjectFile: vi.fn(async () => {}),
237
+ updateEditingFileContent: vi.fn(),
238
+ domEditSaveTimestampRef: { current: 0 },
239
+ editHistory: { recordEdit: vi.fn(async () => {}) },
240
+ fileTree: [],
241
+ importedFontAssetsRef: { current: [] },
242
+ projectDir: null,
243
+ projectIdRef: { current: "proj-1" },
244
+ previewIframe: null,
245
+ refreshKey: 0,
246
+ previewDocumentVersion: 0,
247
+ rightPanelTab: "design",
248
+ applyStudioManualEditsToPreviewRef: { current: async () => {} },
249
+ syncPreviewHistoryHotkey: vi.fn(),
250
+ reloadPreview: vi.fn(),
251
+ setRefreshKey: vi.fn(),
252
+ sdkSession,
253
+ forceReloadSdkSession: vi.fn(),
254
+ };
255
+ useDomEditSession(params);
256
+ return null;
257
+ }
258
+
259
+ const container = document.createElement("div");
260
+ const root = createRoot(container);
261
+ act(() => {
262
+ root.render(<Probe />);
263
+ });
264
+ try {
265
+ expect(capturedOnReorderShadow.fn).toBeTypeOf("function");
266
+ capturedOnReorderShadow.fn?.(["hf-target"]);
267
+ expect(recordResolverParity).toHaveBeenCalledWith(
268
+ sdkSession,
269
+ "hf-target",
270
+ "reorderElements",
271
+ expect.any(Function),
272
+ );
273
+ } finally {
274
+ act(() => root.unmount());
275
+ }
276
+ });
277
+ });
@@ -87,7 +87,7 @@ export function useDomEditSession({
87
87
  showToast,
88
88
  refreshPreviewDocumentVersion,
89
89
  queueDomEditSave,
90
- readProjectFile: _readProjectFile,
90
+ readProjectFile,
91
91
  writeProjectFile,
92
92
  updateEditingFileContent,
93
93
  domEditSaveTimestampRef,
@@ -111,7 +111,6 @@ export function useDomEditSession({
111
111
  forceReloadSdkSession,
112
112
  }: UseDomEditSessionParams) {
113
113
  void _setRefreshKey;
114
- void _readProjectFile;
115
114
 
116
115
  // ── Selection ──
117
116
 
@@ -295,7 +294,14 @@ export function useDomEditSession({
295
294
  // the SDK resolves each reordered element (the reorderElements op's targets).
296
295
  onReorderShadow: sdkSession
297
296
  ? (targets: string[]) => {
298
- for (const target of targets) recordResolverParity(sdkSession, target, "reorderElements");
297
+ // Single-flight: every target in one reorder batch shares the same file, so
298
+ // memoize the read instead of firing one fetch per unresolved target.
299
+ let reorderSrcPromise: Promise<string> | undefined;
300
+ const reorderSrc = activeCompPath
301
+ ? () => (reorderSrcPromise ??= readProjectFile(activeCompPath))
302
+ : undefined;
303
+ for (const target of targets)
304
+ void recordResolverParity(sdkSession, target, "reorderElements", reorderSrc);
299
305
  }
300
306
  : undefined,
301
307
  });
@@ -5,36 +5,21 @@
5
5
  // localStorage.setItem('hyperframes-studio:telemetryDisabled','1')
6
6
  // ---------------------------------------------------------------------------
7
7
 
8
- import { generateId } from "../utils/generateId";
8
+ import { resolveStudioDistinctId } from "./distinctId";
9
+ import { safeLocalStorage, safeSessionStorage } from "../utils/safeStorage";
9
10
 
10
- const ANON_ID_KEY = "hyperframes-studio:anonymousId";
11
11
  const OPT_OUT_KEY = "hyperframes-studio:telemetryDisabled";
12
12
  const NOTICE_KEY = "hyperframes-studio:telemetryNoticeShown";
13
13
 
14
- function safeLocalStorage(): Storage | null {
15
- try {
16
- return typeof localStorage === "undefined" ? null : localStorage;
17
- } catch {
18
- return null;
19
- }
20
- }
21
-
22
- function newAnonymousId(): string {
23
- return generateId();
24
- }
25
-
14
+ /**
15
+ * Anonymous telemetry id for `studio_*` and render events.
16
+ *
17
+ * Delegates to the single source of truth in `distinctId.ts` so this id is
18
+ * identical to the one used for `studio:*` events (utils/studioTelemetry.ts)
19
+ * and, when the CLI launched Studio, to the CLI's own `config.anonymousId`.
20
+ */
26
21
  export function getAnonymousId(): string {
27
- const ls = safeLocalStorage();
28
- if (!ls) return "anonymous";
29
- const existing = ls.getItem(ANON_ID_KEY);
30
- if (existing) return existing;
31
- const id = newAnonymousId();
32
- try {
33
- ls.setItem(ANON_ID_KEY, id);
34
- } catch {
35
- /* private browsing / quota — return the in-memory ID for this session */
36
- }
37
- return id;
22
+ return resolveStudioDistinctId();
38
23
  }
39
24
 
40
25
  export function isOptedOut(): boolean {
@@ -58,14 +43,6 @@ export function markNoticeShown(): void {
58
43
  // Uses sessionStorage directly because the dedupe is per-tab, not per-browser.
59
44
  const SESSION_FIRED_KEY = "hyperframes-studio:sessionStartFired";
60
45
 
61
- function safeSessionStorage(): Storage | null {
62
- try {
63
- return typeof sessionStorage === "undefined" ? null : sessionStorage;
64
- } catch {
65
- return null;
66
- }
67
- }
68
-
69
46
  export function hasFiredSessionStart(): boolean {
70
47
  return safeSessionStorage()?.getItem(SESSION_FIRED_KEY) === "1";
71
48
  }
@@ -0,0 +1,103 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { describe, expect, it, beforeEach, afterEach } from "vitest";
4
+ import {
5
+ resolveStudioDistinctId,
6
+ getCliDistinctId,
7
+ __resetStudioDistinctIdForTests,
8
+ DISTINCT_ID_KEY,
9
+ LEGACY_STUDIO_ANON_ID_KEY,
10
+ } from "./distinctId";
11
+
12
+ function clearCliId(): void {
13
+ delete window.__HF_CLI_DISTINCT_ID;
14
+ }
15
+
16
+ describe("resolveStudioDistinctId", () => {
17
+ beforeEach(() => {
18
+ localStorage.clear();
19
+ clearCliId();
20
+ __resetStudioDistinctIdForTests();
21
+ });
22
+
23
+ afterEach(() => {
24
+ clearCliId();
25
+ __resetStudioDistinctIdForTests();
26
+ });
27
+
28
+ it("adopts the CLI-seeded id and persists it to both keys", () => {
29
+ window.__HF_CLI_DISTINCT_ID = "cli-machine-uuid";
30
+ const id = resolveStudioDistinctId();
31
+ expect(id).toBe("cli-machine-uuid");
32
+ expect(localStorage.getItem(DISTINCT_ID_KEY)).toBe("cli-machine-uuid");
33
+ expect(localStorage.getItem(LEGACY_STUDIO_ANON_ID_KEY)).toBe("cli-machine-uuid");
34
+ });
35
+
36
+ it("prefers the CLI id even over an existing persisted id", () => {
37
+ localStorage.setItem(DISTINCT_ID_KEY, "old-browser-id");
38
+ window.__HF_CLI_DISTINCT_ID = "cli-machine-uuid";
39
+ expect(resolveStudioDistinctId()).toBe("cli-machine-uuid");
40
+ });
41
+
42
+ it("ignores an empty CLI id and falls back to the persisted id", () => {
43
+ window.__HF_CLI_DISTINCT_ID = "";
44
+ localStorage.setItem(DISTINCT_ID_KEY, "persisted-id");
45
+ expect(resolveStudioDistinctId()).toBe("persisted-id");
46
+ });
47
+
48
+ it("reuses the canonical persisted id when no CLI id is present", () => {
49
+ localStorage.setItem(DISTINCT_ID_KEY, "canonical-id");
50
+ const id = resolveStudioDistinctId();
51
+ expect(id).toBe("canonical-id");
52
+ // Backfills the legacy key so both clients agree.
53
+ expect(localStorage.getItem(LEGACY_STUDIO_ANON_ID_KEY)).toBe("canonical-id");
54
+ });
55
+
56
+ it("reuses the legacy key when only it exists, and backfills the canonical key", () => {
57
+ localStorage.setItem(LEGACY_STUDIO_ANON_ID_KEY, "legacy-id");
58
+ const id = resolveStudioDistinctId();
59
+ expect(id).toBe("legacy-id");
60
+ expect(localStorage.getItem(DISTINCT_ID_KEY)).toBe("legacy-id");
61
+ });
62
+
63
+ it("mints and persists a new id when nothing exists (standalone Studio)", () => {
64
+ const id = resolveStudioDistinctId();
65
+ expect(id).toBeTruthy();
66
+ expect(localStorage.getItem(DISTINCT_ID_KEY)).toBe(id);
67
+ expect(localStorage.getItem(LEGACY_STUDIO_ANON_ID_KEY)).toBe(id);
68
+ });
69
+
70
+ it("memoizes the resolved id within a session", () => {
71
+ const first = resolveStudioDistinctId();
72
+ localStorage.setItem(DISTINCT_ID_KEY, "changed-underneath");
73
+ expect(resolveStudioDistinctId()).toBe(first);
74
+ });
75
+
76
+ it("memoizes an adopted CLI id even if window.__HF_CLI_DISTINCT_ID changes later", () => {
77
+ window.__HF_CLI_DISTINCT_ID = "cli-id-1";
78
+ expect(resolveStudioDistinctId()).toBe("cli-id-1");
79
+ // A late reassignment of the injected global must not change the resolved id.
80
+ window.__HF_CLI_DISTINCT_ID = "cli-id-2";
81
+ expect(resolveStudioDistinctId()).toBe("cli-id-1");
82
+ });
83
+ });
84
+
85
+ describe("getCliDistinctId", () => {
86
+ beforeEach(() => {
87
+ clearCliId();
88
+ });
89
+
90
+ it("returns the injected id when present", () => {
91
+ window.__HF_CLI_DISTINCT_ID = "cli-id";
92
+ expect(getCliDistinctId()).toBe("cli-id");
93
+ });
94
+
95
+ it("returns null when absent", () => {
96
+ expect(getCliDistinctId()).toBeNull();
97
+ });
98
+
99
+ it("returns null for an empty string", () => {
100
+ window.__HF_CLI_DISTINCT_ID = "";
101
+ expect(getCliDistinctId()).toBeNull();
102
+ });
103
+ });
@@ -0,0 +1,125 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Single source of truth for the Studio telemetry distinct_id.
3
+ //
4
+ // Studio historically minted TWO independent anonymous ids:
5
+ // - `hf-studio-anon-id` (utils/studioTelemetry.ts → studio:* events)
6
+ // - `hyperframes-studio:anonymousId` (telemetry/config.ts → studio_* + render events)
7
+ // so a single browser looked like two different people in PostHog. This module
8
+ // resolves ONE id that both clients (and the render→CLI channel) share.
9
+ //
10
+ // CLI→Studio identity stitch (Layer 1, no login / no PII):
11
+ // When the CLI launches Studio it injects its own `config.anonymousId`
12
+ // (a random UUID from ~/.hyperframes/config.json) as `window.__HF_CLI_DISTINCT_ID`
13
+ // (see packages/cli/src/server/studioServer.ts). When present we ADOPT it as the
14
+ // Studio distinct_id and persist it, so CLI `cli_command*` events and the
15
+ // browser's `studio:*` / `studio_*` / render events are attributed to the same
16
+ // PostHog person. When absent (Studio opened standalone) we fall back to the
17
+ // previous per-browser localStorage id — behaviour is unchanged.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ import { generateId } from "../utils/generateId";
21
+ import { safeLocalStorage } from "../utils/safeStorage";
22
+
23
+ // Canonical storage key. Both legacy keys are kept in sync (below) so any code
24
+ // still reading them directly, plus older cached values, resolve to one id.
25
+ export const DISTINCT_ID_KEY = "hyperframes-studio:anonymousId";
26
+ // Legacy key used by utils/studioTelemetry.ts for `studio:*` events.
27
+ export const LEGACY_STUDIO_ANON_ID_KEY = "hf-studio-anon-id";
28
+
29
+ // Global injected by the CLI's embedded studio server at page load. Read-only
30
+ // from the browser's perspective.
31
+ declare global {
32
+ interface Window {
33
+ __HF_CLI_DISTINCT_ID?: string;
34
+ }
35
+ }
36
+
37
+ let cachedId: string | null = null;
38
+
39
+ /**
40
+ * The distinct_id the CLI seeded into the page, if any. A non-empty string
41
+ * means "this Studio was launched by the HyperFrames CLI, adopt its identity".
42
+ */
43
+ export function getCliDistinctId(): string | null {
44
+ try {
45
+ const id = typeof window === "undefined" ? undefined : window.__HF_CLI_DISTINCT_ID;
46
+ return typeof id === "string" && id.length > 0 ? id : null;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ // Persist to both the canonical and legacy keys so the two Studio clients and
53
+ // any cached reads converge on one id. Best-effort — private browsing / quota
54
+ // failures are non-fatal (we still return the in-memory id for this session).
55
+ function persist(ls: Storage, id: string): void {
56
+ for (const key of [DISTINCT_ID_KEY, LEGACY_STUDIO_ANON_ID_KEY]) {
57
+ try {
58
+ ls.setItem(key, id);
59
+ } catch {
60
+ /* ignore */
61
+ }
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Resolve the single Studio telemetry distinct_id.
67
+ *
68
+ * Precedence:
69
+ * 1. CLI-seeded id (`window.__HF_CLI_DISTINCT_ID`) — adopted + persisted so
70
+ * the browser session joins the CLI machine's PostHog person.
71
+ * 2. Existing persisted id (canonical or legacy key) — unchanged behaviour.
72
+ * 3. A freshly generated UUID — persisted for future loads.
73
+ *
74
+ * Memoized per module instance so repeated calls in a session are stable even
75
+ * if localStorage is unavailable.
76
+ */
77
+ export function resolveStudioDistinctId(): string {
78
+ if (cachedId) return cachedId;
79
+
80
+ const ls = safeLocalStorage();
81
+
82
+ // 1. CLI-seeded identity wins. Adopt + persist so it's stable across reloads
83
+ // and shared by every Studio telemetry path.
84
+ const cliId = getCliDistinctId();
85
+ if (cliId) {
86
+ cachedId = cliId;
87
+ if (ls) persist(ls, cliId);
88
+ return cliId;
89
+ }
90
+
91
+ // 2. Reuse an existing persisted id (prefer canonical, fall back to legacy).
92
+ if (ls) {
93
+ // getItem can throw in storage-restricted contexts (partitioned / sandboxed
94
+ // storage) even when the localStorage reference itself resolved — stay
95
+ // fail-silent (telemetry must never break Studio) and treat it as "no id".
96
+ let existing: string | null = null;
97
+ try {
98
+ existing = ls.getItem(DISTINCT_ID_KEY) ?? ls.getItem(LEGACY_STUDIO_ANON_ID_KEY);
99
+ } catch {
100
+ /* ignore */
101
+ }
102
+ if (existing) {
103
+ cachedId = existing;
104
+ // Backfill the other key so both clients agree going forward.
105
+ persist(ls, existing);
106
+ return existing;
107
+ }
108
+ } else {
109
+ // No storage at all (SSR / locked-down browser): stable within the session.
110
+ // `cachedId` is guaranteed null here (early-returned at the top otherwise).
111
+ cachedId = "anonymous";
112
+ return cachedId;
113
+ }
114
+
115
+ // 3. Mint a new id and persist it.
116
+ const id = generateId();
117
+ cachedId = id;
118
+ persist(ls, id);
119
+ return id;
120
+ }
121
+
122
+ /** Test-only: clear the memoized id so a fresh resolution can be exercised. */
123
+ export function __resetStudioDistinctIdForTests(): void {
124
+ cachedId = null;
125
+ }
@@ -0,0 +1,20 @@
1
+ // Best-effort access to Web Storage. Reading the `localStorage` /
2
+ // `sessionStorage` globals can throw (SSR, storage disabled, sandboxed or
3
+ // partitioned browsing contexts), so callers get `null` instead of an
4
+ // exception — telemetry must never break Studio.
5
+
6
+ export function safeLocalStorage(): Storage | null {
7
+ try {
8
+ return typeof localStorage === "undefined" ? null : localStorage;
9
+ } catch {
10
+ return null;
11
+ }
12
+ }
13
+
14
+ export function safeSessionStorage(): Storage | null {
15
+ try {
16
+ return typeof sessionStorage === "undefined" ? null : sessionStorage;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }