import http from "node:http"; import fs from "node:fs/promises"; import path from "node:path"; import crypto from "node:crypto"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; const PORT = Number(process.env.PORT || 7860); const ROOT = path.dirname(fileURLToPath(import.meta.url)); const WORK = path.join(ROOT, "work"); const jobs = new Map(); const MAX_BYTES = 250 * 1024 * 1024; const R2_ACCOUNT_ID = process.env.R2_ACCOUNT_ID || ""; const R2_BUCKET = process.env.R2_BUCKET || ""; const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID || ""; const R2_SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY || ""; const R2_PUBLIC_BASE_URL = process.env.R2_PUBLIC_BASE_URL || ""; const R2_REGION = "auto"; const R2_SERVICE = "s3"; function r2Configured() { return Boolean(R2_ACCOUNT_ID && R2_BUCKET && R2_ACCESS_KEY_ID && R2_SECRET_ACCESS_KEY && R2_PUBLIC_BASE_URL); } function encodeRfc3986(value) { return encodeURIComponent(value).replace(/[!'()*]/g, char => "%" + char.charCodeAt(0).toString(16).toUpperCase()); } function encodePath(value) { return value.split("/").map(encodeRfc3986).join("/"); } function canonicalQueryString(params) { return Object.keys(params).sort().map(key => encodeRfc3986(key) + "=" + encodeRfc3986(params[key])).join("&"); } function sha256Hex(value) { return crypto.createHash("sha256").update(value, "utf8").digest("hex"); } function hmac(key, value) { return crypto.createHmac("sha256", key).update(value, "utf8").digest(); } function hmacHex(key, value) { return crypto.createHmac("sha256", key).update(value, "utf8").digest("hex"); } function getSignatureKey(secret, dateStamp) { const kDate = hmac("AWS4" + secret, dateStamp); const kRegion = hmac(kDate, R2_REGION); const kService = hmac(kRegion, R2_SERVICE); return hmac(kService, "aws4_request"); } function toAmzDate(date) { return date.toISOString().replace(/[:-]|\.\d{3}/g, "").slice(0, 15) + "Z"; } function buildR2PresignedPutUrl(key, contentType) { if (!r2Configured()) throw new Error("R2 secrets are not configured"); const now = new Date(); const host = R2_ACCOUNT_ID + ".r2.cloudflarestorage.com"; const amzDate = toAmzDate(now); const dateStamp = amzDate.slice(0, 8); const credentialScope = dateStamp + "/" + R2_REGION + "/" + R2_SERVICE + "/aws4_request"; const canonicalUri = "/" + encodePath(R2_BUCKET) + "/" + encodePath(key); const signedHeaders = "content-type;host"; const query = { "X-Amz-Algorithm": "AWS4-HMAC-SHA256", "X-Amz-Credential": R2_ACCESS_KEY_ID + "/" + credentialScope, "X-Amz-Date": amzDate, "X-Amz-Expires": "600", "X-Amz-SignedHeaders": signedHeaders }; const canonicalQuery = canonicalQueryString(query); const canonicalHeaders = "content-type:" + contentType.trim().replace(/\s+/g, " ") + "\n" + "host:" + host + "\n"; const canonicalRequest = ["PUT", canonicalUri, canonicalQuery, canonicalHeaders, signedHeaders, "UNSIGNED-PAYLOAD"].join("\n"); const stringToSign = ["AWS4-HMAC-SHA256", amzDate, credentialScope, sha256Hex(canonicalRequest)].join("\n"); const signature = hmacHex(getSignatureKey(R2_SECRET_ACCESS_KEY, dateStamp), stringToSign); return "https://" + host + canonicalUri + "?" + canonicalQuery + "&X-Amz-Signature=" + signature; } function r2PublicUrl(key) { return R2_PUBLIC_BASE_URL.replace(/\/+$/, "") + "/" + encodePath(key); } async function uploadToR2(file, key) { const contentType = "model/gltf-binary"; const data = await fs.readFile(file); const uploadUrl = buildR2PresignedPutUrl(key, contentType); const response = await fetch(uploadUrl, { method: "PUT", headers: { "content-type": contentType }, body: data, signal: AbortSignal.timeout(180000) }); if (!response.ok) throw new Error("R2 upload failed: HTTP " + response.status + " " + (await response.text()).slice(0, 1000)); return {key, url: r2PublicUrl(key), bytes: data.length}; } await fs.mkdir(WORK, { recursive: true }); function json(res, status, body) { const data = Buffer.from(JSON.stringify(body)); res.writeHead(status, { "content-type": "application/json; charset=utf-8", "content-length": data.length }); res.end(data); } function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; let size = 0; req.on("data", chunk => { size += chunk.length; if (size > 1024 * 1024) { reject(new Error("request too large")); req.destroy(); return; } chunks.push(chunk); }); req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); req.on("error", reject); }); } function safeUrl(value) { try { const parsed = new URL(value); return parsed.protocol === "https:" || parsed.protocol === "http:"; } catch { return false; } } async function download(url, target) { const response = await fetch(url, { signal: AbortSignal.timeout(180000), headers: { "user-agent": "Machesta-GLB-Optimizer/1.0" } }); if (!response.ok) throw new Error("download failed: HTTP " + response.status); const declared = Number(response.headers.get("content-length") || 0); if (declared > MAX_BYTES) throw new Error("input GLB exceeds size limit"); const buffer = Buffer.from(await response.arrayBuffer()); if (buffer.length > MAX_BYTES) throw new Error("input GLB exceeds size limit"); await fs.writeFile(target, buffer); return buffer.length; } function runOptimize(input, output) { return new Promise((resolve, reject) => { const args = [ "gltf-transform", "optimize", input, output, "--compress", "draco", "--texture-compress", "webp", "--texture-size", "512", "--simplify-ratio", "0.3", "--simplify-error", "0.001" ]; const child = spawn("npx", ["--yes", ...args], { cwd: ROOT, stdio: ["ignore", "pipe", "pipe"] }); let logs = ""; child.stdout.on("data", data => { logs += data.toString(); }); child.stderr.on("data", data => { logs += data.toString(); }); child.on("error", reject); child.on("close", code => { if (code === 0) resolve(logs.slice(-12000)); else reject(new Error("optimizer exited with code " + code + ": " + logs.slice(-4000))); }); }); } async function processJob(job) { job.status = "processing"; const input = path.join(WORK, job.id + "-input.glb"); const output = path.join(WORK, job.id + "-mobile.glb"); try { job.inputBytes = await download(job.url, input); await runOptimize(input, output); const stat = await fs.stat(output); if (stat.size === 0) throw new Error("optimizer produced an empty file"); job.outputBytes = stat.size; const r2Key = "optimized-glb/" + job.id + ".glb"; const uploaded = await uploadToR2(output, r2Key); job.r2Key = uploaded.key; job.r2Url = uploaded.url; job.status = "ready"; job.downloadPath = uploaded.url; } catch (error) { job.status = "failed"; job.error = error instanceof Error ? error.message : String(error); } finally { await fs.rm(input, { force: true }).catch(() => {}); if (job.status !== "ready") await fs.rm(output, { force: true }).catch(() => {}); } } const server = http.createServer(async (req, res) => { const url = new URL(req.url || "/", "http://" + (req.headers.host || "localhost")); if (req.method === "GET" && url.pathname === "/health") { return json(res, 200, { ok: true, service: "machesta-glb-optimizer", optimizer: "gltf-transform-draco", r2Configured: r2Configured() }); } if (req.method === "POST" && url.pathname === "/optimize") { try { const body = JSON.parse(await readBody(req)); if (!safeUrl(body.url)) return json(res, 400, { error: "url must be http or https" }); const id = crypto.randomUUID(); const job = { id: id, url: body.url, status: "queued", createdAt: new Date().toISOString() }; jobs.set(id, job); processJob(job); return json(res, 202, { jobId: id, status: job.status, statusUrl: "/jobs/" + id }); } catch (error) { return json(res, 400, { error: error instanceof Error ? error.message : String(error) }); } } const jobMatch = url.pathname.match(/^\/jobs\/([a-f0-9-]+)$/); if (req.method === "GET" && jobMatch) { const job = jobs.get(jobMatch[1]); return job ? json(res, 200, job) : json(res, 404, { error: "job not found" }); } const downloadMatch = url.pathname.match(/^\/download\/([a-f0-9-]+)$/); if (req.method === "GET" && downloadMatch) { const job = jobs.get(downloadMatch[1]); if (!job || job.status !== "ready") return json(res, 404, { error: "optimized file not ready" }); const file = path.join(WORK, job.id + "-mobile.glb"); try { const data = await fs.readFile(file); res.writeHead(200, { "content-type": "model/gltf-binary", "content-disposition": "attachment; filename=\"" + job.id + "-mobile.glb\"", "content-length": data.length }); return res.end(data); } catch { return json(res, 404, { error: "optimized file expired or unavailable" }); } } return json(res, 404, { error: "not found" }); }); server.listen(PORT, "0.0.0.0", () => { console.log("Machesta GLB optimizer listening on " + PORT); });