@web2docx/web2docx-sdk
Version:
Real-time HTML to PDF/Image conversion client for Web2Docx API
377 lines (322 loc) • 11.1 kB
JavaScript
const axios = require("axios");
const WebSocket = require("ws");
class Web2DocxClient {
constructor(apiKey, options = {}) {
if (!apiKey) throw new Error("API Key is required");
this.apiKey = apiKey;
// Allow configurable URLs for different environments
this.baseURL =
options.baseURL ||
process.env.WEB2DOCX_API_URL ||
`https://queue.web2docx.com/queue/job/`;
this.wsURL =
options.wsURL ||
process.env.WEB2DOCX_WS_URL ||
`wss://queue.web2docx.com`;
// Production fixes for WebSocket path
if (!this.wsURL.includes("/queue") && !options.wsURL) {
this.wsURL += this.wsURL.endsWith("/") ? "queue" : "/queue";
}
this.isConnected = false;
this.connectionPromise = null;
this.messageHandlers = new Map(); // Store job-specific handlers
this.reconnectAttempts = 0;
this.maxReconnectAttempts = Infinity; // Keep trying indefinitely for persistent connection
// Add debug logging for production troubleshooting
this.debug = options.debug || process.env.NODE_ENV === "development";
if (this.debug) {
console.log(`🔧 Web2DocxClient initialized:`);
console.log(` API URL: ${this.baseURL}`);
console.log(` WebSocket URL: ${this.wsURL}`);
}
this._initializeWebSocket();
}
_initializeWebSocket() {
return new Promise((resolve, reject) => {
this.ws = new WebSocket(this.wsURL);
this.ws.on("open", () => {
console.log("✅ WebSocket connected to server");
this.isConnected = true;
this.reconnectAttempts = 0;
this._setupHeartbeat();
resolve();
});
this.ws.on("message", (msg) => {
try {
const message = JSON.parse(msg);
// Handle connection confirmation
if (message.type === "connection") {
this.clientId = message.clientId;
console.log(`🆔 Assigned client ID: ${this.clientId}`);
return;
}
// Handle heartbeat
if (message.type === "pong") {
this.lastPong = Date.now();
return;
}
// Handle job-specific messages
if (message.jobId) {
const handler = this.messageHandlers.get(message.jobId);
if (handler) {
handler(message);
}
}
} catch (error) {
console.error("❌ Error parsing WebSocket message:", error);
}
});
this.ws.on("close", () => {
console.log(
"❌ WebSocket connection closed - attempting to maintain persistent connection"
);
this.isConnected = false;
this._clearHeartbeat();
// Always attempt to reconnect to maintain persistent connection
this._handleReconnection();
});
this.ws.on("error", (error) => {
console.error("❌ WebSocket error:", error);
this.isConnected = false;
reject(error);
});
// Connection timeout
setTimeout(() => {
if (!this.isConnected) {
reject(new Error("WebSocket connection timeout"));
}
}, 10000);
});
}
async _handleReconnection() {
// Keep trying to reconnect indefinitely to maintain persistent connection
this.reconnectAttempts++;
const delay = Math.min(
1000 * Math.pow(2, Math.min(this.reconnectAttempts, 6)),
30000
);
console.log(
`🔄 Maintaining persistent connection - reconnect attempt ${this.reconnectAttempts} in ${delay}ms`
);
setTimeout(() => {
this._initializeWebSocket().catch(() => {
// Will retry again through the close event
});
}, delay);
}
async _ensureConnected() {
if (this.isConnected) return;
if (!this.connectionPromise) {
this.connectionPromise = this._initializeWebSocket();
}
try {
await this.connectionPromise;
} catch (error) {
this.connectionPromise = null;
throw new Error("Failed to establish WebSocket connection");
}
}
_setupHeartbeat() {
this.heartbeatInterval = setInterval(() => {
if (this.isConnected && this.ws.readyState === this.ws.OPEN) {
this.ws.send(JSON.stringify({ type: "ping", timestamp: Date.now() }));
}
}, 30000); // Ping every 30 seconds
}
_clearHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
// Optional manual shutdown method (connection stays open by default)
close() {
console.log("📋 Manually closing WebSocket connection");
this._clearHeartbeat();
this.messageHandlers.clear();
if (this.ws && this.ws.readyState === this.ws.OPEN) {
this.ws.close();
}
}
// TODO: add pdf options like grayscale, orientation etc..
async htmlToPdf(html) {
const type = "html-pdf";
const jobId = await this._queueJob("html", { html, type });
return this._waitForJob(
jobId,
(message) => {
return Uint8Array.from(atob(message.data), (c) => c.charCodeAt(0));
},
90000
); // 90 second timeout for single conversions
}
// batch processing (html-pdf)
async htmlBatchToPdf(htmlList) {
const type = "html-pdf-batch";
const batchLimit = 10;
if (htmlList.length > batchLimit) {
throw new Error(
`Batch limit of ${batchLimit} exceeded. Please reduce the number of HTML files.`
);
}
const jobId = await this._queueJob("html", { htmlList, type });
// Longer timeout for batch jobs - 2 minutes + 15 seconds per file
const timeoutMs = 120000 + htmlList.length * 15000;
return this._waitForJob(
jobId,
(message) => {
return Uint8Array.from(atob(message.data), (c) => c.charCodeAt(0));
},
timeoutMs
);
}
async htmlToDocx(html) {
const type = "html-docx";
const jobId = await this._queueJob("html", { html, type });
return this._waitForJob(
jobId,
(message) => {
return Uint8Array.from(atob(message.data), (c) => c.charCodeAt(0));
},
90000
); // 90 second timeout for single conversions
}
// batch html to docx
async htmlBatchToDocx(htmlList) {
const type = "html-docx-batch";
const batchLimit = 10;
if (htmlList.length > batchLimit) {
throw new Error(
`Batch limit of ${batchLimit} exceeded. Please reduce the number of HTML files.`
);
}
const jobId = await this._queueJob("html", { htmlList, type });
// Longer timeout for batch jobs - 2 minutes + 15 seconds per file
const timeoutMs = 120000 + htmlList.length * 15000;
return this._waitForJob(
jobId,
(message) => {
return Uint8Array.from(atob(message.data), (c) => c.charCodeAt(0));
},
timeoutMs
);
}
async htmlToImage(html) {
const type = "html-image";
const jobId = await this._queueJob("html", { html, type });
return this._waitForJob(
jobId,
(message) => {
return Uint8Array.from(atob(message.data), (c) => c.charCodeAt(0));
},
60000
); // 60 second timeout for image conversions (faster)
}
// batch processing(html-image)
async htmlBatchToImage(htmlList) {
const type = "html-image-batch";
const batchLimit = 10;
if (htmlList.length > batchLimit) {
throw new Error(
`Batch limit of ${batchLimit} exceeded. Please reduce the number of HTML files.`
);
}
const jobId = await this._queueJob("html", { htmlList, type });
// Image batch jobs - 90 seconds + 10 seconds per file (images are faster)
const timeoutMs = 90000 + htmlList.length * 10000;
return this._waitForJob(
jobId,
(message) => {
return Uint8Array.from(atob(message.data), (c) => c.charCodeAt(0));
},
timeoutMs
);
}
async _queueJob(endpoint, data) {
// Ensure WebSocket is connected BEFORE queuing the job
await this._ensureConnected();
try {
const response = await axios.post(this.baseURL + endpoint, data, {
headers: {
"x-api-key": this.apiKey,
"Content-Type": "application/json",
},
});
if (response.data.queueType === "pro_max") {
console.log("🚀 Job added to high priority queue!");
} else if (response.data.queueType === "pro") {
console.log("🚄 Job added to priority queue!");
} else {
console.log("📄 Job added to standard free queue!");
}
return response.data.jobId;
} catch (error) {
console.error(error.response);
throw new Error(error.response?.data?.error || "API request failed");
}
}
async _waitForJob(jobId, processOutput, timeoutMs = 120000) {
await this._ensureConnected();
return new Promise((resolve, reject) => {
let isResolved = false;
const cleanup = () => {
this.messageHandlers.delete(jobId);
if (timeoutHandle) clearTimeout(timeoutHandle);
};
const messageHandler = (message) => {
if (isResolved) return;
try {
if (message.status === "completed") {
isResolved = true;
cleanup();
console.log(`🎉 Job ${jobId} completed!`);
if (!message.data) {
reject(new Error("Job completed but no data received"));
return;
}
resolve(processOutput(message));
} else if (message.status === "failed") {
isResolved = true;
cleanup();
console.error(`❌ Job ${jobId} failed: ${message.error}`);
reject(new Error(message.error || "Job failed"));
} else if (message.status === "processing") {
console.log(`⏳ Job ${jobId} is processing...`);
if (message.progress) {
console.log(`📊 Progress: ${message.progress}%`);
}
}
} catch (error) {
if (!isResolved) {
isResolved = true;
cleanup();
reject(new Error(`Error processing job result: ${error.message}`));
}
}
};
// Store the handler for this specific job
this.messageHandlers.set(jobId, messageHandler);
// Set up timeout
const timeoutHandle = setTimeout(() => {
if (!isResolved) {
isResolved = true;
cleanup();
reject(new Error(`Job ${jobId} timed out after ${timeoutMs}ms`));
}
}, timeoutMs);
// Handle WebSocket disconnection during job wait - but don't fail immediately
const originalClose = this.ws.onclose;
this.ws.onclose = (event) => {
if (!isResolved) {
console.log(
"⚠️ WebSocket disconnected during job wait - attempting to maintain connection"
);
// Don't immediately fail - the reconnection logic will handle this
// The job will timeout if it can't reconnect in time
}
if (originalClose) originalClose(event);
};
});
}
}
module.exports = Web2DocxClient;