import { createReadStream, createWriteStream } from "node:fs"; import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; import { basename, extname, join, normalize } from "node:path"; import { pipeline } from "node:stream/promises"; import { fileURLToPath } from "node:url"; const root = fileURLToPath(new URL("..", import.meta.url)); const distDir = join(root, "apps", "catalog", "dist"); const runtimeDir = join(root, "runtime-data"); const uploadDir = join(runtimeDir, "uploads"); const layoutPath = join(runtimeDir, "layout.json"); const port = Number(process.env.PORT || 3333); await mkdir(uploadDir, { recursive: true }); const mimeTypes = { ".css": "text/css; charset=utf-8", ".gif": "image/gif", ".html": "text/html; charset=utf-8", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".js": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".map": "application/json; charset=utf-8", ".mp4": "video/mp4", ".png": "image/png", ".svg": "image/svg+xml", ".webm": "video/webm", ".webp": "image/webp", ".mov": "video/quicktime", }; function json(response, statusCode, value) { response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); response.end(JSON.stringify(value)); } async function readJsonBody(request, maxBytes = 2 * 1024 * 1024) { const chunks = []; let size = 0; for await (const chunk of request) { size += chunk.length; if (size > maxBytes) throw new Error("payload_too_large"); chunks.push(chunk); } return JSON.parse(Buffer.concat(chunks).toString("utf8")); } function safeUploadName(name, contentType = "") { const allowed = new Set([".gif", ".jpeg", ".jpg", ".m4v", ".mov", ".mp4", ".png", ".webm", ".webp"]); const contentTypeExtensions = { "image/gif": ".gif", "image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp", "video/mp4": ".mp4", "video/quicktime": ".mov", "video/webm": ".webm", }; const requestedExtension = extname(name).toLowerCase(); const extension = allowed.has(requestedExtension) ? requestedExtension : contentTypeExtensions[contentType] || ".bin"; const stem = name.replace(/\.[^.]+$/, "").replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "stage-video"; return `${Date.now()}-${stem}${extension}`; } async function serveFile(request, response, filePath) { const info = await stat(filePath); const contentType = mimeTypes[extname(filePath).toLowerCase()] || "application/octet-stream"; const range = request.headers.range?.match(/^bytes=(\d*)-(\d*)$/); if (range) { const start = range[1] ? Number(range[1]) : 0; const end = range[2] ? Math.min(Number(range[2]), info.size - 1) : info.size - 1; if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= info.size) { response.writeHead(416, { "content-range": `bytes */${info.size}` }); response.end(); return; } response.writeHead(206, { "content-type": contentType, "content-length": end - start + 1, "content-range": `bytes ${start}-${end}/${info.size}`, "accept-ranges": "bytes", }); if (request.method === "HEAD") return response.end(); createReadStream(filePath, { start, end }).pipe(response); return; } response.writeHead(200, { "content-type": contentType, "content-length": info.size, "accept-ranges": "bytes", }); if (request.method === "HEAD") return response.end(); createReadStream(filePath).pipe(response); } const server = createServer(async (request, response) => { try { const url = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`); if (url.pathname === "/api/layout" && request.method === "GET") { try { json(response, 200, JSON.parse(await readFile(layoutPath, "utf8"))); } catch (error) { if (error?.code === "ENOENT") return json(response, 200, null); throw error; } return; } if (url.pathname === "/api/layout" && request.method === "PUT") { const layout = await readJsonBody(request); const next = { ...layout, savedAt: new Date().toISOString(), schemaVersion: 1 }; const tempPath = `${layoutPath}.tmp`; await writeFile(tempPath, `${JSON.stringify(next, null, 2)}\n`, "utf8"); await rename(tempPath, layoutPath); json(response, 200, next); return; } if (url.pathname === "/api/layout/media" && request.method === "PUT") { const originalName = decodeURIComponent(String(request.headers["x-file-name"] || "stage-video.mp4")); const fileName = safeUploadName(originalName, String(request.headers["content-type"] || "")); const targetPath = join(uploadDir, fileName); await pipeline(request, createWriteStream(targetPath, { flags: "wx" })); json(response, 201, { fileName: originalName, url: `/uploads/${fileName}` }); return; } if (url.pathname.startsWith("/uploads/")) { const fileName = basename(normalize(url.pathname.slice("/uploads/".length))); await serveFile(request, response, join(uploadDir, fileName)); return; } const relativePath = url.pathname === "/" ? "index.html" : url.pathname.replace(/^\//, ""); const candidate = join(distDir, normalize(relativePath)); try { await serveFile(request, response, candidate); } catch (error) { if (error?.code !== "ENOENT") throw error; await serveFile(request, response, join(distDir, "index.html")); } } catch (error) { json(response, error?.message === "payload_too_large" ? 413 : 500, { error: error?.message || "server_error" }); } }); server.listen(port, "127.0.0.1", () => { console.log(`NODE.DC Design Guideline: http://127.0.0.1:${port}`); console.log(`Layout store: ${layoutPath}`); });