224 lines
6.7 KiB
JavaScript
224 lines
6.7 KiB
JavaScript
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
import path from "node:path";
|
|
|
|
export const MAX_BUILD_FRAME_BYTES = 512 * 1024;
|
|
export const BUILD_TIMEOUT_MS = 30 * 60 * 1000;
|
|
const packages = new Set(
|
|
[
|
|
"common",
|
|
"infra",
|
|
"logic",
|
|
"game-engine",
|
|
"game-api",
|
|
"game-frontend",
|
|
"gateway-api",
|
|
"gateway-frontend",
|
|
"release-controller",
|
|
].map((name) => `@sammo-ts/${name}`),
|
|
);
|
|
const within = (candidate, root) => {
|
|
const relative = path.relative(root, candidate);
|
|
return (
|
|
relative === "" ||
|
|
(!relative.startsWith("..") && !path.isAbsolute(relative))
|
|
);
|
|
};
|
|
|
|
export const readBuilderToken = (filename) => {
|
|
try {
|
|
if (
|
|
!filename ||
|
|
!statSync(filename).isFile() ||
|
|
statSync(filename).size > 256
|
|
)
|
|
throw new Error();
|
|
const token = readFileSync(filename, "utf8").trim();
|
|
if (
|
|
token.startsWith("replace-with-") ||
|
|
!/^[A-Za-z0-9_-]{43,128}$/.test(token)
|
|
)
|
|
throw new Error();
|
|
return token;
|
|
} catch {
|
|
throw new Error("A valid RELEASE_BUILDER_TOKEN_FILE is required.");
|
|
}
|
|
};
|
|
|
|
const statExists = (filename) => {
|
|
try {
|
|
statSync(filename);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
const safeCachePath = (value, roots) => {
|
|
if (!path.isAbsolute(value) || value.length > 2048)
|
|
throw new Error("Invalid build cache path.");
|
|
const resolved = path.resolve(value);
|
|
if (!roots.some((root) => within(resolved, root)))
|
|
throw new Error("Build cache is outside shared storage.");
|
|
let existing = resolved;
|
|
while (!statExists(existing)) existing = path.dirname(existing);
|
|
if (!roots.some((root) => within(realpathSync(existing), root)))
|
|
throw new Error("Build cache symlink leaves shared storage.");
|
|
return resolved;
|
|
};
|
|
const safeEnv = (env, roots) => {
|
|
if (
|
|
!env ||
|
|
typeof env !== "object" ||
|
|
Array.isArray(env) ||
|
|
Object.keys(env).length > 32
|
|
)
|
|
throw new Error("Invalid build environment.");
|
|
const result = {};
|
|
for (const [name, value] of Object.entries(env)) {
|
|
if (
|
|
typeof value !== "string" ||
|
|
value.length > 8192 ||
|
|
value.includes("\0")
|
|
)
|
|
throw new Error("Invalid build environment.");
|
|
if (
|
|
/^VITE_[A-Z0-9_]+$/.test(name) ||
|
|
name === "TZ" ||
|
|
(name === "CI" && ["true", "false", "1", "0"].includes(value))
|
|
)
|
|
result[name] = value;
|
|
else if (
|
|
["NODE_OPTIONS", "PROFILE_FRONTEND_BUILD_NODE_OPTIONS"].includes(name)
|
|
) {
|
|
const heap = /^--max-old-space-size=(\d+)$/.exec(value);
|
|
if (!heap || Number(heap[1]) < 512 || Number(heap[1]) > 3584)
|
|
throw new Error("Invalid build heap control.");
|
|
result[name] = value;
|
|
} else if (
|
|
["RAYON_NUM_THREADS", "RELEASE_TURBO_CONCURRENCY"].includes(name)
|
|
) {
|
|
if (!/^[1-4]$/.test(value))
|
|
throw new Error("Invalid build concurrency control.");
|
|
result[name] = value;
|
|
} else if (name === "TURBO_CACHE_DIR")
|
|
result[name] = safeCachePath(value, roots);
|
|
else if (name !== "PATH")
|
|
throw new Error("Unsupported build environment variable.");
|
|
}
|
|
return result;
|
|
};
|
|
|
|
export const validateBuildCommand = (raw, allowedRoots, cacheRoots) => {
|
|
if (
|
|
!raw ||
|
|
typeof raw !== "object" ||
|
|
Array.isArray(raw) ||
|
|
!Array.isArray(raw.args) ||
|
|
raw.args.length > 40 ||
|
|
typeof raw.cwd !== "string" ||
|
|
raw.cwd.length > 2048 ||
|
|
!path.isAbsolute(raw.cwd) ||
|
|
raw.args.some(
|
|
(arg) =>
|
|
typeof arg !== "string" || arg.length > 2048 || arg.includes("\0"),
|
|
)
|
|
)
|
|
throw new Error("Invalid build command.");
|
|
const cwd = realpathSync(raw.cwd);
|
|
if (!allowedRoots.some((root) => within(cwd, root)))
|
|
throw new Error("Build cwd is outside shared storage.");
|
|
const args = raw.args;
|
|
const install =
|
|
args.length === 2 &&
|
|
args[0] === "install" &&
|
|
args[1] === "--frozen-lockfile";
|
|
const packageTask =
|
|
args.length === 3 &&
|
|
args[0] === "--filter" &&
|
|
packages.has(args[1]) &&
|
|
(args[2] === "build" ||
|
|
(args[1] === "@sammo-ts/infra" && args[2] === "prisma:generate"));
|
|
let turbo = false;
|
|
if (
|
|
args[0] === "exec" &&
|
|
args[1] === "turbo" &&
|
|
args[2] === "run" &&
|
|
["build", "build:release"].includes(args[3])
|
|
) {
|
|
const flags = args.slice(4);
|
|
const seen = new Set();
|
|
let filters = 0;
|
|
turbo =
|
|
flags.every((flag) => {
|
|
if (flag.startsWith("--filter=")) {
|
|
filters += 1;
|
|
return packages.has(flag.slice(9));
|
|
}
|
|
const name = flag.split("=")[0];
|
|
if (seen.has(name)) return false;
|
|
seen.add(name);
|
|
if (flag.startsWith("--cache-dir=")) {
|
|
safeCachePath(flag.slice(12), cacheRoots);
|
|
return true;
|
|
}
|
|
return (
|
|
/^--concurrency=[1-4]$/.test(flag) ||
|
|
flag === "--ui=stream" ||
|
|
flag === "--output-logs=new-only"
|
|
);
|
|
}) && filters > 0;
|
|
}
|
|
const materialize =
|
|
args.length === 2 &&
|
|
args[0] === "tools/build-scripts/materialize-profile-frontend.mjs" &&
|
|
/^(?:che|kwe|pwe|twe|nya|pya|hwe):[A-Za-z0-9_.-]{1,64}$/.test(args[1]);
|
|
if (!(
|
|
(raw.command === "pnpm" && (install || packageTask || turbo)) ||
|
|
(raw.command === "node" && materialize)
|
|
))
|
|
throw new Error("Unsupported release build command.");
|
|
return {
|
|
command: raw.command,
|
|
args,
|
|
cwd,
|
|
env: safeEnv(raw.env ?? {}, cacheRoots),
|
|
};
|
|
};
|
|
|
|
export async function* readBuilderMessages(response) {
|
|
if (!response.ok || !response.body) {
|
|
await response.body?.cancel().catch(() => undefined);
|
|
throw new Error(`Release builder returned HTTP ${response.status}.`);
|
|
}
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
let pending = "";
|
|
let bytes = 0;
|
|
try {
|
|
while (true) {
|
|
const chunk = await reader.read();
|
|
if (chunk.done) break;
|
|
bytes += chunk.value.byteLength;
|
|
if (bytes > 64 * 1024 * 1024)
|
|
throw new Error("Release builder stream is too large.");
|
|
pending += decoder.decode(chunk.value, { stream: true });
|
|
let newline;
|
|
while ((newline = pending.indexOf("\n")) >= 0) {
|
|
const line = pending.slice(0, newline);
|
|
pending = pending.slice(newline + 1);
|
|
if (Buffer.byteLength(line) > MAX_BUILD_FRAME_BYTES)
|
|
throw new Error("Release builder frame is too large.");
|
|
if (line.trim()) yield JSON.parse(line);
|
|
}
|
|
if (Buffer.byteLength(pending) > MAX_BUILD_FRAME_BYTES)
|
|
throw new Error("Release builder frame is too large.");
|
|
}
|
|
pending += decoder.decode();
|
|
if (Buffer.byteLength(pending) > MAX_BUILD_FRAME_BYTES)
|
|
throw new Error("Release builder frame is too large.");
|
|
if (pending.trim()) yield JSON.parse(pending);
|
|
} finally {
|
|
await reader.cancel().catch(() => undefined);
|
|
reader.releaseLock();
|
|
}
|
|
}
|