diff --git a/packages/studio/src/shims/canvasBrowserStub.js b/packages/studio/src/shims/canvasBrowserStub.js new file mode 100644 index 0000000000..005ba0d1c2 --- /dev/null +++ b/packages/studio/src/shims/canvasBrowserStub.js @@ -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 `` + * 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..2f4185588f --- /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 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 + * 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,