Include content delivery runtime
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
constants,
|
||||
copyFile,
|
||||
mkdir,
|
||||
readdir,
|
||||
rename,
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const PROJECT_ROOT = path.resolve(SCRIPT_DIRECTORY, "../..");
|
||||
export const CONTENT_SCHEMA_VERSION = 1;
|
||||
export const DEFAULT_MINIMUM_APP_VERSION = "0.1.0";
|
||||
export const BUNDLED_ANDROID_PACKS = Object.freeze([
|
||||
"core:ui",
|
||||
"core:equipment",
|
||||
"dungeon:wailing-caverns",
|
||||
]);
|
||||
|
||||
const RUNTIME_EXTENSIONS = new Set([
|
||||
".avif", ".glb", ".json", ".ktx2", ".mp3", ".ogg", ".png",
|
||||
".svg", ".wav", ".webm", ".webp",
|
||||
]);
|
||||
|
||||
export const DEFAULT_CONTENT_SOURCES = Object.freeze([
|
||||
{
|
||||
directory: path.join(PROJECT_ROOT, "public", "assets"),
|
||||
logicalPrefix: "assets",
|
||||
},
|
||||
{
|
||||
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons"),
|
||||
logicalPrefix: "assets/game/dungeons",
|
||||
},
|
||||
]);
|
||||
|
||||
function normalizedRelativePath(value) {
|
||||
return value.split(path.sep).join("/").replace(/^\/+/, "");
|
||||
}
|
||||
|
||||
function encodeUrlPath(value) {
|
||||
return value.split("/").map(encodeURIComponent).join("/");
|
||||
}
|
||||
|
||||
function packLabel(packId) {
|
||||
const [category, id = category] = packId.split(":", 2);
|
||||
const name = id.split("-").map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`).join(" ");
|
||||
if (category === "dungeon") return name;
|
||||
if (category === "characters") return `${name} Characters`;
|
||||
if (category === "manastorm") return `${name} Manastorm`;
|
||||
if (packId === "shared:creatures") return "Shared Creatures";
|
||||
if (packId === "core:ui") return "Interface";
|
||||
if (packId === "core:equipment") return "Starter Equipment";
|
||||
return name;
|
||||
}
|
||||
|
||||
export function contentPackForPath(logicalPath) {
|
||||
const segments = normalizedRelativePath(logicalPath).split("/");
|
||||
if (segments[0] !== "assets") return "core:misc";
|
||||
if (segments[1] === "characters" && segments[2]) return `characters:${segments[2]}`;
|
||||
if (segments[1] === "creatures" && segments[2]) {
|
||||
return segments[2] === "shared" ? "shared:creatures" : `dungeon:${segments[2]}`;
|
||||
}
|
||||
if (segments[1] === "game" && segments[2] === "creatures" && segments[3]) {
|
||||
return segments[3] === "shared" ? "shared:creatures" : `dungeon:${segments[3]}`;
|
||||
}
|
||||
if (segments[1] === "game" && segments[2] === "dungeons" && segments[3]) {
|
||||
return `dungeon:${segments[3]}`;
|
||||
}
|
||||
if (segments[1] === "game" && segments[2] === "manastorm" && segments[3]) {
|
||||
return `manastorm:${segments[3]}`;
|
||||
}
|
||||
if (segments[1] === "ui") return "core:ui";
|
||||
if (segments[1] === "equipment") return "core:equipment";
|
||||
return "core:misc";
|
||||
}
|
||||
|
||||
async function walkFiles(directory) {
|
||||
const files = [];
|
||||
async function visit(current) {
|
||||
const entries = await readdir(current, { withFileTypes: true });
|
||||
entries.sort((left, right) => left.name.localeCompare(right.name));
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) await visit(fullPath);
|
||||
else if (entry.isFile() && RUNTIME_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(fullPath);
|
||||
}
|
||||
}
|
||||
await visit(directory);
|
||||
return files;
|
||||
}
|
||||
|
||||
async function sha256File(filePath) {
|
||||
const { createReadStream } = await import("node:fs");
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = createHash("sha256");
|
||||
const stream = createReadStream(filePath);
|
||||
stream.on("error", reject);
|
||||
stream.on("data", (chunk) => hash.update(chunk));
|
||||
stream.on("end", () => resolve(hash.digest("hex")));
|
||||
});
|
||||
}
|
||||
|
||||
export async function scanContentSources(sources = DEFAULT_CONTENT_SOURCES) {
|
||||
const discovered = new Map();
|
||||
for (const source of sources) {
|
||||
const sourceDirectory = path.resolve(source.directory);
|
||||
const files = await walkFiles(sourceDirectory);
|
||||
for (const sourcePath of files) {
|
||||
const relative = normalizedRelativePath(path.relative(sourceDirectory, sourcePath));
|
||||
const logicalPath = normalizedRelativePath(`${source.logicalPrefix}/${relative}`);
|
||||
const fileStats = await stat(sourcePath);
|
||||
const sha256 = await sha256File(sourcePath);
|
||||
const prior = discovered.get(logicalPath);
|
||||
if (prior) {
|
||||
if (prior.sha256 !== sha256) {
|
||||
throw new Error(`Two content sources map different files to ${logicalPath}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
discovered.set(logicalPath, {
|
||||
logicalPath,
|
||||
sourcePath,
|
||||
sha256,
|
||||
bytes: fileStats.size,
|
||||
extension: path.extname(sourcePath).toLowerCase(),
|
||||
packId: contentPackForPath(logicalPath),
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...discovered.values()].sort((left, right) => left.logicalPath.localeCompare(right.logicalPath));
|
||||
}
|
||||
|
||||
function contentVersion(files) {
|
||||
const digest = createHash("sha256");
|
||||
for (const file of files) digest.update(`${file.logicalPath}\0${file.sha256}\0${file.bytes}\n`);
|
||||
return `content-${digest.digest("hex").slice(0, 16)}`;
|
||||
}
|
||||
|
||||
export function createContentManifest(files, options = {}) {
|
||||
const baseUrl = String(options.baseUrl ?? "/content/objects").replace(/\/+$/, "");
|
||||
const version = options.version ?? contentVersion(files);
|
||||
const packs = new Map();
|
||||
const fileEntries = {};
|
||||
|
||||
for (const file of files) {
|
||||
const objectName = `${file.sha256}${file.extension}`;
|
||||
const logicalUrl = `/${normalizedRelativePath(file.logicalPath)}`;
|
||||
fileEntries[logicalUrl] = {
|
||||
packId: file.packId,
|
||||
url: `${baseUrl}/${encodeUrlPath(objectName)}`,
|
||||
sha256: file.sha256,
|
||||
bytes: file.bytes,
|
||||
};
|
||||
const pack = packs.get(file.packId) ?? {
|
||||
id: file.packId,
|
||||
label: packLabel(file.packId),
|
||||
bytes: 0,
|
||||
fileCount: 0,
|
||||
bundledOnAndroid: BUNDLED_ANDROID_PACKS.includes(file.packId),
|
||||
dependencies: [],
|
||||
};
|
||||
pack.bytes += file.bytes;
|
||||
pack.fileCount += 1;
|
||||
packs.set(file.packId, pack);
|
||||
}
|
||||
|
||||
if (packs.has("shared:creatures")) {
|
||||
for (const pack of packs.values()) {
|
||||
if (pack.id.startsWith("dungeon:") || pack.id.startsWith("manastorm:")) {
|
||||
pack.dependencies = ["shared:creatures"];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: CONTENT_SCHEMA_VERSION,
|
||||
version,
|
||||
createdAt: options.createdAt ?? new Date().toISOString(),
|
||||
minimumAppVersion: options.minimumAppVersion ?? DEFAULT_MINIMUM_APP_VERSION,
|
||||
packs: [...packs.values()].sort((left, right) => left.id.localeCompare(right.id)),
|
||||
files: fileEntries,
|
||||
};
|
||||
}
|
||||
|
||||
async function copyObjectIfMissing(file, objectDirectory) {
|
||||
const objectName = `${file.sha256}${file.extension}`;
|
||||
const destination = path.join(objectDirectory, objectName);
|
||||
try {
|
||||
const destinationStats = await stat(destination);
|
||||
if (destinationStats.size !== file.bytes) throw new Error(`Published object has the wrong size: ${destination}`);
|
||||
return false;
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
try {
|
||||
await copyFile(file.sourcePath, destination, constants.COPYFILE_EXCL);
|
||||
} catch (error) {
|
||||
if (error?.code !== "EEXIST") throw error;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function replaceJsonAtomically(destination, value) {
|
||||
const temporary = `${destination}.${process.pid}.${Date.now()}.tmp`;
|
||||
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
await rename(temporary, destination);
|
||||
}
|
||||
|
||||
export async function publishContent(options = {}) {
|
||||
const contentDirectory = path.resolve(options.contentDirectory ?? process.env.CONTENT_DIR ?? path.join(PROJECT_ROOT, "content"));
|
||||
const objectDirectory = path.join(contentDirectory, "objects");
|
||||
const manifestDirectory = path.join(contentDirectory, "manifests");
|
||||
await mkdir(objectDirectory, { recursive: true });
|
||||
await mkdir(manifestDirectory, { recursive: true });
|
||||
|
||||
const files = await scanContentSources(options.sources ?? DEFAULT_CONTENT_SOURCES);
|
||||
let copiedObjects = 0;
|
||||
for (const file of files) {
|
||||
if (await copyObjectIfMissing(file, objectDirectory)) copiedObjects += 1;
|
||||
}
|
||||
const manifest = createContentManifest(files, options);
|
||||
await replaceJsonAtomically(path.join(manifestDirectory, `${manifest.version}.json`), manifest);
|
||||
await replaceJsonAtomically(path.join(contentDirectory, "manifest.json"), manifest);
|
||||
return { contentDirectory, copiedObjects, manifest };
|
||||
}
|
||||
|
||||
function parseCliArguments(argv) {
|
||||
const options = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const value = argv[index];
|
||||
if (value === "--content-dir") options.contentDirectory = argv[++index];
|
||||
else if (value === "--base-url") options.baseUrl = argv[++index];
|
||||
else if (value === "--minimum-app-version") options.minimumAppVersion = argv[++index];
|
||||
else throw new Error(`Unknown content publishing argument: ${value}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const result = await publishContent(parseCliArguments(process.argv.slice(2)));
|
||||
const bytes = result.manifest.packs.reduce((total, pack) => total + pack.bytes, 0);
|
||||
console.log(`Published ${result.manifest.files ? Object.keys(result.manifest.files).length : 0} files (${bytes} bytes)`);
|
||||
console.log(`Content version: ${result.manifest.version}`);
|
||||
console.log(`New immutable objects: ${result.copiedObjects}`);
|
||||
console.log(`Manifest: ${path.join(result.contentDirectory, "manifest.json")}`);
|
||||
}
|
||||
Reference in New Issue
Block a user