mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
1081 lines
38 KiB
JavaScript
1081 lines
38 KiB
JavaScript
const { execFile } = require("node:child_process");
|
|
const { access, cp, lstat, mkdir, readFile, readlink, readdir, realpath, rm, stat, symlink, writeFile } = require("node:fs/promises");
|
|
const { createRequire } = require("node:module");
|
|
const path = require("node:path");
|
|
const { promisify } = require("node:util");
|
|
|
|
const CONFIG_ENV = "OD_TOOLS_PACK_WEB_STANDALONE_HOOK_CONFIG";
|
|
const STANDALONE_RESOURCE_NAME = "open-design-web-standalone";
|
|
const REQUIRED_MODULES = ["next/package.json", "react/package.json", "react-dom/package.json", "styled-jsx/package.json"];
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
function isRecord(value) {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function requireString(record, key) {
|
|
const value = record[key];
|
|
if (typeof value !== "string" || value.length === 0) {
|
|
throw new Error(`[tools-pack web-standalone] config.${key} must be a non-empty string`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function requireBoolean(record, key) {
|
|
const value = record[key];
|
|
if (typeof value !== "boolean") {
|
|
throw new Error(`[tools-pack web-standalone] config.${key} must be a boolean`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function optionalBoolean(record, key, fallback) {
|
|
const value = record[key];
|
|
if (value == null) return fallback;
|
|
if (typeof value !== "boolean") {
|
|
throw new Error(`[tools-pack web-standalone] config.${key} must be a boolean`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function requireAbsolutePath(record, key) {
|
|
const value = requireString(record, key);
|
|
if (!path.isAbsolute(value)) {
|
|
throw new Error(`[tools-pack web-standalone] config.${key} must be absolute: ${value}`);
|
|
}
|
|
return path.resolve(value);
|
|
}
|
|
|
|
function isWithin(parent, child) {
|
|
const relative = path.relative(parent, child);
|
|
return relative.length === 0 || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
}
|
|
|
|
async function isWithinPhysicalPath(parent, child) {
|
|
const [realParent, realChild] = await Promise.all([realpath(parent), realpath(child)]);
|
|
return isWithin(realParent, realChild);
|
|
}
|
|
|
|
async function pathExists(filePath) {
|
|
try {
|
|
await access(filePath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function pathLstatExists(filePath) {
|
|
try {
|
|
await lstat(filePath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function readHookConfig() {
|
|
const configPath = process.env[CONFIG_ENV];
|
|
if (configPath == null || configPath.length === 0) {
|
|
throw new Error(`[tools-pack web-standalone] missing ${CONFIG_ENV}`);
|
|
}
|
|
if (!path.isAbsolute(configPath)) {
|
|
throw new Error(`[tools-pack web-standalone] ${CONFIG_ENV} must be absolute: ${configPath}`);
|
|
}
|
|
|
|
const raw = JSON.parse(await readFile(configPath, "utf8"));
|
|
if (!isRecord(raw) || raw.version !== 1) {
|
|
throw new Error("[tools-pack web-standalone] hook config must be an object with version=1");
|
|
}
|
|
|
|
const workspaceRoot = requireAbsolutePath(raw, "workspaceRoot");
|
|
const standaloneSourceRoot = requireAbsolutePath(raw, "standaloneSourceRoot");
|
|
const webStaticSourceRoot = requireAbsolutePath(raw, "webStaticSourceRoot");
|
|
const webPublicSourceRoot = requireAbsolutePath(raw, "webPublicSourceRoot");
|
|
const auditReportPath = requireAbsolutePath(raw, "auditReportPath");
|
|
const resourceName = requireString(raw, "resourceName");
|
|
if (resourceName !== STANDALONE_RESOURCE_NAME) {
|
|
throw new Error(`[tools-pack web-standalone] unsupported resourceName: ${resourceName}`);
|
|
}
|
|
|
|
for (const [key, value] of Object.entries({ standaloneSourceRoot, webStaticSourceRoot, webPublicSourceRoot })) {
|
|
if (!isWithin(workspaceRoot, value)) {
|
|
throw new Error(`[tools-pack web-standalone] config.${key} must stay under workspaceRoot: ${value}`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
auditReportPath,
|
|
hyperframesRuntimeSourceRoot: requireAbsolutePath(raw, "hyperframesRuntimeSourceRoot"),
|
|
macAdhocBundleSign: optionalBoolean(raw, "macAdhocBundleSign", false),
|
|
pruneCopiedSharp: requireBoolean(raw, "pruneCopiedSharp"),
|
|
pruneRootNext: requireBoolean(raw, "pruneRootNext"),
|
|
pruneRootSharp: requireBoolean(raw, "pruneRootSharp"),
|
|
requireRootWebPackageAudit: optionalBoolean(raw, "requireRootWebPackageAudit", true),
|
|
resourceName,
|
|
standaloneSourceRoot,
|
|
webPublicSourceRoot,
|
|
webStaticSourceRoot,
|
|
workspaceRoot,
|
|
};
|
|
}
|
|
|
|
function resolveAppPath(context) {
|
|
if (context == null || typeof context.appOutDir !== "string" || context.appOutDir.length === 0) {
|
|
throw new Error("[tools-pack web-standalone] electron-builder context.appOutDir is missing");
|
|
}
|
|
const productFilename = context.packager?.appInfo?.productFilename;
|
|
if (typeof productFilename !== "string" || productFilename.length === 0) {
|
|
throw new Error("[tools-pack web-standalone] electron-builder productFilename is missing");
|
|
}
|
|
if (context.electronPlatformName === "win32") return context.appOutDir;
|
|
return path.join(context.appOutDir, `${productFilename}.app`);
|
|
}
|
|
|
|
function resolveResourcesRoot(context, appPath) {
|
|
switch (context?.electronPlatformName) {
|
|
case "darwin":
|
|
return path.join(appPath, "Contents", "Resources");
|
|
case "win32":
|
|
return path.join(context.appOutDir, "resources");
|
|
default:
|
|
throw new Error(`[tools-pack web-standalone] unsupported platform: ${context?.electronPlatformName ?? "unknown"}`);
|
|
}
|
|
}
|
|
|
|
function resolveRootAppNodeModulesRoot(resourcesRoot) {
|
|
return path.join(resourcesRoot, "app", "node_modules");
|
|
}
|
|
|
|
async function sizePathBytes(filePath) {
|
|
let metadata;
|
|
try {
|
|
metadata = await lstat(filePath);
|
|
} catch {
|
|
return 0;
|
|
}
|
|
|
|
if (!metadata.isDirectory()) return metadata.size;
|
|
|
|
const entries = await readdir(filePath, { withFileTypes: true }).catch(() => []);
|
|
let total = 0;
|
|
for (const entry of entries) {
|
|
total += await sizePathBytes(path.join(filePath, entry.name));
|
|
}
|
|
return total;
|
|
}
|
|
|
|
async function copyRequired(sourcePath, destinationPath, options = {}) {
|
|
if (!(await pathExists(sourcePath))) {
|
|
throw new Error(`[tools-pack web-standalone] required source missing: ${sourcePath}`);
|
|
}
|
|
await rm(destinationPath, { force: true, recursive: true });
|
|
await mkdir(path.dirname(destinationPath), { recursive: true });
|
|
await cp(sourcePath, destinationPath, {
|
|
dereference: options.dereference === true,
|
|
recursive: true,
|
|
verbatimSymlinks: options.dereference === true ? false : true,
|
|
});
|
|
}
|
|
|
|
async function copyOptional(sourcePath, destinationPath, options = {}) {
|
|
if (!(await pathExists(sourcePath))) return false;
|
|
await copyRequired(sourcePath, destinationPath, options);
|
|
return true;
|
|
}
|
|
|
|
async function linkRelative(sourcePath, destinationPath, options = {}) {
|
|
if (!(await pathExists(sourcePath))) return false;
|
|
if (await pathLstatExists(destinationPath)) return false;
|
|
await mkdir(path.dirname(destinationPath), { recursive: true });
|
|
if (options.copyInsteadOfSymlink === true) {
|
|
await copyRequired(sourcePath, destinationPath, { dereference: true });
|
|
return true;
|
|
}
|
|
const relativeTarget = path.relative(path.dirname(destinationPath), sourcePath);
|
|
await symlink(relativeTarget.length === 0 ? "." : relativeTarget, destinationPath);
|
|
return true;
|
|
}
|
|
|
|
async function linkPnpmPublicHoist(destinationRoot, options = {}) {
|
|
const nodeModulesRoot = path.join(destinationRoot, "node_modules");
|
|
const hoistRoot = path.join(nodeModulesRoot, ".pnpm", "node_modules");
|
|
const entries = await readdir(hoistRoot, { withFileTypes: true }).catch(() => []);
|
|
const linked = [];
|
|
|
|
for (const entry of entries) {
|
|
const sourcePath = path.join(hoistRoot, entry.name);
|
|
if (entry.name.startsWith("@") && entry.isDirectory()) {
|
|
const scopedEntries = await readdir(sourcePath).catch(() => []);
|
|
for (const scopedEntry of scopedEntries) {
|
|
const scopedSource = path.join(sourcePath, scopedEntry);
|
|
const scopedDestination = path.join(nodeModulesRoot, entry.name, scopedEntry);
|
|
if (await linkRelative(scopedSource, scopedDestination, options)) linked.push(scopedDestination);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const destinationPath = path.join(nodeModulesRoot, entry.name);
|
|
if (await linkRelative(sourcePath, destinationPath, options)) linked.push(destinationPath);
|
|
}
|
|
|
|
return linked;
|
|
}
|
|
|
|
async function resolveStandaloneSourceWebRoot(standaloneSourceRoot) {
|
|
const candidates = [
|
|
path.join(standaloneSourceRoot, "apps", "web"),
|
|
standaloneSourceRoot,
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
if (await pathExists(path.join(candidate, "server.js"))) return candidate;
|
|
}
|
|
|
|
throw new Error(`[tools-pack web-standalone] standalone server.js not found under ${standaloneSourceRoot}`);
|
|
}
|
|
|
|
async function installStandaloneResource(config, resourcesRoot, platformName) {
|
|
const sourceWebRoot = await resolveStandaloneSourceWebRoot(config.standaloneSourceRoot);
|
|
const destinationRoot = path.join(resourcesRoot, config.resourceName);
|
|
const destinationWebRoot = path.join(destinationRoot, "apps", "web");
|
|
const copyOptions = { dereference: platformName === "win32" };
|
|
|
|
await rm(destinationRoot, { force: true, recursive: true });
|
|
await mkdir(destinationWebRoot, { recursive: true });
|
|
|
|
await copyRequired(path.join(config.standaloneSourceRoot, "node_modules"), path.join(destinationRoot, "node_modules"), copyOptions);
|
|
await copyRequired(path.join(sourceWebRoot, "server.js"), path.join(destinationWebRoot, "server.js"));
|
|
await copyOptional(path.join(sourceWebRoot, "package.json"), path.join(destinationWebRoot, "package.json"));
|
|
const copiedNestedNodeModules = await copyOptional(path.join(sourceWebRoot, "node_modules"), path.join(destinationWebRoot, "node_modules"), copyOptions);
|
|
const linkedHoistEntries = await linkPnpmPublicHoist(destinationRoot, { copyInsteadOfSymlink: platformName === "win32" });
|
|
await copyRequired(path.join(sourceWebRoot, ".next"), path.join(destinationWebRoot, ".next"));
|
|
const copiedStatic = await copyOptional(config.webStaticSourceRoot, path.join(destinationWebRoot, ".next", "static"));
|
|
const copiedPublic = await copyOptional(config.webPublicSourceRoot, path.join(destinationWebRoot, "public"));
|
|
const rewrittenSymlinks = platformName === "win32"
|
|
? []
|
|
: await rewriteCopiedStandaloneSymlinks({
|
|
destinationRoot,
|
|
destinationWebRoot,
|
|
sourceWebRoot,
|
|
standaloneSourceRoot: config.standaloneSourceRoot,
|
|
});
|
|
|
|
return {
|
|
copiedNestedNodeModules,
|
|
copiedPublic,
|
|
copiedStatic,
|
|
destinationRoot,
|
|
destinationWebRoot,
|
|
linkedHoistEntries,
|
|
rewrittenSymlinks,
|
|
sourceWebRoot,
|
|
};
|
|
}
|
|
|
|
async function rewriteCopiedStandaloneSymlinks(options) {
|
|
const mappings = [
|
|
{
|
|
destinationRoot: path.join(options.destinationWebRoot, "node_modules"),
|
|
sourceRoot: path.join(options.sourceWebRoot, "node_modules"),
|
|
},
|
|
{
|
|
destinationRoot: path.join(options.destinationRoot, "node_modules"),
|
|
sourceRoot: path.join(options.standaloneSourceRoot, "node_modules"),
|
|
},
|
|
{
|
|
destinationRoot: options.destinationWebRoot,
|
|
sourceRoot: options.sourceWebRoot,
|
|
},
|
|
{
|
|
destinationRoot: options.destinationRoot,
|
|
sourceRoot: options.standaloneSourceRoot,
|
|
},
|
|
];
|
|
const rewrittenSymlinks = [];
|
|
|
|
function mapPath(pathToMap, fromKey, toKey) {
|
|
for (const mapping of mappings) {
|
|
if (!isWithin(mapping[fromKey], pathToMap)) continue;
|
|
return path.join(mapping[toKey], path.relative(mapping[fromKey], pathToMap));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function visit(current) {
|
|
let metadata;
|
|
try {
|
|
metadata = await lstat(current);
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
if (metadata.isSymbolicLink()) {
|
|
const copiedSourcePath = mapPath(current, "destinationRoot", "sourceRoot");
|
|
if (copiedSourcePath == null) return;
|
|
|
|
const currentTarget = await readlink(current);
|
|
const sourceTarget = path.resolve(path.dirname(copiedSourcePath), currentTarget);
|
|
const destinationTarget = mapPath(sourceTarget, "sourceRoot", "destinationRoot");
|
|
// External source-tree symlinks are not rewritten; the closure audit below
|
|
// reports them as externalSymlink and fails the package instead.
|
|
if (destinationTarget == null) return;
|
|
|
|
const nextTarget = path.relative(path.dirname(current), destinationTarget) || ".";
|
|
if (nextTarget === currentTarget) return;
|
|
|
|
await rm(current, { force: true, recursive: true });
|
|
await symlink(nextTarget, current);
|
|
rewrittenSymlinks.push({
|
|
path: current,
|
|
target: nextTarget,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!metadata.isDirectory()) return;
|
|
|
|
const entries = await readdir(current, { withFileTypes: true }).catch(() => []);
|
|
for (const entry of entries) {
|
|
await visit(path.join(current, entry.name));
|
|
}
|
|
}
|
|
|
|
await visit(options.destinationRoot);
|
|
return rewrittenSymlinks;
|
|
}
|
|
|
|
async function removePathAndRecord(targetPath, reason, removedPaths) {
|
|
const existed = await pathExists(targetPath);
|
|
const bytes = await sizePathBytes(targetPath);
|
|
await rm(targetPath, { force: true, recursive: true });
|
|
if (existed || bytes > 0) {
|
|
removedPaths.push({ bytes, path: targetPath, reason });
|
|
}
|
|
}
|
|
|
|
function isPrunablePnpmSharpEntry(name) {
|
|
return name.startsWith("sharp@") || name.startsWith("@img+colour@") || name.startsWith("@img+sharp-");
|
|
}
|
|
|
|
function isPrunableImgEntry(name) {
|
|
return name === "colour" || name.startsWith("sharp-");
|
|
}
|
|
|
|
async function pruneImgScope(scopePath, reason, removedPaths) {
|
|
const entries = await readdir(scopePath).catch(() => []);
|
|
for (const entry of entries) {
|
|
if (isPrunableImgEntry(entry)) {
|
|
await removePathAndRecord(path.join(scopePath, entry), reason, removedPaths);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function pruneCopiedSharp(destinationRoot) {
|
|
const nodeModulesRoot = path.join(destinationRoot, "node_modules");
|
|
const pnpmRoot = path.join(nodeModulesRoot, ".pnpm");
|
|
const removedPaths = [];
|
|
|
|
await removePathAndRecord(path.join(nodeModulesRoot, "sharp"), "copied top-level sharp symlink", removedPaths);
|
|
await pruneImgScope(path.join(nodeModulesRoot, "@img"), "copied top-level @img sharp symlink", removedPaths);
|
|
await removePathAndRecord(path.join(pnpmRoot, "node_modules", "sharp"), "copied pnpm sharp symlink", removedPaths);
|
|
await pruneImgScope(path.join(pnpmRoot, "node_modules", "@img"), "copied pnpm @img sharp symlink", removedPaths);
|
|
|
|
const pnpmEntries = await readdir(pnpmRoot).catch(() => []);
|
|
for (const entry of pnpmEntries) {
|
|
if (isPrunablePnpmSharpEntry(entry)) {
|
|
await removePathAndRecord(path.join(pnpmRoot, entry), "copied pnpm sharp package", removedPaths);
|
|
continue;
|
|
}
|
|
|
|
if (entry.startsWith("next@")) {
|
|
await removePathAndRecord(path.join(pnpmRoot, entry, "node_modules", "sharp"), "copied next sharp symlink", removedPaths);
|
|
}
|
|
}
|
|
|
|
return removedPaths;
|
|
}
|
|
|
|
async function dedupeCopiedStandaloneNext(destinationRoot, destinationWebRoot, platformName) {
|
|
if (platformName !== "win32") return null;
|
|
|
|
const nodeModulesRoot = path.join(destinationRoot, "node_modules");
|
|
const rootNextRoot = path.join(nodeModulesRoot, "next");
|
|
const pnpmHoistedNextRoot = path.join(nodeModulesRoot, ".pnpm", "node_modules", "next");
|
|
const webNextRoot = path.join(destinationWebRoot, "node_modules", "next");
|
|
const removedPaths = [];
|
|
|
|
if (!(await pathExists(path.join(webNextRoot, "package.json")))) {
|
|
throw new Error(`[tools-pack web-standalone] copied standalone app-local Next package missing: ${webNextRoot}`);
|
|
}
|
|
|
|
await removePathAndRecord(
|
|
rootNextRoot,
|
|
"copied standalone root next public-hoist duplicate",
|
|
removedPaths,
|
|
);
|
|
await removePathAndRecord(
|
|
pnpmHoistedNextRoot,
|
|
"copied standalone pnpm-hoisted next duplicate superseded by app-local next",
|
|
removedPaths,
|
|
);
|
|
|
|
return {
|
|
removedPaths,
|
|
retainedPath: webNextRoot,
|
|
};
|
|
}
|
|
|
|
async function pruneBrokenSymlinks(root, current = root, removedPaths = [], reason = "broken symlink") {
|
|
let metadata;
|
|
try {
|
|
metadata = await lstat(current);
|
|
} catch {
|
|
return removedPaths;
|
|
}
|
|
|
|
if (metadata.isSymbolicLink()) {
|
|
try {
|
|
await stat(current);
|
|
} catch {
|
|
await removePathAndRecord(current, reason, removedPaths);
|
|
}
|
|
return removedPaths;
|
|
}
|
|
|
|
if (!metadata.isDirectory()) return removedPaths;
|
|
|
|
const entries = await readdir(current, { withFileTypes: true }).catch(() => []);
|
|
for (const entry of entries) {
|
|
await pruneBrokenSymlinks(root, path.join(current, entry.name), removedPaths, reason);
|
|
}
|
|
return removedPaths;
|
|
}
|
|
|
|
function isSourceBuildResidue(relativePath) {
|
|
const normalized = relativePath.split(path.sep).join("/");
|
|
return normalized.endsWith(".map") || normalized.endsWith(".tsbuildinfo");
|
|
}
|
|
|
|
async function pruneMatchingFilesSummary(root, includeRelativePath, reason) {
|
|
const summary = {
|
|
bytes: 0,
|
|
count: 0,
|
|
reason,
|
|
root,
|
|
};
|
|
|
|
async function visit(current) {
|
|
let metadata;
|
|
try {
|
|
metadata = await lstat(current);
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
if (metadata.isDirectory()) {
|
|
const entries = await readdir(current, { withFileTypes: true }).catch(() => []);
|
|
for (const entry of entries) {
|
|
await visit(path.join(current, entry.name));
|
|
}
|
|
return;
|
|
}
|
|
|
|
const relativePath = path.relative(root, current);
|
|
if (relativePath.length === 0 || !includeRelativePath(relativePath)) return;
|
|
summary.bytes += metadata.size;
|
|
summary.count += 1;
|
|
await rm(current, { force: true });
|
|
}
|
|
|
|
await visit(root);
|
|
return summary.count > 0 ? [summary] : [];
|
|
}
|
|
|
|
async function pruneSourceBuildResidue(root, reason) {
|
|
return await pruneMatchingFilesSummary(root, isSourceBuildResidue, reason);
|
|
}
|
|
|
|
function isForbiddenCopiedEntry(relativePath, platformName) {
|
|
const normalized = relativePath.split(path.sep).join("/");
|
|
const withRootSlash = `/${normalized}`;
|
|
const forbiddenSwc = platformName === "win32"
|
|
? withRootSlash.includes("swc-darwin") || withRootSlash.includes("swc-linux")
|
|
: withRootSlash.includes("swc-darwin");
|
|
return (
|
|
withRootSlash.includes("/node_modules/.pnpm/sharp@") ||
|
|
withRootSlash.includes("/node_modules/.pnpm/@img+colour@") ||
|
|
withRootSlash.includes("/node_modules/.pnpm/@img+sharp-") ||
|
|
withRootSlash.includes("/node_modules/sharp") ||
|
|
withRootSlash.includes("/node_modules/@img/colour") ||
|
|
withRootSlash.includes("/node_modules/@img/sharp-") ||
|
|
withRootSlash.includes("sharp-libvips") ||
|
|
forbiddenSwc
|
|
);
|
|
}
|
|
|
|
async function collectClosureStats(
|
|
root,
|
|
current = root,
|
|
stats = { brokenSymlinks: [], externalSymlinks: [], forbiddenEntries: [], symlinks: 0 },
|
|
platformName = process.platform,
|
|
) {
|
|
let metadata;
|
|
try {
|
|
metadata = await lstat(current);
|
|
} catch {
|
|
return stats;
|
|
}
|
|
|
|
const relativePath = path.relative(root, current);
|
|
if (relativePath.length > 0 && isForbiddenCopiedEntry(relativePath, platformName)) {
|
|
stats.forbiddenEntries.push(relativePath.split(path.sep).join("/"));
|
|
}
|
|
|
|
if (metadata.isSymbolicLink()) {
|
|
stats.symlinks += 1;
|
|
try {
|
|
await stat(current);
|
|
if (!(await isWithinPhysicalPath(root, current))) {
|
|
stats.externalSymlinks.push(relativePath.split(path.sep).join("/"));
|
|
}
|
|
} catch {
|
|
stats.brokenSymlinks.push(relativePath.split(path.sep).join("/"));
|
|
}
|
|
return stats;
|
|
}
|
|
|
|
if (!metadata.isDirectory()) return stats;
|
|
|
|
const entries = await readdir(current, { withFileTypes: true }).catch(() => []);
|
|
for (const entry of entries) {
|
|
await collectClosureStats(root, path.join(current, entry.name), stats, platformName);
|
|
}
|
|
return stats;
|
|
}
|
|
|
|
function isMacCodeBundle(name) {
|
|
return name.endsWith(".app") || name.endsWith(".framework");
|
|
}
|
|
|
|
async function ensureRelativeSymlink(linkPath, targetPath, type) {
|
|
if (await pathLstatExists(linkPath)) {
|
|
const metadata = await lstat(linkPath);
|
|
if (metadata.isSymbolicLink()) {
|
|
const existingTarget = await readlink(linkPath);
|
|
if (existingTarget === targetPath) return false;
|
|
}
|
|
await rm(linkPath, { force: true, recursive: true });
|
|
}
|
|
|
|
await symlink(targetPath, linkPath, type);
|
|
return true;
|
|
}
|
|
|
|
async function normalizeMacVersionedFramework(frameworkPath) {
|
|
const versionsRoot = path.join(frameworkPath, "Versions");
|
|
const entries = await readdir(versionsRoot, { withFileTypes: true }).catch(() => []);
|
|
const versionName = entries
|
|
.filter((entry) => entry.isDirectory() && entry.name !== "Current")
|
|
.map((entry) => entry.name)
|
|
.sort()[0];
|
|
if (versionName == null) return false;
|
|
|
|
const versionPath = path.join(versionsRoot, versionName);
|
|
await ensureRelativeSymlink(path.join(versionsRoot, "Current"), versionName, "dir");
|
|
|
|
const versionEntries = await readdir(versionPath, { withFileTypes: true }).catch(() => []);
|
|
let changed = false;
|
|
for (const entry of versionEntries) {
|
|
if (entry.name === "_CodeSignature") continue;
|
|
const targetPath = `Versions/Current/${entry.name}`;
|
|
const linkPath = path.join(frameworkPath, entry.name);
|
|
const type = entry.isDirectory() ? "dir" : "file";
|
|
changed = (await ensureRelativeSymlink(linkPath, targetPath, type)) || changed;
|
|
}
|
|
|
|
return changed;
|
|
}
|
|
|
|
async function normalizeMacVersionedFrameworks(appPath) {
|
|
const frameworksRoot = path.join(appPath, "Contents", "Frameworks");
|
|
|
|
async function visit(current) {
|
|
const entries = await readdir(current, { withFileTypes: true }).catch(() => []);
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const entryPath = path.join(current, entry.name);
|
|
if (entry.name.endsWith(".framework")) {
|
|
await normalizeMacVersionedFramework(entryPath);
|
|
continue;
|
|
}
|
|
await visit(entryPath);
|
|
}
|
|
}
|
|
|
|
await visit(frameworksRoot);
|
|
}
|
|
|
|
async function resolveMacAdhocSignTarget(bundlePath, bundleName) {
|
|
if (!bundleName.endsWith(".framework")) return bundlePath;
|
|
|
|
const currentVersionPath = path.join(bundlePath, "Versions", "Current");
|
|
if (await pathExists(currentVersionPath)) return currentVersionPath;
|
|
|
|
return bundlePath;
|
|
}
|
|
|
|
async function collectMacAdhocSignTargets(appPath) {
|
|
const frameworksRoot = path.join(appPath, "Contents", "Frameworks");
|
|
const targets = [];
|
|
|
|
async function visit(current) {
|
|
const entries = await readdir(current, { withFileTypes: true }).catch(() => []);
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const entryPath = path.join(current, entry.name);
|
|
if (isMacCodeBundle(entry.name)) {
|
|
targets.push(await resolveMacAdhocSignTarget(entryPath, entry.name));
|
|
continue;
|
|
}
|
|
await visit(entryPath);
|
|
}
|
|
}
|
|
|
|
await visit(frameworksRoot);
|
|
targets.push(appPath);
|
|
return targets;
|
|
}
|
|
|
|
async function signMacAdhocBundle(appPath) {
|
|
await normalizeMacVersionedFrameworks(appPath);
|
|
const targets = await collectMacAdhocSignTargets(appPath);
|
|
for (const target of targets) {
|
|
await execFileAsync("codesign", ["--force", "--deep", "--sign", "-", "--timestamp=none", target], {
|
|
maxBuffer: 20 * 1024 * 1024,
|
|
});
|
|
}
|
|
await execFileAsync("codesign", ["--verify", "--deep", "--strict", "--verbose=2", appPath], {
|
|
maxBuffer: 20 * 1024 * 1024,
|
|
});
|
|
return targets;
|
|
}
|
|
|
|
async function assertResolvedInside(root, moduleName, resolvedPath) {
|
|
if (!(await isWithinPhysicalPath(root, resolvedPath))) {
|
|
throw new Error(`[tools-pack web-standalone] ${moduleName} resolved outside copied standalone: ${resolvedPath}`);
|
|
}
|
|
}
|
|
|
|
async function auditCopiedStandalone(config, installResult, platformName) {
|
|
const serverPath = path.join(installResult.destinationWebRoot, "server.js");
|
|
const staticRoot = path.join(installResult.destinationWebRoot, ".next", "static");
|
|
const publicRoot = path.join(installResult.destinationWebRoot, "public");
|
|
const nodeModulesRoot = path.join(installResult.destinationRoot, "node_modules");
|
|
const webNodeModulesRoot = path.join(installResult.destinationWebRoot, "node_modules");
|
|
const requiredPaths = [serverPath, staticRoot, nodeModulesRoot];
|
|
if (await pathExists(config.webPublicSourceRoot)) requiredPaths.push(publicRoot);
|
|
|
|
for (const requiredPath of requiredPaths) {
|
|
if (!(await pathExists(requiredPath))) {
|
|
throw new Error(`[tools-pack web-standalone] copied standalone audit missing: ${requiredPath}`);
|
|
}
|
|
}
|
|
|
|
const localRequire = createRequire(serverPath);
|
|
const resolvedModules = {};
|
|
for (const moduleName of REQUIRED_MODULES) {
|
|
const resolvedPath = localRequire.resolve(moduleName);
|
|
await assertResolvedInside(installResult.destinationRoot, moduleName, resolvedPath);
|
|
resolvedModules[moduleName] = resolvedPath;
|
|
}
|
|
|
|
const closureStats = await collectClosureStats(installResult.destinationRoot, installResult.destinationRoot, undefined, platformName);
|
|
if (closureStats.brokenSymlinks.length > 0) {
|
|
throw new Error(`[tools-pack web-standalone] copied standalone has broken symlinks: ${closureStats.brokenSymlinks.join(", ")}`);
|
|
}
|
|
if (closureStats.externalSymlinks.length > 0) {
|
|
throw new Error(`[tools-pack web-standalone] copied standalone has external symlinks: ${closureStats.externalSymlinks.join(", ")}`);
|
|
}
|
|
if (closureStats.forbiddenEntries.length > 0) {
|
|
throw new Error(`[tools-pack web-standalone] copied standalone has forbidden entries: ${closureStats.forbiddenEntries.join(", ")}`);
|
|
}
|
|
|
|
return {
|
|
brokenSymlinks: closureStats.brokenSymlinks,
|
|
bytes: await sizePathBytes(installResult.destinationRoot),
|
|
destinationRoot: installResult.destinationRoot,
|
|
destinationWebRoot: installResult.destinationWebRoot,
|
|
externalSymlinks: closureStats.externalSymlinks,
|
|
forbiddenEntries: closureStats.forbiddenEntries,
|
|
nodeModulesBytes: await sizePathBytes(nodeModulesRoot),
|
|
resolvedModules,
|
|
serverPath,
|
|
symlinks: closureStats.symlinks,
|
|
webNextBytes: await sizePathBytes(path.join(webNodeModulesRoot, "next")),
|
|
webNodeModulesBytes: await sizePathBytes(webNodeModulesRoot),
|
|
};
|
|
}
|
|
|
|
async function auditCopiedStandaloneNextDedupe(installResult, platformName) {
|
|
if (platformName !== "win32") return null;
|
|
|
|
const serverPath = path.join(installResult.destinationWebRoot, "server.js");
|
|
const retainedNextRoot = path.join(installResult.destinationWebRoot, "node_modules", "next");
|
|
const retainedNextPackagePath = path.join(retainedNextRoot, "package.json");
|
|
const checkedPaths = [
|
|
path.join(installResult.destinationRoot, "node_modules", "next"),
|
|
path.join(installResult.destinationRoot, "node_modules", ".pnpm", "node_modules", "next"),
|
|
];
|
|
const remainingPaths = [];
|
|
|
|
for (const checkedPath of checkedPaths) {
|
|
if (await pathExists(checkedPath)) remainingPaths.push(checkedPath);
|
|
}
|
|
if (remainingPaths.length > 0) {
|
|
throw new Error(
|
|
`[tools-pack web-standalone] copied standalone next dedupe audit found remaining duplicate paths: ${remainingPaths.join(", ")}`,
|
|
);
|
|
}
|
|
|
|
if (!(await pathExists(retainedNextPackagePath))) {
|
|
throw new Error(`[tools-pack web-standalone] copied standalone retained app-local next missing: ${retainedNextPackagePath}`);
|
|
}
|
|
|
|
const resolvedNextPackagePath = createRequire(serverPath).resolve("next/package.json");
|
|
if (!(await isWithinPhysicalPath(retainedNextRoot, resolvedNextPackagePath))) {
|
|
throw new Error(
|
|
`[tools-pack web-standalone] copied standalone next resolved outside retained app-local next: ${resolvedNextPackagePath}`,
|
|
);
|
|
}
|
|
|
|
return {
|
|
checkedPaths,
|
|
remainingPaths,
|
|
resolvedNextPackagePath,
|
|
retainedNextRoot,
|
|
};
|
|
}
|
|
|
|
async function pruneRootNext(appNodeModulesRoot, platformName) {
|
|
const removedPaths = [];
|
|
|
|
if (platformName === "win32") {
|
|
await removePathAndRecord(
|
|
path.join(appNodeModulesRoot, "next"),
|
|
"root next package superseded by copied standalone resource",
|
|
removedPaths,
|
|
);
|
|
await removePathAndRecord(
|
|
path.join(appNodeModulesRoot, "@next"),
|
|
"root @next package scope superseded by copied standalone resource",
|
|
removedPaths,
|
|
);
|
|
} else {
|
|
const nextScopeRoot = path.join(appNodeModulesRoot, "@next");
|
|
const nextScopeEntries = await readdir(nextScopeRoot).catch(() => []);
|
|
for (const entry of nextScopeEntries) {
|
|
if (platformName === "darwin" && entry.startsWith("swc-darwin-")) {
|
|
await removePathAndRecord(path.join(nextScopeRoot, entry), "root next darwin swc package", removedPaths);
|
|
}
|
|
}
|
|
}
|
|
|
|
await removePathAndRecord(
|
|
path.join(appNodeModulesRoot, "@open-design", "web", ".next", "standalone"),
|
|
"root @open-design/web standalone output",
|
|
removedPaths,
|
|
);
|
|
|
|
return removedPaths;
|
|
}
|
|
|
|
async function auditRootNextPruned(appNodeModulesRoot, platformName, enabled) {
|
|
if (platformName !== "win32" || !enabled) return null;
|
|
|
|
const checkedPaths = [
|
|
path.join(appNodeModulesRoot, "next"),
|
|
path.join(appNodeModulesRoot, "@next"),
|
|
];
|
|
const remainingPaths = [];
|
|
for (const checkedPath of checkedPaths) {
|
|
if (await pathExists(checkedPath)) remainingPaths.push(checkedPath);
|
|
}
|
|
if (remainingPaths.length > 0) {
|
|
throw new Error(`[tools-pack web-standalone] root next pruning audit found remaining paths: ${remainingPaths.join(", ")}`);
|
|
}
|
|
|
|
return {
|
|
checkedPaths,
|
|
remainingPaths,
|
|
};
|
|
}
|
|
|
|
async function pruneRootSharp(appNodeModulesRoot) {
|
|
const pnpmRoot = path.join(appNodeModulesRoot, ".pnpm");
|
|
const removedPaths = [];
|
|
|
|
await removePathAndRecord(path.join(appNodeModulesRoot, "sharp"), "root sharp package", removedPaths);
|
|
await pruneImgScope(path.join(appNodeModulesRoot, "@img"), "root @img sharp package", removedPaths);
|
|
await removePathAndRecord(path.join(pnpmRoot, "node_modules", "sharp"), "root pnpm sharp symlink", removedPaths);
|
|
await pruneImgScope(path.join(pnpmRoot, "node_modules", "@img"), "root pnpm @img sharp symlink", removedPaths);
|
|
|
|
const pnpmEntries = await readdir(pnpmRoot).catch(() => []);
|
|
for (const entry of pnpmEntries) {
|
|
if (isPrunablePnpmSharpEntry(entry)) {
|
|
await removePathAndRecord(path.join(pnpmRoot, entry), "root pnpm sharp package", removedPaths);
|
|
}
|
|
}
|
|
|
|
return removedPaths;
|
|
}
|
|
|
|
function resolveHyperframesRuntimePackageNames(platformName) {
|
|
const arch = platformName === "win32" ? "x64" : process.arch;
|
|
if (arch !== "arm64" && arch !== "x64") {
|
|
throw new Error(`[tools-pack hyperframes] unsupported ${platformName} architecture: ${arch}`);
|
|
}
|
|
if (platformName === "win32") {
|
|
return [
|
|
"sharp",
|
|
"@img/colour",
|
|
`@img/sharp-win32-${arch}`,
|
|
];
|
|
}
|
|
if (platformName === "darwin") {
|
|
return [
|
|
"sharp",
|
|
"@img/colour",
|
|
`@img/sharp-darwin-${arch}`,
|
|
`@img/sharp-libvips-darwin-${arch}`,
|
|
];
|
|
}
|
|
throw new Error(`[tools-pack hyperframes] unsupported platform: ${platformName}`);
|
|
}
|
|
|
|
function packagePath(nodeModulesRoot, packageName) {
|
|
return path.join(nodeModulesRoot, ...packageName.split("/"));
|
|
}
|
|
|
|
async function copyHyperframesRuntimeDependencies(config, appNodeModulesRoot, platformName) {
|
|
const sourceNodeModulesRoot = path.join(config.hyperframesRuntimeSourceRoot, "node_modules");
|
|
const copied = [];
|
|
for (const packageName of resolveHyperframesRuntimePackageNames(platformName)) {
|
|
const sourcePath = packagePath(sourceNodeModulesRoot, packageName);
|
|
const destinationPath = packagePath(appNodeModulesRoot, packageName);
|
|
await copyRequired(sourcePath, destinationPath, { dereference: true });
|
|
copied.push({
|
|
bytes: await sizePathBytes(destinationPath),
|
|
destinationPath,
|
|
packageName,
|
|
sourcePath,
|
|
});
|
|
}
|
|
return copied;
|
|
}
|
|
|
|
async function smokeHyperframesCli(
|
|
appPath,
|
|
appNodeModulesRoot,
|
|
platformName,
|
|
productFilename,
|
|
copiedPackages,
|
|
) {
|
|
const cliPath = path.join(appNodeModulesRoot, "hyperframes", "dist", "cli.js");
|
|
const nodePath = platformName === "win32"
|
|
? path.join(appPath, `${productFilename}.exe`)
|
|
: path.join(appPath, "Contents", "MacOS", productFilename);
|
|
const requiredPaths = [
|
|
cliPath,
|
|
nodePath,
|
|
...copiedPackages.map((entry) => path.join(entry.destinationPath, "package.json")),
|
|
];
|
|
for (const requiredPath of requiredPaths) {
|
|
if (!(await pathExists(requiredPath))) {
|
|
throw new Error(`[tools-pack hyperframes] final packaged runtime is missing: ${requiredPath}`);
|
|
}
|
|
}
|
|
|
|
const env = { ...process.env, ELECTRON_RUN_AS_NODE: "1", NODE_PATH: "" };
|
|
delete env.NODE_OPTIONS;
|
|
const result = await execFileAsync(nodePath, [cliPath, "--version"], {
|
|
cwd: appPath,
|
|
env,
|
|
maxBuffer: 20 * 1024 * 1024,
|
|
timeout: 30_000,
|
|
});
|
|
return {
|
|
cliPath,
|
|
nodePath,
|
|
stderr: result.stderr.trim(),
|
|
stdout: result.stdout.trim(),
|
|
};
|
|
}
|
|
|
|
async function pruneRootWebPackage(appNodeModulesRoot, platformName) {
|
|
if (platformName !== "win32") return [];
|
|
|
|
const webPackageRoot = path.join(appNodeModulesRoot, "@open-design", "web");
|
|
const removedPaths = [];
|
|
for (const entry of [".next", "app", "next.config.ts", "public", "src"]) {
|
|
await removePathAndRecord(
|
|
path.join(webPackageRoot, entry),
|
|
"root @open-design/web standalone-safe package residue",
|
|
removedPaths,
|
|
);
|
|
}
|
|
return removedPaths;
|
|
}
|
|
|
|
async function auditRootWebPackage(appNodeModulesRoot) {
|
|
const webPackageRoot = path.join(appNodeModulesRoot, "@open-design", "web");
|
|
const packageJsonPath = path.join(webPackageRoot, "package.json");
|
|
const sidecarEntryPath = path.join(webPackageRoot, "dist", "sidecar", "index.js");
|
|
for (const requiredPath of [packageJsonPath, sidecarEntryPath]) {
|
|
if (!(await pathExists(requiredPath))) {
|
|
throw new Error(`[tools-pack web-standalone] root @open-design/web audit missing: ${requiredPath}`);
|
|
}
|
|
}
|
|
return {
|
|
bytes: await sizePathBytes(webPackageRoot),
|
|
packageJsonPath,
|
|
retainedDistBytes: await sizePathBytes(path.join(webPackageRoot, "dist")),
|
|
sidecarEntryPath,
|
|
webPackageRoot,
|
|
};
|
|
}
|
|
|
|
async function auditNoBrokenSymlinks(root, label) {
|
|
const stats = await collectClosureStats(root);
|
|
if (stats.brokenSymlinks.length > 0) {
|
|
throw new Error(`[tools-pack web-standalone] ${label} has broken symlinks: ${stats.brokenSymlinks.join(", ")}`);
|
|
}
|
|
return {
|
|
brokenSymlinks: stats.brokenSymlinks,
|
|
symlinks: stats.symlinks,
|
|
};
|
|
}
|
|
|
|
async function runWebStandaloneAfterPack(context) {
|
|
if (context?.electronPlatformName !== "darwin" && context?.electronPlatformName !== "win32") {
|
|
throw new Error(`[tools-pack web-standalone] unsupported platform: ${context?.electronPlatformName ?? "unknown"}`);
|
|
}
|
|
|
|
const config = await readHookConfig();
|
|
const appPath = resolveAppPath(context);
|
|
const resourcesRoot = resolveResourcesRoot(context, appPath);
|
|
if (context.electronPlatformName === "darwin" && !(await pathExists(appPath))) {
|
|
throw new Error(`[tools-pack web-standalone] app bundle not found: ${appPath}`);
|
|
}
|
|
if (!(await pathExists(resourcesRoot))) {
|
|
throw new Error(`[tools-pack web-standalone] resources root not found: ${resourcesRoot}`);
|
|
}
|
|
|
|
const appNodeModulesRoot = resolveRootAppNodeModulesRoot(resourcesRoot);
|
|
const installResult = await installStandaloneResource(config, resourcesRoot, context.electronPlatformName);
|
|
const copiedPrune = config.pruneCopiedSharp ? await pruneCopiedSharp(installResult.destinationRoot) : [];
|
|
const copiedNextDedupe = await dedupeCopiedStandaloneNext(
|
|
installResult.destinationRoot,
|
|
installResult.destinationWebRoot,
|
|
context.electronPlatformName,
|
|
);
|
|
const copiedBuildResiduePrune = context.electronPlatformName === "win32"
|
|
? await pruneSourceBuildResidue(installResult.destinationRoot, "copied standalone source/build residue")
|
|
: [];
|
|
const brokenSymlinkPrune = await pruneBrokenSymlinks(
|
|
installResult.destinationRoot,
|
|
installResult.destinationRoot,
|
|
[],
|
|
"copied broken symlink",
|
|
);
|
|
const copiedNextDedupeAudit = await auditCopiedStandaloneNextDedupe(
|
|
installResult,
|
|
context.electronPlatformName,
|
|
);
|
|
const copiedAudit = await auditCopiedStandalone(config, installResult, context.electronPlatformName);
|
|
const rootPrune = config.pruneRootNext ? await pruneRootNext(appNodeModulesRoot, context.electronPlatformName) : [];
|
|
const rootSharpPrune = config.pruneRootSharp ? await pruneRootSharp(appNodeModulesRoot) : [];
|
|
const hyperframesRuntimeCopies = await copyHyperframesRuntimeDependencies(
|
|
config,
|
|
appNodeModulesRoot,
|
|
context.electronPlatformName,
|
|
);
|
|
const rootWebPackagePrune = await pruneRootWebPackage(appNodeModulesRoot, context.electronPlatformName);
|
|
const rootBuildResiduePrune = context.electronPlatformName === "win32"
|
|
? await pruneSourceBuildResidue(appNodeModulesRoot, "root app source/build residue")
|
|
: [];
|
|
const rootWebPackageAudit = context.electronPlatformName === "win32" && config.requireRootWebPackageAudit
|
|
? await auditRootWebPackage(appNodeModulesRoot)
|
|
: null;
|
|
const rootNextPruneAudit = await auditRootNextPruned(
|
|
appNodeModulesRoot,
|
|
context.electronPlatformName,
|
|
config.pruneRootNext,
|
|
);
|
|
const rootBrokenSymlinkPrune = await pruneBrokenSymlinks(
|
|
appNodeModulesRoot,
|
|
appNodeModulesRoot,
|
|
[],
|
|
"root broken symlink",
|
|
);
|
|
const rootSymlinkAudit = await auditNoBrokenSymlinks(
|
|
appNodeModulesRoot,
|
|
"root app node_modules",
|
|
);
|
|
const hyperframesCliSmoke = await smokeHyperframesCli(
|
|
appPath,
|
|
appNodeModulesRoot,
|
|
context.electronPlatformName,
|
|
context.packager.appInfo.productFilename,
|
|
hyperframesRuntimeCopies,
|
|
);
|
|
const macAdhocBundleSign = context.electronPlatformName === "darwin" && config.macAdhocBundleSign
|
|
? await signMacAdhocBundle(appPath)
|
|
: [];
|
|
const report = {
|
|
appPath,
|
|
brokenSymlinkPrune,
|
|
copiedAudit,
|
|
copiedBuildResiduePrune,
|
|
copiedNextDedupe,
|
|
copiedNextDedupeAudit,
|
|
copiedPrune,
|
|
generatedAt: new Date().toISOString(),
|
|
hyperframesCliSmoke,
|
|
hyperframesRuntimeCopies,
|
|
macAdhocBundleSign,
|
|
platformName: context.electronPlatformName,
|
|
resourcesRoot,
|
|
rootBuildResiduePrune,
|
|
rootBrokenSymlinkPrune,
|
|
rootNextPruneAudit,
|
|
rootPrune,
|
|
rootSharpPrune,
|
|
rootSymlinkAudit,
|
|
rootWebPackageAudit,
|
|
rootWebPackagePrune,
|
|
sourceWebRoot: installResult.sourceWebRoot,
|
|
version: 1,
|
|
};
|
|
|
|
await mkdir(path.dirname(config.auditReportPath), { recursive: true });
|
|
await writeFile(config.auditReportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
module.exports = async function webStandaloneAfterPack(context) {
|
|
try {
|
|
await runWebStandaloneAfterPack(context);
|
|
} catch (error) {
|
|
console.error(
|
|
"[tools-pack web-standalone] after-pack hook failed:",
|
|
error instanceof Error ? error.message : error,
|
|
);
|
|
console.error("[tools-pack web-standalone] electron-builder context:", {
|
|
appOutDir: context?.appOutDir,
|
|
electronPlatformName: context?.electronPlatformName,
|
|
productFilename: context?.packager?.appInfo?.productFilename,
|
|
});
|
|
throw error;
|
|
}
|
|
};
|