Update server.js

This commit is contained in:
2026-01-25 18:55:26 -08:00
committed by GitHub
parent 38b4173a6e
commit 24834a2fed

193
server.js
View File

@@ -3,7 +3,6 @@ import sharp from "sharp";
import { execFile } from "child_process"; import { execFile } from "child_process";
import fs from "fs/promises"; import fs from "fs/promises";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import archiver from "archiver";
import libheifModule from "libheif-js"; import libheifModule from "libheif-js";
const libheif = libheifModule?.default ?? libheifModule; const libheif = libheifModule?.default ?? libheifModule;
@@ -32,6 +31,10 @@ const DEFAULT_REQ_TIMEOUT_PDF_MS = clampInt(
5 * 60_000 5 * 60_000
); );
// If your platform hard-limits concurrent connections to 1, set this to 1.
const MAX_INFLIGHT = clampInt(process.env.MAX_INFLIGHT, 1, 16, 1);
let inflight = 0;
app.use((req, res, next) => { app.use((req, res, next) => {
const requestId = const requestId =
String(req.headers["x-request-id"] || "").trim() || randomUUID(); String(req.headers["x-request-id"] || "").trim() || randomUUID();
@@ -118,15 +121,15 @@ function looksLikeHeic(buf) {
); );
} }
async function assertSupportedRaster(input, req) { async function assertSupportedRaster(input) {
if (looksLikeHeic(input)) return; if (looksLikeHeic(input)) return;
try { try {
await sharp(input, { failOnError: false }).metadata(); await sharp(input, { failOnError: false }).metadata();
} catch { } catch {
throw Object.assign( throw Object.assign(new Error("Unsupported image input"), {
new Error("Unsupported image input"), statusCode: 415,
{ statusCode: 415, code: "unsupported_media_type" } code: "unsupported_media_type",
); });
} }
} }
@@ -148,6 +151,8 @@ function parseOptions(req) {
maxDim: clampInt(req.headers["x-max-dimension"], 500, 6000, 2000), maxDim: clampInt(req.headers["x-max-dimension"], 500, 6000, 2000),
fit: "inside", fit: "inside",
withoutEnlargement: parseBool(req.headers["x-without-enlargement"], true), withoutEnlargement: parseBool(req.headers["x-without-enlargement"], true),
// allow callers to request higher-res PDF rendering if they want
pdfDpi: clampInt(req.headers["x-pdf-dpi"], 72, 600, 300),
}; };
} }
@@ -156,12 +161,17 @@ function parseOptions(req) {
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
function normalizeForVision(input, opts) { function normalizeForVision(input, opts) {
let pipeline = sharp(input, { // IMPORTANT:
// If opts.raw is present, Sharp must be told it's raw pixel data.
const sharpInputOpts = {
failOnError: false, failOnError: false,
limitInputPixels: 200e6, limitInputPixels: 200e6,
}) ...(opts?.raw ? { raw: opts.raw } : {}),
.rotate() // apply EXIF orientation };
.toColorspace("rgb"); // normalize colorspace
let pipeline = sharp(input, sharpInputOpts)
.rotate() // apply EXIF orientation (no-op for raw)
.toColorspace("rgb"); // normalize colorspace
if (opts.maxDim) { if (opts.maxDim) {
pipeline = pipeline.resize({ pipeline = pipeline.resize({
@@ -172,6 +182,7 @@ function normalizeForVision(input, opts) {
}); });
} }
// Strip all metadata by default (Vision/transport safe).
return pipeline return pipeline
.jpeg({ .jpeg({
quality: opts.quality, quality: opts.quality,
@@ -179,7 +190,7 @@ function normalizeForVision(input, opts) {
mozjpeg: true, mozjpeg: true,
progressive: true, progressive: true,
}) })
.withMetadata(false) // explicit: strip ALL metadata .withMetadata(false)
.toBuffer(); .toBuffer();
} }
@@ -203,39 +214,40 @@ function heifDisplayToRGBA(img) {
} }
async function heicToJpeg(input, opts) { async function heicToJpeg(input, opts) {
if (!libheif?.HeifDecoder) { if (!libheif?.HeifDecoder) throw new Error("libheif-js unavailable");
throw new Error("libheif-js unavailable");
}
const dec = new libheif.HeifDecoder(); const dec = new libheif.HeifDecoder();
const imgs = dec.decode(input); const imgs = dec.decode(input);
if (!imgs?.length) throw new Error("HEIC decode failed"); if (!imgs?.length) throw new Error("HEIC decode failed");
const { width, height, rgba } = await heifDisplayToRGBA(imgs[0]); const { width, height, rgba } = await heifDisplayToRGBA(imgs[0]);
return normalizeForVision(
Buffer.from(rgba), // Feed raw pixels correctly:
{ ...opts, raw: { width, height, channels: 4 } } return normalizeForVision(Buffer.from(rgba), {
); ...opts,
raw: { width, height, channels: 4 },
});
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* PDF handling */ /* PDF handling */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
async function pdfFirstPageToJpeg(input, opts, dpi = 300) { async function pdfFirstPageToJpeg(input, opts) {
const id = randomUUID(); const id = randomUUID();
const pdf = `/tmp/${id}.pdf`; const pdf = `/tmp/${id}.pdf`;
const out = `/tmp/${id}.jpg`; const out = `/tmp/${id}.jpg`;
try { try {
await fs.writeFile(pdf, input); await fs.writeFile(pdf, input);
await execFilePromise("pdftoppm", [
"-jpeg", // Give PDFs their own longer timeout budget.
"-singlefile", // (Also helps platforms that kill "stuck" requests.)
"-r", await execFilePromise(
String(dpi), "pdftoppm",
pdf, ["-jpeg", "-singlefile", "-r", String(opts.pdfDpi), pdf, `/tmp/${id}`],
`/tmp/${id}`, DEFAULT_REQ_TIMEOUT_PDF_MS
]); );
const buf = await fs.readFile(out); const buf = await fs.readFile(out);
return normalizeForVision(buf, opts); return normalizeForVision(buf, opts);
} finally { } finally {
@@ -244,59 +256,116 @@ async function pdfFirstPageToJpeg(input, opts, dpi = 300) {
} }
} }
/* ------------------------------------------------------------------ */
/* Concurrency gate (single-flight) */
/* ------------------------------------------------------------------ */
async function withInflightLimit(req, res, fn) {
if (inflight >= MAX_INFLIGHT) {
// If your instance hard-limits concurrent connections, returning 503 fast
// prevents health check failures and connection pileups.
return sendError(
res,
503,
"busy",
"Converter busy; retry shortly",
req.requestId
);
}
inflight++;
try {
return await fn();
} finally {
inflight--;
}
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Routes */ /* Routes */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
app.post("/convert", async (req, res) => { app.post("/convert", async (req, res) => {
try { // If your platform counts keep-alive as an active connection, close quickly.
if (!requireAuth(req, res)) return; res.setHeader("Connection", "close");
if (!req.body?.length)
return sendError(res, 400, "empty_body", "Empty body", req.requestId);
const opts = parseOptions(req);
if (isPdfRequest(req)) {
const jpeg = await pdfFirstPageToJpeg(req.body, opts);
res.setHeader("Content-Type", "image/jpeg");
return res.send(jpeg);
}
await assertSupportedRaster(req.body, req);
return withInflightLimit(req, res, async () => {
try { try {
const jpeg = await normalizeForVision(req.body, opts); if (!requireAuth(req, res)) return;
res.setHeader("Content-Type", "image/jpeg");
return res.send(jpeg); if (!req.body?.length) {
} catch { return sendError(res, 400, "empty_body", "Empty body", req.requestId);
if (looksLikeHeic(req.body)) { }
const jpeg = await heicToJpeg(req.body, opts);
const opts = parseOptions(req);
if (isPdfRequest(req)) {
if (isAborted(req, res)) return;
const jpeg = await pdfFirstPageToJpeg(req.body, opts);
if (isAborted(req, res)) return;
res.setHeader("Content-Type", "image/jpeg"); res.setHeader("Content-Type", "image/jpeg");
return res.send(jpeg); return res.send(jpeg);
} }
throw new Error("Image conversion failed");
// Fast sniff: if HEIC, go directly to HEIC decode path.
if (looksLikeHeic(req.body)) {
if (isAborted(req, res)) return;
const jpeg = await heicToJpeg(req.body, opts);
if (isAborted(req, res)) return;
res.setHeader("Content-Type", "image/jpeg");
return res.send(jpeg);
}
await assertSupportedRaster(req.body);
if (isAborted(req, res)) return;
const jpeg = await normalizeForVision(req.body, opts);
if (isAborted(req, res)) return;
res.setHeader("Content-Type", "image/jpeg");
return res.send(jpeg);
} catch (e) {
const status = e?.statusCode || 500;
const code = e?.code || "conversion_failed";
console.error(
JSON.stringify({
requestId: req.requestId,
err: String(e?.stack || e),
})
);
return sendError(
res,
status,
code,
status === 415 ? "Unsupported media type" : "Conversion failed",
req.requestId
);
} }
} catch (e) { });
console.error(JSON.stringify({ requestId: req.requestId, err: String(e) }));
return sendError(res, 500, "conversion_failed", "Conversion failed", req.requestId);
}
}); });
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Helpers */ /* Helpers */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
function execFilePromise(cmd, args) { function execFilePromise(cmd, args, timeoutMs) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
execFile(cmd, args, (err, _stdout, stderr) => { const child = execFile(cmd, args, { timeout: timeoutMs }, (err, _o, stderr) => {
if (err) { if (err) {
if (err.code === "ENOENT") { if (err.code === "ENOENT") return reject(new Error(`Missing dependency: ${cmd}`));
reject(new Error(`Missing dependency: ${cmd}`)); if (err.killed || err.signal === "SIGTERM") {
} else { return reject(new Error(`${cmd} timed out after ${timeoutMs}ms`));
reject(new Error(stderr || String(err)));
} }
} else resolve(); return reject(new Error(stderr || String(err)));
}
resolve();
}); });
// Best-effort: if the parent request is gone, the route checks isAborted(),
// but this protects against orphaned converters in some environments.
child.on("error", () => {});
}); });
} }
@@ -315,6 +384,10 @@ async function safeUnlink(p) {
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
const port = Number(process.env.PORT) || 8080; const port = Number(process.env.PORT) || 8080;
app.listen(port, "0.0.0.0", () => const server = app.listen(port, "0.0.0.0", () =>
console.log(`converter listening on :${port}`) console.log(`converter listening on :${port}`)
); );
// Reduce lingering keep-alive sockets (helps strict connection caps).
server.keepAliveTimeout = 5_000;
server.headersTimeout = 10_000;