156 lines
5.9 KiB
JavaScript
156 lines
5.9 KiB
JavaScript
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",
|
|
".ico": "image/x-icon",
|
|
".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", ".ico", ".jpeg", ".jpg", ".m4v", ".mov", ".mp4", ".png", ".webm", ".webp"]);
|
|
const contentTypeExtensions = {
|
|
"image/gif": ".gif",
|
|
"image/x-icon": ".ico",
|
|
"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}`);
|
|
});
|