From d5e1de92018cd8dd9b5d3d02e8c946640b1e12e7 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Fri, 18 Sep 2026 17:38:39 -0700 Subject: [PATCH 1/2] fix(studio): a composition with a no longer kills the SDK session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit linkedom's HTMLCanvasElement constructor calls createCanvas(300, 150) from the Node-only `canvas` package, behind this guard: try { module.exports = require('canvas') } catch { module.exports = require('./canvas-shim.cjs') } The guard only works with a runtime require. Vite resolves the specifier statically, so the bundled form is `try { exports = } catch {...}` — a plain assignment that cannot throw. The catch never runs, linkedom's own shim is bundled but unreachable, and createCanvas lands undefined. Verified in the built artifact: the canvas module namespace carried only a `default` export. Parsing any tag therefore threw inside openComposition, so Studio lost its SDK session for the entire composition. Every edit then fell back to the server path, and the resolver shadow did not run either, which is why this was invisible before v0.8.47 added studio:sdk_session_unavailable. In the 72h after the flip it accounted for 1065 of 1544 session-open failures, across a large share of 197 users. Canvas is common in compositions: Three.js, shaders, particle effects, charts. Aliases `canvas` to a local browser stub that mirrors linkedom's shim (measurement-only; nothing in the SDK rasterizes through it). Reimplemented locally rather than pointing at linkedom's commonjs/canvas-shim.cjs, which its exports map does not expose. Confirmed against a rebuild: the namespace now carries createCanvas, so the call returns a stub instead of throwing. Note the unit tests here pass with or without the fix — vitest resolves the require at runtime, so only the bundled build was ever broken. The regression guard therefore pins the alias itself, since nothing else in the suite can observe the bundler. Co-Authored-By: Claude Opus 5 (1M context) --- .../studio/src/shims/canvasBrowserStub.js | 51 ++++++++++++ .../src/utils/sdkCanvasComposition.test.ts | 77 +++++++++++++++++++ packages/studio/vite.config.ts | 7 ++ 3 files changed, 135 insertions(+) create mode 100644 packages/studio/src/shims/canvasBrowserStub.js create mode 100644 packages/studio/src/utils/sdkCanvasComposition.test.ts diff --git a/packages/studio/src/shims/canvasBrowserStub.js b/packages/studio/src/shims/canvasBrowserStub.js new file mode 100644 index 0000000000..0a9153e789 --- /dev/null +++ b/packages/studio/src/shims/canvasBrowserStub.js @@ -0,0 +1,51 @@ +/** + * Browser stub for the Node-only `canvas` package. + * + * linkedom's `HTMLCanvasElement` constructor calls `createCanvas(300, 150)` + * (300x150 being the HTML canvas default size). linkedom guards that import: + * + * try { module.exports = require('canvas') } + * catch { module.exports = require('./canvas-shim.cjs') } + * + * That guard only works with a *runtime* require. Vite resolves the specifier + * statically, so the bundled form is a plain assignment that cannot throw, the + * catch never runs, and `createCanvas` lands `undefined`. Parsing any `` + * tag then throws inside `openComposition`, Studio loses its SDK session for the + * whole composition, and every edit silently falls back to the server path — the + * resolver shadow does not run either, so it was invisible until + * `studio:sdk_session_unavailable` shipped in v0.8.47. + * + * Production telemetry over the 72h after the flip: 1065 of 1544 session-open + * failures were this crash, across a large share of 197 users. Canvas is common + * in compositions (Three.js, shaders, particle effects, charts). + * + * Aliasing `canvas` to this file makes the browser build resolve to a + * measurement-only canvas, which is all linkedom's static parse needs — nothing + * in the SDK rasterizes through it. It mirrors linkedom's own + * `commonjs/canvas-shim.cjs`, reimplemented here because linkedom's `exports` + * map does not expose that file. + */ + +class BrowserCanvasStub { + constructor(width, height) { + this.width = width; + this.height = height; + } + /** linkedom only ever needs the element to exist; nothing draws through it. */ + getContext() { + return null; + } + toDataURL() { + return ""; + } +} + +export function createCanvas(width, height) { + return new BrowserCanvasStub(width, height); +} + +// linkedom consumes this as a DEFAULT import — `import Canvas from +// '../../commonjs/canvas.cjs'` then `const {createCanvas} = Canvas` — so the +// default export is the load-bearing one; the named export above is interop +// belt-and-braces. +export default { createCanvas }; diff --git a/packages/studio/src/utils/sdkCanvasComposition.test.ts b/packages/studio/src/utils/sdkCanvasComposition.test.ts new file mode 100644 index 0000000000..07097af1da --- /dev/null +++ b/packages/studio/src/utils/sdkCanvasComposition.test.ts @@ -0,0 +1,77 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { openComposition } from "@hyperframes/sdk"; + +/** + * linkedom's HTMLCanvasElement constructor calls `createCanvas(300, 150)` from + * the Node-only `canvas` package. linkedom guards that import with + * `try { require('canvas') } catch { require('./canvas-shim.cjs') }` — but a + * bundler resolves the require statically, so the catch never fires in the + * browser build and `createCanvas` lands undefined. Parsing any `` tag + * then throws, `openComposition` rejects, and Studio silently loses its SDK + * session for the whole composition (no cutover, and the resolver shadow does + * not run either). + * + * Production telemetry, 72h after the v0.8.47 flip: 1065 of 1544 + * `studio:sdk_session_unavailable` events were this crash, spread across a + * large share of 197 users. Canvas is common in compositions — Three.js, + * shaders, particle effects, charts. + * + * The fix is a `resolve.alias` in the studio's vite config pointing `canvas` at + * linkedom's own shim. This test pins the behaviour the alias has to preserve. + */ +const CANVAS_COMPOSITION = ` + + +
+ +
+ +`; + +describe("openComposition with a element", () => { + it("opens a session instead of throwing on createCanvas", async () => { + const session = await openComposition(CANVAS_COMPOSITION, { history: false }); + expect(session).toBeTruthy(); + session.dispose(); + }); + + it("models the canvas element so edits can target it", async () => { + const session = await openComposition(CANVAS_COMPOSITION, { history: false }); + // A composition whose elements cannot be modelled is a session the cutover + // path declines wholesale (sessionEmpty), which is the same silent bypass by + // a different route. + expect(session.getElements().length).toBeGreaterThan(0); + session.dispose(); + }); +}); + +describe("canvas browser-stub alias", () => { + // The two tests above pass with OR without the fix: vitest resolves + // `require('canvas')` at runtime, so linkedom's own try/catch fallback works + // here. Only the bundled build breaks. Nothing else in this suite can observe + // the bundler, so the alias itself is what has to be pinned — without this, + // deleting the alias reintroduces a crash that is invisible until it reaches + // production telemetry. + it("is configured, so the browser build never resolves the Node canvas package", () => { + const config = readFileSync(resolve(import.meta.dirname, "../../vite.config.ts"), "utf8"); + expect(config).toMatch( + /\bcanvas:\s*resolve\(__dirname,\s*"src\/shims\/canvasBrowserStub\.js"\)/, + ); + }); + + it("exports the createCanvas shape linkedom destructures", async () => { + const stub = await import("../shims/canvasBrowserStub.js"); + // linkedom takes the DEFAULT import and destructures createCanvas off it + // (`import Canvas from '...'; const {createCanvas} = Canvas`), so the + // default export is the one that actually has to carry the function. + expect(typeof stub.default.createCanvas).toBe("function"); + expect(typeof stub.createCanvas).toBe("function"); + const canvas = stub.default.createCanvas(300, 150); + expect(canvas.width).toBe(300); + expect(canvas.height).toBe(150); + expect(canvas.getContext()).toBeNull(); + }); +}); diff --git a/packages/studio/vite.config.ts b/packages/studio/vite.config.ts index a1664691b4..38f4e8fb81 100644 --- a/packages/studio/vite.config.ts +++ b/packages/studio/vite.config.ts @@ -244,6 +244,13 @@ export default defineConfig({ }, resolve: { alias: { + // linkedom's HTMLCanvasElement constructor calls createCanvas(300, 150) + // from the Node-only `canvas` package, behind a + // `try { require('canvas') } catch { shim }` guard. A bundler resolves + // that require statically, so the catch never fires and createCanvas is + // undefined — every composition containing a then throws inside + // openComposition and silently loses its SDK session. See the stub. + canvas: resolve(__dirname, "src/shims/canvasBrowserStub.js"), "@hyperframes/player": resolve(__dirname, "../player/src/hyperframes-player.ts"), "@hyperframes/studio-server/source-mutation": resolve( __dirname, From db78b4fe68c91397a9a93453d91058c05c488a45 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Fri, 18 Sep 2026 18:00:28 -0700 Subject: [PATCH 2/2] fix(studio): keep a literal relative require out of the canvas-stub docs verify-packed-manifests scans packed JavaScript for relative import specifiers with a set of regexes that do not strip comments, so the `require('./canvas-shim.cjs')` inside the stub's doc comment read as a real import of a file that is not in the studio tarball, and the Build job failed. Describes linkedom's try/catch guard in prose instead. The same literal was in the test file's header comment; CI only reported the first match, so both are fixed here. Co-Authored-By: Claude Opus 5 (1M context) --- packages/studio/src/shims/canvasBrowserStub.js | 7 +++---- .../studio/src/utils/sdkCanvasComposition.test.ts | 14 +++++++------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/studio/src/shims/canvasBrowserStub.js b/packages/studio/src/shims/canvasBrowserStub.js index 0a9153e789..005ba0d1c2 100644 --- a/packages/studio/src/shims/canvasBrowserStub.js +++ b/packages/studio/src/shims/canvasBrowserStub.js @@ -2,10 +2,9 @@ * Browser stub for the Node-only `canvas` package. * * linkedom's `HTMLCanvasElement` constructor calls `createCanvas(300, 150)` - * (300x150 being the HTML canvas default size). linkedom guards that import: - * - * try { module.exports = require('canvas') } - * catch { module.exports = require('./canvas-shim.cjs') } + * (300x150 being the HTML canvas default size). linkedom guards that import in + * a try/catch: the try requires the native `canvas` package, and the catch + * falls back to linkedom's own bundled canvas shim. * * That guard only works with a *runtime* require. Vite resolves the specifier * statically, so the bundled form is a plain assignment that cannot throw, the diff --git a/packages/studio/src/utils/sdkCanvasComposition.test.ts b/packages/studio/src/utils/sdkCanvasComposition.test.ts index 07097af1da..2f4185588f 100644 --- a/packages/studio/src/utils/sdkCanvasComposition.test.ts +++ b/packages/studio/src/utils/sdkCanvasComposition.test.ts @@ -6,13 +6,13 @@ import { openComposition } from "@hyperframes/sdk"; /** * linkedom's HTMLCanvasElement constructor calls `createCanvas(300, 150)` from - * the Node-only `canvas` package. linkedom guards that import with - * `try { require('canvas') } catch { require('./canvas-shim.cjs') }` — but a - * bundler resolves the require statically, so the catch never fires in the - * browser build and `createCanvas` lands undefined. Parsing any `` tag - * then throws, `openComposition` rejects, and Studio silently loses its SDK - * session for the whole composition (no cutover, and the resolver shadow does - * not run either). + * the Node-only `canvas` package. linkedom guards that import in a try/catch — + * try the native package, fall back to its own bundled shim — but a bundler + * resolves the require statically, so the catch never fires in the browser + * build and `createCanvas` lands undefined. Parsing any `` tag then + * throws, `openComposition` rejects, and Studio silently loses its SDK session + * for the whole composition (no cutover, and the resolver shadow does not run + * either). * * Production telemetry, 72h after the v0.8.47 flip: 1065 of 1544 * `studio:sdk_session_unavailable` events were this crash, spread across a