Build Module Studio application composition
This commit is contained in:
+321
-2
@@ -1,6 +1,7 @@
|
||||
import { createReadStream, createWriteStream } from "node:fs";
|
||||
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { basename, extname, join, normalize } from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -9,10 +10,20 @@ 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 applicationsDir = join(runtimeDir, "applications");
|
||||
const designProfilesDir = join(runtimeDir, "design-profiles");
|
||||
const layoutPath = join(runtimeDir, "layout.json");
|
||||
const pageRegistryPath = join(root, "registry", "pages.json");
|
||||
const port = Number(process.env.PORT || 3333);
|
||||
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
await mkdir(applicationsDir, { recursive: true });
|
||||
await mkdir(designProfilesDir, { recursive: true });
|
||||
const pageRegistry = JSON.parse(await readFile(pageRegistryPath, "utf8"));
|
||||
|
||||
function findPageTemplate(id, version) {
|
||||
return pageRegistry.templates.find((template) => template.id === id && (!version || template.version === version));
|
||||
}
|
||||
|
||||
const mimeTypes = {
|
||||
".css": "text/css; charset=utf-8",
|
||||
@@ -37,6 +48,187 @@ function json(response, statusCode, value) {
|
||||
response.end(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function applicationError(code, statusCode = 400) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
|
||||
function normalizeSlug(value) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]+/g, "-")
|
||||
.replace(/-{2,}/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
function applicationPath(id) {
|
||||
const safeId = String(id || "");
|
||||
if (!/^[0-9a-f-]{36}$/i.test(safeId)) throw applicationError("invalid_application_id");
|
||||
return join(applicationsDir, `${safeId}.json`);
|
||||
}
|
||||
|
||||
function designProfilePath(id) {
|
||||
const safeId = String(id || "");
|
||||
if (safeId !== "default" && !/^[0-9a-f-]{36}$/i.test(safeId)) throw applicationError("invalid_design_profile_id");
|
||||
return join(designProfilesDir, `${safeId}.json`);
|
||||
}
|
||||
|
||||
function validateDesignProfile(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw applicationError("invalid_design_profile");
|
||||
if (value.schemaVersion !== "0.1.0") throw applicationError("unsupported_design_profile_schema");
|
||||
if (value.id !== "default" && !/^[0-9a-f-]{36}$/i.test(String(value.id || ""))) throw applicationError("invalid_design_profile_id");
|
||||
const name = String(value.name || "").trim();
|
||||
if (!name || name.length > 120) throw applicationError("invalid_design_profile_name");
|
||||
if (!value.layout || typeof value.layout !== "object" || Array.isArray(value.layout)) throw applicationError("invalid_design_profile_layout");
|
||||
return { ...value, name };
|
||||
}
|
||||
|
||||
async function ensureDefaultDesignProfile() {
|
||||
try {
|
||||
await readFile(designProfilePath("default"), "utf8");
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
let layout = {};
|
||||
try { layout = JSON.parse(await readFile(layoutPath, "utf8")); } catch (layoutError) { if (layoutError?.code !== "ENOENT") throw layoutError; }
|
||||
const now = new Date().toISOString();
|
||||
await writeFile(designProfilePath("default"), `${JSON.stringify({
|
||||
schemaVersion: "0.1.0",
|
||||
id: "default",
|
||||
name: "NODE.DC Default",
|
||||
version: "0.6.0",
|
||||
status: "draft",
|
||||
layout,
|
||||
timestamps: { createdAt: now, updatedAt: now },
|
||||
}, null, 2)}\n`, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
await ensureDefaultDesignProfile();
|
||||
|
||||
async function writeDesignProfile(profile) {
|
||||
const targetPath = designProfilePath(profile.id);
|
||||
const tempPath = `${targetPath}.${randomUUID()}.tmp`;
|
||||
await writeFile(tempPath, `${JSON.stringify(profile, null, 2)}\n`, "utf8");
|
||||
await rename(tempPath, targetPath);
|
||||
}
|
||||
|
||||
function designProfileSummary(profile) {
|
||||
return { id: profile.id, name: profile.name, version: profile.version, theme: profile.layout?.theme === "light" ? "light" : "dark", updatedAt: profile.timestamps.updatedAt };
|
||||
}
|
||||
|
||||
function nextPatchVersion(version) {
|
||||
const match = String(version || "0.1.0").match(/^(\d+)\.(\d+)\.(\d+)$/);
|
||||
return match ? `${match[1]}.${match[2]}.${Number(match[3]) + 1}` : "0.1.0";
|
||||
}
|
||||
|
||||
function validateApplicationManifest(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw applicationError("invalid_application_manifest");
|
||||
if (value.schemaVersion !== "0.1.0") throw applicationError("unsupported_application_schema");
|
||||
if (!/^[0-9a-f-]{36}$/i.test(String(value.id || ""))) throw applicationError("invalid_application_id");
|
||||
if (value.status !== "draft") throw applicationError("invalid_application_status");
|
||||
if (!/^0\.\d+\.\d+$/.test(String(value.version || ""))) throw applicationError("invalid_application_version");
|
||||
if (!value.metadata || typeof value.metadata !== "object") throw applicationError("invalid_application_metadata");
|
||||
const name = String(value.metadata.name || "").trim();
|
||||
const slug = normalizeSlug(value.metadata.slug);
|
||||
if (!name || name.length > 120) throw applicationError("invalid_application_name");
|
||||
if (!slug) throw applicationError("invalid_application_slug");
|
||||
if (!value.designProfile || typeof value.designProfile !== "object") throw applicationError("invalid_design_profile");
|
||||
if (value.designProfile.theme !== "dark" && value.designProfile.theme !== "light") throw applicationError("invalid_application_theme");
|
||||
if (!Array.isArray(value.pages)) throw applicationError("invalid_application_pages");
|
||||
const pageIds = new Set();
|
||||
for (const page of value.pages) {
|
||||
if (!page || typeof page !== "object") throw applicationError("invalid_application_page");
|
||||
const pageId = String(page.id || "").trim();
|
||||
if (!/^[a-z0-9-]+$/.test(pageId) || pageIds.has(pageId)) throw applicationError("invalid_application_page_id");
|
||||
pageIds.add(pageId);
|
||||
if (!page.template || typeof page.template !== "object" || !String(page.template.id || "").trim()) {
|
||||
throw applicationError("invalid_application_page_template");
|
||||
}
|
||||
const template = findPageTemplate(String(page.template.id), String(page.template.version || ""));
|
||||
if (!template) throw applicationError("unknown_application_page_template");
|
||||
if (!page.navigation || typeof page.navigation.visible !== "boolean") throw applicationError("invalid_application_navigation");
|
||||
if (!page.features || typeof page.features !== "object" || Array.isArray(page.features)) throw applicationError("invalid_application_features");
|
||||
const allowedFeatures = new Set(template.features.map((feature) => feature.id));
|
||||
if (Object.keys(page.features).some((feature) => !allowedFeatures.has(feature))) throw applicationError("unsupported_application_feature");
|
||||
for (const feature of template.features) {
|
||||
if (feature.required && page.features[feature.id] !== true) throw applicationError("required_application_feature_disabled");
|
||||
}
|
||||
}
|
||||
return {
|
||||
...value,
|
||||
metadata: {
|
||||
...value.metadata,
|
||||
name,
|
||||
slug,
|
||||
description: String(value.metadata.description || "").slice(0, 500),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createApplicationManifest(input = {}) {
|
||||
const templateId = String(input?.templateId || "").trim();
|
||||
const templateVersion = String(input?.templateVersion || "").trim() || undefined;
|
||||
const template = templateId ? findPageTemplate(templateId, templateVersion) : null;
|
||||
if (templateId && !template) throw applicationError("unknown_page_template");
|
||||
const now = new Date().toISOString();
|
||||
const name = String(input?.name || (template ? `NODE.DC ${template.title} Demo` : "Новый модуль")).trim() || "Новый модуль";
|
||||
const slug = normalizeSlug(input?.slug || name) || "nodedc-map-demo";
|
||||
return {
|
||||
schemaVersion: "0.1.0",
|
||||
id: randomUUID(),
|
||||
status: "draft",
|
||||
version: "0.1.0",
|
||||
metadata: {
|
||||
name,
|
||||
slug,
|
||||
description: String(input?.description || "Первый Application Draft NDC Module Studio."),
|
||||
},
|
||||
designProfile: {
|
||||
id: "default",
|
||||
version: "0.6.0",
|
||||
theme: input?.theme === "light" ? "light" : "dark",
|
||||
},
|
||||
pages: template ? [{
|
||||
id: template.page.id,
|
||||
title: template.page.title,
|
||||
path: template.page.path,
|
||||
template: { id: template.id, version: template.version },
|
||||
navigation: { visible: true, label: template.page.navigationLabel, order: 0 },
|
||||
features: Object.fromEntries(template.features.map((feature) => [feature.id, feature.required ? true : feature.defaultVisible])),
|
||||
}] : [],
|
||||
favicon: {
|
||||
source: "design-profile",
|
||||
},
|
||||
timestamps: {
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function writeApplicationManifest(manifest) {
|
||||
const targetPath = applicationPath(manifest.id);
|
||||
const tempPath = `${targetPath}.${randomUUID()}.tmp`;
|
||||
await writeFile(tempPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
||||
await rename(tempPath, targetPath);
|
||||
}
|
||||
|
||||
function applicationSummary(manifest) {
|
||||
return {
|
||||
id: manifest.id,
|
||||
name: manifest.metadata.name,
|
||||
slug: manifest.metadata.slug,
|
||||
status: manifest.status,
|
||||
version: manifest.version,
|
||||
theme: manifest.designProfile.theme,
|
||||
pageCount: manifest.pages.length,
|
||||
updatedAt: manifest.timestamps.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function readJsonBody(request, maxBytes = 2 * 1024 * 1024) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
@@ -111,6 +303,131 @@ const server = createServer(async (request, response) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/applications" && request.method === "GET") {
|
||||
const manifests = [];
|
||||
for (const entry of await readdir(applicationsDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
||||
try {
|
||||
const manifest = validateApplicationManifest(JSON.parse(await readFile(join(applicationsDir, entry.name), "utf8")));
|
||||
manifests.push(applicationSummary(manifest));
|
||||
} catch {
|
||||
// A damaged draft must not make the complete Studio project list unavailable.
|
||||
}
|
||||
}
|
||||
manifests.sort((left, right) => String(right.updatedAt).localeCompare(String(left.updatedAt)));
|
||||
json(response, 200, manifests);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/page-templates" && request.method === "GET") {
|
||||
json(response, 200, pageRegistry);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/design-profiles" && request.method === "GET") {
|
||||
const profiles = [];
|
||||
for (const entry of await readdir(designProfilesDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
||||
try { profiles.push(designProfileSummary(validateDesignProfile(JSON.parse(await readFile(join(designProfilesDir, entry.name), "utf8"))))); } catch { /* skip damaged draft */ }
|
||||
}
|
||||
profiles.sort((left, right) => left.name.localeCompare(right.name));
|
||||
json(response, 200, profiles);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/design-profiles" && request.method === "POST") {
|
||||
const input = await readJsonBody(request);
|
||||
const now = new Date().toISOString();
|
||||
const profile = validateDesignProfile({
|
||||
schemaVersion: "0.1.0",
|
||||
id: randomUUID(),
|
||||
name: input?.name,
|
||||
version: "0.1.0",
|
||||
status: "draft",
|
||||
layout: input?.layout,
|
||||
timestamps: { createdAt: now, updatedAt: now },
|
||||
});
|
||||
await writeDesignProfile(profile);
|
||||
json(response, 201, profile);
|
||||
return;
|
||||
}
|
||||
|
||||
const designProfileMatch = url.pathname.match(/^\/api\/design-profiles\/(default|[0-9a-f-]{36})$/i);
|
||||
if (designProfileMatch && request.method === "GET") {
|
||||
try { json(response, 200, validateDesignProfile(JSON.parse(await readFile(designProfilePath(designProfileMatch[1]), "utf8")))); }
|
||||
catch (error) { if (error?.code === "ENOENT") return json(response, 404, { error: "design_profile_not_found" }); throw error; }
|
||||
return;
|
||||
}
|
||||
if (designProfileMatch && request.method === "PUT") {
|
||||
let current;
|
||||
try { current = validateDesignProfile(JSON.parse(await readFile(designProfilePath(designProfileMatch[1]), "utf8"))); }
|
||||
catch (error) { if (error?.code === "ENOENT") return json(response, 404, { error: "design_profile_not_found" }); throw error; }
|
||||
const input = await readJsonBody(request);
|
||||
const next = validateDesignProfile({
|
||||
...current,
|
||||
name: input?.name || current.name,
|
||||
version: nextPatchVersion(current.version),
|
||||
layout: input?.layout,
|
||||
timestamps: { createdAt: current.timestamps.createdAt, updatedAt: new Date().toISOString() },
|
||||
});
|
||||
await writeDesignProfile(next);
|
||||
json(response, 200, next);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/applications" && request.method === "POST") {
|
||||
const input = await readJsonBody(request);
|
||||
const manifest = validateApplicationManifest(createApplicationManifest(input));
|
||||
await writeApplicationManifest(manifest);
|
||||
json(response, 201, manifest);
|
||||
return;
|
||||
}
|
||||
|
||||
const applicationMatch = url.pathname.match(/^\/api\/applications\/([0-9a-f-]{36})$/i);
|
||||
if (applicationMatch && request.method === "GET") {
|
||||
try {
|
||||
const manifest = validateApplicationManifest(JSON.parse(await readFile(applicationPath(applicationMatch[1]), "utf8")));
|
||||
json(response, 200, manifest);
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return json(response, 404, { error: "application_not_found" });
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (applicationMatch && request.method === "PUT") {
|
||||
const currentPath = applicationPath(applicationMatch[1]);
|
||||
let current;
|
||||
try {
|
||||
current = validateApplicationManifest(JSON.parse(await readFile(currentPath, "utf8")));
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return json(response, 404, { error: "application_not_found" });
|
||||
throw error;
|
||||
}
|
||||
const input = await readJsonBody(request);
|
||||
if (String(input?.id || "") !== applicationMatch[1]) throw applicationError("application_id_mismatch");
|
||||
const next = validateApplicationManifest({
|
||||
...input,
|
||||
timestamps: {
|
||||
createdAt: current.timestamps.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
await writeApplicationManifest(next);
|
||||
json(response, 200, next);
|
||||
return;
|
||||
}
|
||||
|
||||
if (applicationMatch && request.method === "DELETE") {
|
||||
const currentPath = applicationPath(applicationMatch[1]);
|
||||
try { await stat(currentPath); } catch (error) { if (error?.code === "ENOENT") return json(response, 404, { error: "application_not_found" }); throw error; }
|
||||
const deletedPath = join(runtimeDir, "deleted-applications");
|
||||
await mkdir(deletedPath, { recursive: true });
|
||||
await rename(currentPath, join(deletedPath, `${applicationMatch[1]}-${Date.now()}.json`));
|
||||
json(response, 200, { deleted: true, id: applicationMatch[1] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/layout" && request.method === "PUT") {
|
||||
const layout = await readJsonBody(request);
|
||||
const next = { ...layout, savedAt: new Date().toISOString(), schemaVersion: 1 };
|
||||
@@ -145,11 +462,13 @@ const server = createServer(async (request, response) => {
|
||||
await serveFile(request, response, join(distDir, "index.html"));
|
||||
}
|
||||
} catch (error) {
|
||||
json(response, error?.message === "payload_too_large" ? 413 : 500, { error: error?.message || "server_error" });
|
||||
const statusCode = error?.statusCode || (error?.message === "payload_too_large" ? 413 : 500);
|
||||
json(response, statusCode, { 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}`);
|
||||
console.log(`Application drafts: ${applicationsDir}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user