Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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",
Expand All @@ -68,8 +68,5 @@
"access": "public"
},
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
"engines": {
"node": ">=24"
},
"license": "MIT"
}
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
158 changes: 111 additions & 47 deletions scripts/check-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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(
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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();
Expand All @@ -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 });
Expand Down
22 changes: 22 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
20 changes: 10 additions & 10 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -46,7 +46,7 @@ function loadCanvasPackage(): NodeCanvasPackageLike {
}
}

class NodeCanvasPackageTextCapability implements TextMeasureCapability {
class NodeCanvasPackageMeasurementAdapter implements MeasurementAdapter {
private context: NodeCanvasTextContextLike | null = null;

constructor(
Expand All @@ -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;
Expand All @@ -83,19 +83,19 @@ export function nodeCanvasPackageTextMeasurements(
opts: NodeCanvasPackageTextMeasurementsOptions,
): Node<Measurements> {
const targetId = opts.targetId ?? "text";
const capability = opts.graph.state<TextMeasureCapability>(
new NodeCanvasPackageTextCapability(opts.canvas, opts.width ?? 0, opts.height ?? 0),
const adapter = opts.graph.state<MeasurementAdapter>(
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",
Expand Down
3 changes: 1 addition & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,5 @@
"strict": true,
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
"include": ["src"]
}