diff --git a/package.json b/package.json index 9c19ebd..24c906d 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "release": "pnpm run build && changeset publish" }, "peerDependencies": { - "@graphrefly/ts": ">=0.6.2 <1.0.0", + "@graphrefly/ts": ">=0.6.4 <1.0.0", "canvas": "^3.2.3" }, "peerDependenciesMeta": { @@ -58,7 +58,7 @@ "@biomejs/biome": "2.4.6", "@changesets/changelog-git": "^0.2.1", "@changesets/cli": "^2.31.0", - "@graphrefly/ts": "0.6.2", + "@graphrefly/ts": "0.6.4", "@types/node": "^25.5.0", "tsup": "^8.5.1", "typescript": "^5.7.0", @@ -68,8 +68,5 @@ "access": "public" }, "packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620", - "engines": { - "node": ">=24" - }, "license": "MIT" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8b11dd..7f6d8bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^2.31.0 version: 2.31.1(@types/node@25.9.5) '@graphrefly/ts': - specifier: 0.6.2 - version: 0.6.2 + specifier: 0.6.4 + version: 0.6.4 '@types/node': specifier: ^25.5.0 version: 25.9.5 @@ -316,8 +316,8 @@ packages: cpu: [x64] os: [win32] - '@graphrefly/ts@0.6.2': - resolution: {integrity: sha512-GJDRo8zTAiVS1IW7a9fmatWA+de9DklTnboWQDCRa8oY3g656Ff4atD8bjlJ9vcSqxzcoylsoxo1uVLJwfELnA==} + '@graphrefly/ts@0.6.4': + resolution: {integrity: sha512-+1TcgljcaIGkHIPWPPQeM/06WaVb1xBquJkCboC+bt5VBM0zNgaPQAHSWbqNr0BPWPdgTheiCEwT4Cid/HkBmg==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 @@ -1672,7 +1672,7 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true - '@graphrefly/ts@0.6.2': {} + '@graphrefly/ts@0.6.4': {} '@inquirer/external-editor@1.0.3(@types/node@25.9.5)': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5ec5160..63b555a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,7 +5,7 @@ allowBuilds: canvas: false esbuild: true minimumReleaseAgeExclude: - - '@graphrefly/ts@0.0.1||0.6.2' + - '@graphrefly/ts@0.0.1||0.6.3||0.6.4' # The concrete `canvas` runtime is caller-owned and optional. CI exercises the # integration through an injected fake package instead of downloading/building diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs index e258965..3270eb2 100644 --- a/scripts/check-package.mjs +++ b/scripts/check-package.mjs @@ -15,33 +15,15 @@ import { dirname, join, resolve } from "node:path"; const ROOT = resolve(import.meta.dirname, ".."); const packageRoot = resolve(process.argv[2] ?? "."); const packageJson = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")); +const EXPECTED_PACKAGE_NAME = "@graphrefly/reactive-layout-node-canvas"; +const EXPECTED_REPOSITORY = + "git+https://github.com/graphrefly/graphrefly-reactive-layout-node-canvas.git"; const expectedEntries = { - "@graphrefly/nestjs": { - ".": ["GraphReq", "createNestGraphBoundaryRunner", "fromNestReq", "getNestBoundaryBindings"], - "./microservices": ["GraphMessage", "createGraphMessageBridge", "provideGraphMessageProviders"], - "./native": [ - "createGraphExceptionFilter", - "createNestGraphGuardAwaitScope", - "provideGraphNativeProviders", - ], - "./websockets": ["GraphWs", "createGraphWsBridge", "provideGraphWsProviders"], - }, - "@graphrefly/reactive-layout-node-canvas": { - ".": ["nodeCanvasPackageTextMeasurements"], - }, - "@graphrefly/solid": { - ".": ["createNodeInput", "createNodeRecord", "createNodeValue"], - }, - "@graphrefly/svelte": { - ".": ["nodeReadable", "nodeRecord", "nodeWritable"], - }, - "@graphrefly/vue": { - ".": ["useNodeInput", "useNodeRecord", "useNodeValue"], - }, -}[packageJson.name]; + ".": ["nodeCanvasPackageTextMeasurements"], +}; function fail(message) { - throw new Error(`check-ecosystem-package: ${message}`); + throw new Error(`check-node-canvas-package: ${message}`); } function assert(condition, message) { @@ -57,12 +39,28 @@ function resolveInstalledPackage(name) { return undefined; } -assert(expectedEntries, `unknown ecosystem package: ${packageJson.name}`); +assert(packageJson.name === EXPECTED_PACKAGE_NAME, `unexpected package name: ${packageJson.name}`); +assert( + packageJson.repository?.url === EXPECTED_REPOSITORY, + "repository authority must be standalone", +); assert(packageJson.dependencies === undefined, "package must not declare runtime dependencies"); assert( packageJson.optionalDependencies === undefined, "package must not declare optional dependencies", ); +assert( + JSON.stringify(packageJson.peerDependencies) === + JSON.stringify({ + "@graphrefly/ts": ">=0.6.4 <1.0.0", + canvas: "^3.2.3", + }), + "package peer dependencies do not match the reviewed integration boundary", +); +assert( + packageJson.peerDependenciesMeta?.canvas?.optional === true, + "canvas must remain an optional peer", +); assert(packageJson.sideEffects === false, "sideEffects must be false"); assert( @@ -139,24 +137,68 @@ try { const nodeTypesLink = join(tmp, "node_modules", "@types", "node"); mkdirSync(dirname(nodeTypesLink), { recursive: true }); symlinkSync(nodeTypes, nodeTypesLink, "dir"); - if (packageJson.name === "@graphrefly/reactive-layout-node-canvas") { - const canvasInstall = join(tmp, "node_modules", "canvas"); - mkdirSync(canvasInstall, { recursive: true }); - writeFileSync( - join(canvasInstall, "package.json"), - JSON.stringify({ name: "canvas", version: "3.2.3", main: "index.cjs" }), - ); - writeFileSync( - join(canvasInstall, "index.cjs"), - `exports.createCanvas = () => ({ + + writeFileSync( + join(tmp, "missing-canvas-esm.mjs"), + `import assert from "node:assert/strict"; +import { graph } from "@graphrefly/ts"; +import { nodeCanvasPackageTextMeasurements } from "@graphrefly/reactive-layout-node-canvas"; +const g = graph(); +const measured = nodeCanvasPackageTextMeasurements({ + graph: g, + text: g.state("abc"), + font: g.state("10px test"), +}); +const messages = []; +const unsubscribe = measured.subscribe((message) => messages.push(message)); +assert.equal(messages.some((message) => message[0] === "ERROR"), false); +assert.match(JSON.stringify(messages), /"code":"measurement.failed"/); +unsubscribe(); +`, + ); + writeFileSync( + join(tmp, "missing-canvas-cjs.cjs"), + `const assert = require("node:assert/strict"); +const { graph } = require("@graphrefly/ts"); +const { nodeCanvasPackageTextMeasurements } = require("@graphrefly/reactive-layout-node-canvas"); +const g = graph(); +const measured = nodeCanvasPackageTextMeasurements({ + graph: g, + text: g.state("abc"), + font: g.state("10px test"), +}); +const messages = []; +const unsubscribe = measured.subscribe((message) => messages.push(message)); +assert.equal(messages.some((message) => message[0] === "ERROR"), false); +assert.match(JSON.stringify(messages), /"code":"measurement.failed"/); +unsubscribe(); +`, + ); + execFileSync(process.execPath, [join(tmp, "missing-canvas-esm.mjs")], { + cwd: externalCwd, + stdio: "pipe", + }); + execFileSync(process.execPath, [join(tmp, "missing-canvas-cjs.cjs")], { + cwd: externalCwd, + stdio: "pipe", + }); + + const canvasInstall = join(tmp, "node_modules", "canvas"); + mkdirSync(canvasInstall, { recursive: true }); + writeFileSync( + join(canvasInstall, "package.json"), + JSON.stringify({ name: "canvas", version: "3.2.3", main: "index.cjs" }), + ); + writeFileSync( + join(canvasInstall, "index.cjs"), + `exports.createCanvas = () => ({ getContext: () => ({ font: "", measureText: (text) => ({ width: text.length * 7 }), }), }); `, - ); - } + ); writeFileSync( join(tmp, "esm-smoke.mjs"), @@ -220,10 +262,9 @@ for (const [subpath, expected] of Object.entries(expectedEntries)) { cwd: tmp, stdio: "inherit", }); - if (packageJson.name === "@graphrefly/reactive-layout-node-canvas") { - writeFileSync( - join(tmp, "node-canvas-lazy.mjs"), - `import assert from "node:assert/strict"; + writeFileSync( + join(tmp, "node-canvas-measure-esm.mjs"), + `import assert from "node:assert/strict"; import { graph } from "@graphrefly/ts"; import { nodeCanvasPackageTextMeasurements } from "@graphrefly/reactive-layout-node-canvas"; const g = graph(); @@ -237,12 +278,35 @@ const unsubscribe = measured.subscribe((message) => messages.push(message)); assert.match(JSON.stringify(messages), /"width":21/); unsubscribe(); `, - ); - execFileSync(process.execPath, [join(tmp, "node-canvas-lazy.mjs")], { - cwd: externalCwd, - stdio: "pipe", - }); - } + ); + writeFileSync( + join(tmp, "node-canvas-measure-cjs.cjs"), + `const assert = require("node:assert/strict"); +const { graph } = require("@graphrefly/ts"); +const { nodeCanvasPackageTextMeasurements } = require("@graphrefly/reactive-layout-node-canvas"); +const g = graph(); +const measured = nodeCanvasPackageTextMeasurements({ + graph: g, + text: g.state("abcd"), + font: g.state("10px test"), +}); +const messages = []; +const unsubscribe = measured.subscribe((message) => messages.push(message)); +const serialized = JSON.stringify(messages); +if (!/"width":28/.test(serialized)) { + throw messages[1]?.[1]?.[0]?.details ?? new Error(serialized); +} +unsubscribe(); +`, + ); + execFileSync(process.execPath, [join(tmp, "node-canvas-measure-esm.mjs")], { + cwd: externalCwd, + stdio: "pipe", + }); + execFileSync(process.execPath, [join(tmp, "node-canvas-measure-cjs.cjs")], { + cwd: externalCwd, + stdio: "pipe", + }); } finally { rmSync(tmp, { recursive: true, force: true }); rmSync(externalCwd, { recursive: true, force: true }); diff --git a/src/index.test.ts b/src/index.test.ts index f23202e..0d3b762 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -35,4 +35,26 @@ describe("@graphrefly/reactive-layout-node-canvas", () => { expect(JSON.stringify(messages)).toContain('"width":15'); unsubscribe(); }); + + it("emits a bounded DATA issue and deactivates cleanly when measurement fails", () => { + const g = graph(); + const measured = nodeCanvasPackageTextMeasurements({ + graph: g, + text: g.state("abc"), + font: g.state("10px package"), + canvas: { + createCanvas() { + throw new Error("canvas unavailable"); + }, + }, + }); + const messages: unknown[] = []; + const unsubscribe = measured.subscribe((message) => messages.push(message)); + + expect(messages.some((message) => Array.isArray(message) && message[0] === "ERROR")).toBe( + false, + ); + expect(JSON.stringify(messages)).toContain('"code":"measurement.failed"'); + expect(unsubscribe).not.toThrow(); + }); }); diff --git a/src/index.ts b/src/index.ts index 340d134..8ce9876 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,10 @@ import { createRequire } from "node:module"; import type { Graph, Node } from "@graphrefly/ts"; import { - capabilityTextMeasurements, + type MeasurementAdapter, type Measurements, type SegmentAdapter, - type TextMeasureCapability, + textMeasurementProvider, } from "@graphrefly/ts/solutions/reactive-layout"; export interface NodeCanvasTextContextLike { @@ -46,7 +46,7 @@ function loadCanvasPackage(): NodeCanvasPackageLike { } } -class NodeCanvasPackageTextCapability implements TextMeasureCapability { +class NodeCanvasPackageMeasurementAdapter implements MeasurementAdapter { private context: NodeCanvasTextContextLike | null = null; constructor( @@ -67,7 +67,7 @@ class NodeCanvasPackageTextCapability implements TextMeasureCapability { return context; } - measureText(text: string, font: string): { readonly width: number } { + measureSegment(text: string, font: string): { readonly width: number } { const context = this.getContext(); const previousFont = context.font; context.font = font; @@ -83,19 +83,19 @@ export function nodeCanvasPackageTextMeasurements( opts: NodeCanvasPackageTextMeasurementsOptions, ): Node { const targetId = opts.targetId ?? "text"; - const capability = opts.graph.state( - new NodeCanvasPackageTextCapability(opts.canvas, opts.width ?? 0, opts.height ?? 0), + const adapter = opts.graph.state( + new NodeCanvasPackageMeasurementAdapter(opts.canvas, opts.width ?? 0, opts.height ?? 0), { name: opts.name - ? `${opts.name}:node-canvas-measure-capability` - : `${targetId}-node-canvas-measure-capability`, + ? `${opts.name}:node-canvas-measure-adapter` + : `${targetId}-node-canvas-measure-adapter`, }, ); - return capabilityTextMeasurements({ + return textMeasurementProvider({ graph: opts.graph, text: opts.text, font: opts.font, - capability, + adapter, segmentAdapter: opts.segmentAdapter, targetId: opts.targetId, source: opts.source ?? "nodeCanvasPackageTextMeasurements", diff --git a/tsconfig.json b/tsconfig.json index 17c2cba..e646b91 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,5 @@ "strict": true, "skipLibCheck": true }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] + "include": ["src"] }