Merge pull request #4139 from heygen-com/sdk-cutover-decline-reason

fix(studio): a composition with a <canvas> no longer kills the SDK session
This commit is contained in:
Vance Ingalls
2026-09-18 19:39:22 -07:00
committed by GitHub
3 changed files with 134 additions and 0 deletions
@@ -0,0 +1,50 @@
/**
* 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 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
* catch never runs, and `createCanvas` lands `undefined`. Parsing any `<canvas>`
* 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 };
@@ -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 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 `<canvas>` 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 = `<!doctype html>
<html>
<body data-composition-id="main" data-width="1920" data-height="1080">
<div class="clip" data-start="0" data-duration="3">
<canvas id="scene" width="800" height="600"></canvas>
</div>
</body>
</html>`;
describe("openComposition with a <canvas> 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();
});
});
+7
View File
@@ -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 <canvas> 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,