odiff-bin
Version:
The fastest image difference tool in the world (Zig port of odiff)
386 lines (343 loc) • 11.6 kB
JavaScript
// @ts-check
const { spawn } = require("child_process");
const path = require("path");
const readline = require("readline");
class ODiffServer {
/**
* Create an ODiffServer instance
* Server initialization starts immediately and is awaited automatically in compare()
* @param {string | undefined} [binaryPath] - Optional path to odiff binary (defaults to bin/odiff.exe)
*/
constructor(binaryPath) {
this.binaryPath = binaryPath || path.join(__dirname, "bin", "odiff.exe");
this.process = null;
this.ready = false;
this.pendingRequests = new Map();
this.requestId = 0;
this.exiting = false;
this.writeLock = Promise.resolve();
// Start server initialization immediately
/** @type {Promise | null} */
this._initPromise = this._initialize();
}
/** @returns {Promise<(value?: unknown) => unknown>} */
async _acquireWriteLock() {
const currentLock = this.writeLock;
/** @type {(value?: unknown) => unknown} */
let releaseLock;
this.writeLock = new Promise((resolve) => {
releaseLock = resolve;
});
await currentLock;
return releaseLock;
}
/**
* This is internal node js bs handling a separate buffer for stdin
* that can be overflown or underflow, so we have to wait for it to
* process the data otherwise the data corruption can happen
* @private
* @param {string | Buffer} data - Data to write
* @returns {Promise<void>}
*/
async _writeWithBackpressure(data) {
const noDrainNeeded = this.process?.stdin.write(data);
if (!noDrainNeeded) {
await new Promise((resolve, reject) => {
const stdin = this.process?.stdin;
if (!stdin) {
reject(new Error("Process stdin not available"));
return;
}
let settled = false;
stdin.once("drain", () => {
if (!settled) {
settled = true;
resolve(undefined);
}
});
stdin.once("error", (err) => {
if (!settled) {
settled = true;
reject(err);
}
});
stdin.once("close", () => {
if (!settled) {
settled = true;
reject(new Error("Stream closed before drain"));
}
});
});
}
}
/**
* Internal method to initialize the server process
* @private
*/
async _initialize() {
if (this.process) return;
return new Promise((resolve, reject) => {
try {
this.process = spawn(this.binaryPath, ["--server"], {
stdio: ["pipe", "pipe", "pipe"],
});
this.process.on("error", (err) => {
this._initPromise = null;
reject(new Error(`Failed to start odiff server: ${err.message}`));
});
this.process.on("exit", (code) => {
if (!this.exiting) {
console.warn(`odiff server exited unexpectedly with code ${code}`);
// Reset for potential restart
this._initPromise = null;
}
this.cleanup();
});
const rl = readline.createInterface({
input: this.process.stdout,
crlfDelay: Infinity,
});
rl.on("line", (line) => {
try {
const response = JSON.parse(line);
// Handle ready signal
if (response.ready && !this.ready) {
this.ready = true;
resolve(undefined);
return;
}
// Handle responses with request ID matching
if (!("requestId" in response)) {
throw new Error("odiff: received message without requestId");
}
const pending = this.pendingRequests.get(response.requestId);
if (pending) {
this.pendingRequests.delete(response.requestId);
if (pending.timeoutId !== undefined) {
clearTimeout(pending.timeoutId);
}
// Reject if response contains an error, otherwise resolve
if (response.error) {
pending.reject(new Error(response.error));
} else {
pending.resolve(response);
}
} else {
console.warn(
`Received response for unknown request ID: ${response.requestId}`,
);
}
} catch (err) {
console.error("Failed to parse server response:", line, err);
}
});
if (!process.env.CI) {
setTimeout(() => {
if (!this.ready) {
this.stop();
this._initPromise = null;
reject(
new Error("odiff: server failed to start within 5 seconds"),
);
}
}, 5000);
}
} catch (err) {
this._initPromise = null;
reject(err);
}
});
}
/**
* Compare two images using the persistent server
* Automatically waits for server initialization if needed
*
* @param {string} basePath - Path to base image
* @param {string} comparePath - Path to comparison image
* @param {string} diffOutput - Path to output diff image
* @param {import("./odiff.d.ts").ODiffOptions & { timeout?: number }} [options] - Comparison options
* @returns {Promise<import("./odiff.d.ts").ODiffResult>} Comparison result
*/
async compare(basePath, comparePath, diffOutput, options = {}) {
if (this._initPromise && !this.ready) {
await this._initPromise;
}
// If server died and _initPromise was reset, reinitialize
if (!this._initPromise && !this.ready) {
this._initPromise = this._initialize();
await this._initPromise;
}
const requestId = this.requestId++;
let timeoutId;
const resultPromise = new Promise((resolve, reject) => {
if (options.timeout !== undefined) {
timeoutId = setTimeout(() => {
if (this.pendingRequests.has(requestId)) {
this.pendingRequests.delete(requestId);
reject(
new Error(`odiff: Request timed out after ${options.timeout}ms`),
);
}
}, options.timeout);
}
this.pendingRequests.set(requestId, { resolve, reject, timeoutId });
});
const request = {
requestId: requestId,
base: basePath,
compare: comparePath,
output: diffOutput,
options: {
threshold: options.threshold,
failOnLayoutDiff: options.failOnLayoutDiff,
antialiasing: options.antialiasing,
captureDiffLines: options.captureDiffLines,
outputDiffMask: options.outputDiffMask,
ignoreRegions: options.ignoreRegions,
diffColor: options.diffColor,
diffOverlay: options.diffOverlay,
},
};
// Acquire write lock to prevent concurrent requests from interleaving
const release = await this._acquireWriteLock();
try {
await this._writeWithBackpressure(JSON.stringify(request) + "\n");
} catch (err) {
this.pendingRequests.delete(requestId);
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
throw new Error(`odiff: Failed to send request: ${err.message}`);
} finally {
release();
}
return resultPromise;
}
/**
* Compare two images buffers, the buffer data is the actual encoded file bytes.
* **Important**: Always prefer file paths compare if you are saving images to disk anyway.
*
* @param {Buffer} baseBuffer - Buffer containing base image data
* @param {"png" | "jpeg" | "bmp" | "tiff" | "webp"} baseFormat - Format: "png", "jpeg", "bmp", "tiff", "webp"
* @param {Buffer} compareBuffer - Buffer containing compare image data
* @param {"png" | "jpeg" | "bmp" | "tiff" | "webp"} compareFormat - Format of compare image
* @param {string} diffOutput - Path to output diff image
* @param {import("./odiff.d.ts").ODiffOptions & { timeout?: number }} [options] - Comparison options
* @returns {Promise<import("./odiff.d.ts").ODiffResult>} Comparison result
*/
async compareBuffers(
baseBuffer,
baseFormat,
compareBuffer,
compareFormat,
diffOutput,
options = {},
) {
// Wait for server initialization
if (this._initPromise && !this.ready) {
await this._initPromise;
}
if (!this._initPromise && !this.ready) {
this._initPromise = this._initialize();
await this._initPromise;
}
if (!Buffer.isBuffer(baseBuffer) || !Buffer.isBuffer(compareBuffer)) {
throw new Error(
"Both baseBuffer and compareBuffer must be Buffer instances",
);
}
const requestId = this.requestId++;
let timeoutId;
const resultPromise = new Promise((resolve, reject) => {
if (options.timeout !== undefined) {
timeoutId = setTimeout(() => {
if (this.pendingRequests.has(requestId)) {
this.pendingRequests.delete(requestId);
reject(
new Error(`odiff: Request timed out after ${options.timeout}ms`),
);
}
}, options.timeout);
}
this.pendingRequests.set(requestId, { resolve, reject, timeoutId });
});
const request = {
type: "buffer",
requestId: requestId,
baseLength: baseBuffer.length,
baseFormat: baseFormat,
compareLength: compareBuffer.length,
compareFormat: compareFormat,
output: diffOutput,
options: {
threshold: options.threshold,
failOnLayoutDiff: options.failOnLayoutDiff,
antialiasing: options.antialiasing,
captureDiffLines: options.captureDiffLines,
outputDiffMask: options.outputDiffMask,
ignoreRegions: options.ignoreRegions,
diffColor: options.diffColor,
diffOverlay: options.diffOverlay,
},
};
// Acquire write lock to prevent concurrent requests from interleaving
// This is CRITICAL for buffer comparisons since we write:
// 1. JSON request line
// 2. Base buffer (raw bytes)
// 3. Compare buffer (raw bytes)
// These three writes must be atomic and ordered to avoid data corruption
const release = await this._acquireWriteLock();
try {
await this._writeWithBackpressure(JSON.stringify(request) + "\n");
await this._writeWithBackpressure(baseBuffer);
await this._writeWithBackpressure(compareBuffer);
} catch (err) {
this.pendingRequests.delete(requestId);
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
throw new Error(`odiff: Failed to send request: ${err.message}`);
} finally {
if (release) {
release();
}
}
return resultPromise;
}
/**
* Internal cleanup method
* @private
*/
cleanup() {
this.ready = false;
this.process = null;
this._initPromise = null;
// Reject all pending requests and clear timeouts
for (const [_, { reject, timeoutId }] of this.pendingRequests) {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
reject(new Error("odiff: Server process terminated"));
}
this.pendingRequests.clear();
}
/**
* Stop the odiff server process
* Safe to call even if server is not running
*/
stop() {
if (!this.process) return;
this.exiting = true;
try {
this.process.stdin.end();
this.process.kill();
} catch (err) {
// Ignore errors during shutdown
}
this.cleanup();
this.exiting = false;
}
}
module.exports = {
ODiffServer,
};