ProxyNetwork / server.mjs
samedche's picture
Update server.mjs
711520d verified
Raw
History Blame Contribute Delete
9.3 kB
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);
});