@hyperframes/studio 0.8.63 → 0.8.64

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.8.63",
3
+ "version": "0.8.64",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -51,11 +51,11 @@
51
51
  "dockview-react": "8.3.1",
52
52
  "gsap": "^3.13.0",
53
53
  "mediabunny": "^1.45.3",
54
- "@hyperframes/core": "0.8.63",
55
- "@hyperframes/player": "0.8.63",
56
- "@hyperframes/parsers": "0.8.63",
57
- "@hyperframes/sdk": "0.8.63",
58
- "@hyperframes/studio-server": "0.8.63"
54
+ "@hyperframes/core": "0.8.64",
55
+ "@hyperframes/parsers": "0.8.64",
56
+ "@hyperframes/player": "0.8.64",
57
+ "@hyperframes/sdk": "0.8.64",
58
+ "@hyperframes/studio-server": "0.8.64"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@tailwindcss/vite": "^4.3.3",
@@ -71,7 +71,7 @@
71
71
  "vite": "^6.4.2",
72
72
  "vitest": "^4.1.11",
73
73
  "zustand": "^5.0.0",
74
- "@hyperframes/producer": "0.8.63"
74
+ "@hyperframes/producer": "0.8.64"
75
75
  },
76
76
  "peerDependencies": {
77
77
  "react": "19",
@@ -440,7 +440,9 @@ describe("useSdkSession unavailable telemetry", () => {
440
440
  // so this is what "the composition genuinely is not there" looks like on the
441
441
  // wire — previously indistinguishable from a broken request. The shim and a
442
442
  // real 0-byte file are the same response, hence the name.
443
- it("separates a file that is not on disk", async () => {
443
+ // An older server sends no `missing` field, so the combined label stays —
444
+ // rather than guessing one of the two and quietly corrupting the series.
445
+ it("keeps the combined label when the server does not say which empty this is", async () => {
444
446
  vi.stubGlobal(
445
447
  "fetch",
446
448
  vi.fn(async () => ({ ok: true, json: async () => ({ content: "" }) }) as Response),
@@ -459,6 +461,81 @@ describe("useSdkSession unavailable telemetry", () => {
459
461
  await act(async () => root.unmount());
460
462
  });
461
463
 
464
+ // `missing: true` is the route's shim — nothing resolved at that path.
465
+ it("reports a file the server could not find as absent", async () => {
466
+ vi.stubGlobal(
467
+ "fetch",
468
+ vi.fn(
469
+ async () => ({ ok: true, json: async () => ({ content: "", missing: true }) }) as Response,
470
+ ),
471
+ );
472
+ const root = createRoot(document.createElement("div"));
473
+ await act(async () => root.render(<Probe projectId="project-a" />));
474
+ await flushAsyncEffects();
475
+
476
+ expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
477
+ stage: "read",
478
+ reason: "absent",
479
+ path_in_tree: null,
480
+ });
481
+ await act(async () => root.unmount());
482
+ });
483
+
484
+ // `missing: false` with empty content is a real 0-byte file on disk — a
485
+ // placeholder somebody created and has not written yet, not a bad path.
486
+ it("reports a real zero-byte file separately from an absent one", async () => {
487
+ vi.stubGlobal(
488
+ "fetch",
489
+ vi.fn(
490
+ async () => ({ ok: true, json: async () => ({ content: "", missing: false }) }) as Response,
491
+ ),
492
+ );
493
+ const root = createRoot(document.createElement("div"));
494
+ await act(async () => root.render(<Probe projectId="project-a" />));
495
+ await flushAsyncEffects();
496
+
497
+ expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
498
+ stage: "read",
499
+ reason: "empty_file",
500
+ path_in_tree: null,
501
+ });
502
+ await act(async () => root.unmount());
503
+ });
504
+
505
+ // A 200 carrying HTML is an SPA fallback or a proxy answering in the route's
506
+ // place. Before this, `res.json()` rejected outside any catch and the outer
507
+ // catch filed it as `stage: "open"` — blaming the user's composition for a
508
+ // response the composition had nothing to do with.
509
+ it("reports a non-JSON 200 as a read failure, not a composition parse failure", async () => {
510
+ vi.stubGlobal(
511
+ "fetch",
512
+ vi.fn(
513
+ async () =>
514
+ ({
515
+ ok: true,
516
+ headers: { get: () => "text/html; charset=utf-8" },
517
+ json: async () => {
518
+ throw new SyntaxError(`Unexpected token '<', "<!-- /*!"... is not valid JSON`);
519
+ },
520
+ }) as unknown as Response,
521
+ ),
522
+ );
523
+ const root = createRoot(document.createElement("div"));
524
+ await act(async () => root.render(<Probe projectId="project-a" />));
525
+ await flushAsyncEffects();
526
+
527
+ expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
528
+ stage: "read",
529
+ reason: "invalid_json",
530
+ content_type: "text/html; charset=utf-8",
531
+ });
532
+ expect(trackMock).not.toHaveBeenCalledWith(
533
+ "sdk_session_unavailable",
534
+ expect.objectContaining({ stage: "open" }),
535
+ );
536
+ await act(async () => root.unmount());
537
+ });
538
+
462
539
  // The graveyard-refuted fix's proposed next step: instrument, don't act.
463
540
  // `path_in_tree` separates "genuinely not in the loaded tree" from
464
541
  // "concurrent/duplicate open" without deciding anything on Studio's behalf.
@@ -35,16 +35,46 @@ import { addExternalFileReloadListener } from "./externalFileReloadBus";
35
35
  * before it left the browser — CSP `connect-src`, Private Network Access, an
36
36
  * extension rewriting `fetch` (rejects near-instantly, tab stays visible and
37
37
  * alive). Telemetry before this change could not tell the two apart.
38
+ *
39
+ * `absent` and `empty_file` split what `absent_or_empty` could not: the route
40
+ * answers `content: ""` both for a file it cannot find (the `optional=1` shim)
41
+ * and for a real 0-byte one, so the single label covered a composition nobody
42
+ * has written yet AND a path that does not resolve on this server. The route
43
+ * now marks the shim with `missing: true`. A server without that field still
44
+ * lands in `absent_or_empty`, so the old series stays honest rather than
45
+ * silently folding into one of the new ones.
46
+ *
47
+ * Measured on 0.8.62 before the split: 75 tabs hit this class and not one of
48
+ * them ever landed an SDK edit afterwards — it is terminal for the tab, while
49
+ * the tab itself keeps playing back and navigating, so nothing surfaces.
50
+ *
51
+ * `invalid_json` is a 200 whose body is not JSON at all — in production, an
52
+ * HTML page (`Unexpected token '<', "<!-- /*!"...`), i.e. an SPA fallback or a
53
+ * proxy answering in the route's place. `res.json()` rejected outside any
54
+ * catch until now, so it escaped this function and the effect's outer catch
55
+ * filed it as `stage: "open"` — which asserts the COMPOSITION failed to parse.
56
+ * Third label to make that same wrong claim, after `network` (#4240) and the
57
+ * fetch-rejection split; this one is the response shape, not the composition.
38
58
  */
39
59
  type ProjectFileReadFailure =
40
60
  | { ok: false; reason: "unsafe_path" }
41
61
  | { ok: false; reason: "http_error"; status: number; why?: string }
42
62
  | { ok: false; reason: "missing_content" }
43
63
  | { ok: false; reason: "absent_or_empty" }
64
+ | { ok: false; reason: "absent" }
65
+ | { ok: false; reason: "empty_file" }
66
+ | { ok: false; reason: "invalid_json"; contentType: string }
44
67
  | { ok: false; reason: "network"; elapsedMs: number; hidden: boolean };
45
68
 
46
69
  type ProjectFileReadResult = { ok: true; content: string } | ProjectFileReadFailure;
47
70
 
71
+ /** The three ways a 200 can carry no composition — old combined label plus its split. */
72
+ const EMPTY_READ_REASONS = new Set<ProjectFileReadFailure["reason"]>([
73
+ "absent_or_empty",
74
+ "absent",
75
+ "empty_file",
76
+ ]);
77
+
48
78
  /**
49
79
  * Record a read that produced no usable content, and answer which project — if
50
80
  * any — the failure identifies as unreachable.
@@ -73,12 +103,15 @@ function reportReadFailure(
73
103
  reason: read.reason,
74
104
  ...(read.reason === "http_error" ? { status: read.status, why: read.why } : {}),
75
105
  ...(read.reason === "network" ? { elapsed_ms: read.elapsedMs, hidden: read.hidden } : {}),
76
- // Only meaningful for `absent_or_empty` — the graveyard-refuted "clear
106
+ ...(read.reason === "invalid_json" ? { content_type: read.contentType } : {}),
107
+ // Only meaningful for the empty-read reasons — the graveyard-refuted "clear
77
108
  // activeCompPath when it's not in the tree" fix's proposed next step,
78
109
  // scoped to instrumentation only. `null` while the tree hasn't loaded
79
110
  // yet: a false-negative there would read as "genuinely absent" when it is
80
- // really "haven't looked".
81
- ...(read.reason === "absent_or_empty" ? { path_in_tree: pathInTree } : {}),
111
+ // really "haven't looked". Carried on all three so the split keeps the
112
+ // signal that made it worth splitting: every measured case so far is
113
+ // `path_in_tree: true`, a file the tree lists and this read cannot get.
114
+ ...(EMPTY_READ_REASONS.has(read.reason) ? { path_in_tree: pathInTree } : {}),
82
115
  });
83
116
  if (read.reason !== "http_error") return null;
84
117
  return read.status === 404 ? projectId : null;
@@ -118,6 +151,30 @@ async function httpReadFailureWhy(res: Response): Promise<string | undefined> {
118
151
  return typeof body?.why === "string" ? body.why : undefined;
119
152
  }
120
153
 
154
+ interface ProjectFileBody {
155
+ content?: string;
156
+ /** Present only from a server that separates its shim from a real 0-byte file. */
157
+ missing?: boolean;
158
+ }
159
+
160
+ /**
161
+ * Decide what a 200 with a JSON body actually delivered.
162
+ *
163
+ * `optional=1` answers a missing file with 200 + `content: ""`, so a non-string
164
+ * content is a response shape we did not expect, not absence. An empty string
165
+ * parses into a session with no elements, which declines every edit wholesale —
166
+ * not a session worth opening — and `missing` is the route saying which of the
167
+ * two empties it was. Without that field (older server) the combined label
168
+ * stands rather than guessing one and corrupting the series.
169
+ */
170
+ function classifyProjectFileBody(data: ProjectFileBody): ProjectFileReadResult {
171
+ if (typeof data.content !== "string") return { ok: false, reason: "missing_content" };
172
+ if (data.content !== "") return { ok: true, content: data.content };
173
+ if (data.missing === true) return { ok: false, reason: "absent" };
174
+ if (data.missing === false) return { ok: false, reason: "empty_file" };
175
+ return { ok: false, reason: "absent_or_empty" };
176
+ }
177
+
121
178
  /**
122
179
  * Read a project file's content (optional read — a missing file is not an
123
180
  * error). Replaces the removed SDK http adapter's `read()` — the only thing
@@ -146,16 +203,21 @@ async function readProjectFileOptional(
146
203
  const why = await httpReadFailureWhy(res);
147
204
  return { ok: false, reason: "http_error", status: res.status, ...(why ? { why } : {}) };
148
205
  }
149
- const data = (await res.json()) as { content?: string };
150
- // `optional=1` answers a missing file with 200 + `content: ""`, so a
151
- // non-string here means a response shape we did not expect, not absence.
152
- if (typeof data.content !== "string") return { ok: false, reason: "missing_content" };
153
- // An empty body parses into a session with no elements, which declines every
154
- // edit wholesale — not a session worth opening. The absent-file shim and a
155
- // genuinely 0-byte file are the same 200 on the wire and cannot be told
156
- // apart here, hence the name; for a composition it is always the former.
157
- if (data.content === "") return { ok: false, reason: "absent_or_empty" };
158
- return { ok: true, content: data.content };
206
+ // A 200 that is not JSON is a different failure from a JSON body missing its
207
+ // field: it means something other than this route answered — an SPA fallback
208
+ // or a proxy. Left unguarded, it rejected out of this function entirely and
209
+ // the effect's outer catch blamed the composition. See the type's comment.
210
+ let data: ProjectFileBody;
211
+ try {
212
+ data = (await res.json()) as ProjectFileBody;
213
+ } catch {
214
+ return {
215
+ ok: false,
216
+ reason: "invalid_json",
217
+ contentType: res.headers.get("content-type") ?? "",
218
+ };
219
+ }
220
+ return classifyProjectFileBody(data);
159
221
  }
160
222
 
161
223
  /**