Files
clean-architecture-frontend…/scripts/lib/optional-recipe-bundle.ts
T

374 lines
11 KiB
TypeScript

import { createHash } from "node:crypto";
import { lstat, readdir, realpath } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { gzipSync } from "node:zlib";
import {
build,
normalizePath,
type Plugin,
version as viteVersion,
} from "vite";
const RECIPE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const SOURCE_EXTENSION = /\.(?:[cm]?[jt]s|[jt]sx)$/;
const DECLARATION_FILE = /\.d\.[cm]?ts$/;
type EmittedOutput =
| Readonly<{
type: "chunk";
fileName: string;
code: string;
}>
| Readonly<{
type: "asset";
fileName: string;
source: string | Uint8Array;
}>;
export type OptionalRecipeBundleOutput = Readonly<{
fileName: string;
bytes: number;
gzipBytes: number;
sha256: string;
}>;
export type OptionalRecipeBundleMeasurement = Readonly<{
recipeId: string;
sourceRoots: readonly string[];
sourceFileCount: number;
toolchain: Readonly<{
bundler: "vite";
viteVersion: string;
mode: "production";
target: "es2022";
format: "es";
minifier: "esbuild";
treeshake: false;
compression: "node-zlib-gzip";
}>;
outputs: readonly OptionalRecipeBundleOutput[];
bytes: number;
gzipBytes: number;
bundleBudgetGzipBytes: number;
remainingGzipBytes: number;
sha256: string;
passed: boolean;
}>;
/**
* Builds an uncomposed reference runtime as a synthetic production consumer.
* Every catalog-owned source module is exposed as an entry namespace and
* tree-shaking is disabled so internal fail-closed paths remain in the budget.
*/
export async function measureOptionalRecipeBundle(input: Readonly<{
recipeId: string;
sourceRoots: readonly string[];
bundleBudgetGzipBytes: number;
workspaceRoot?: string;
}>): Promise<OptionalRecipeBundleMeasurement> {
const recipeId = validateRecipeId(input.recipeId);
const bundleBudgetGzipBytes = positiveSafeInteger(
input.bundleBudgetGzipBytes,
"Optional recipe bundle budget",
);
const workspaceRoot = await realpath(
path.resolve(input.workspaceRoot ?? process.cwd()),
);
const sourceRoots = validateSourceRoots(input.sourceRoots);
const sourceFiles = await resolveSourceFiles(
workspaceRoot,
sourceRoots,
);
const virtualEntry =
`virtual:optional-reference-runtime-entry/${recipeId}`;
const resolvedVirtualEntry = `\0${virtualEntry}`;
const entrySource = sourceFiles
.map(
(sourceFile, index) =>
`export * as source${index} from ${JSON.stringify(
viteSourceSpecifier(sourceFile),
)};`,
)
.join("\n")
.concat("\n");
const preservePublicEntryPlugin = {
name: "optional-reference-runtime-entry",
enforce: "pre",
resolveId(id) {
return id === virtualEntry ? resolvedVirtualEntry : null;
},
load(id) {
return id === resolvedVirtualEntry ? entrySource : null;
},
options(options) {
return {
...options,
preserveEntrySignatures: "strict",
};
},
} satisfies Plugin;
const buildResult = await build({
root: workspaceRoot,
configFile: false,
envFile: false,
mode: "production",
publicDir: false,
clearScreen: false,
logLevel: "silent",
plugins: [preservePublicEntryPlugin],
build: {
target: "es2022",
minify: "esbuild",
sourcemap: false,
write: false,
emptyOutDir: false,
copyPublicDir: false,
cssCodeSplit: false,
reportCompressedSize: false,
rollupOptions: {
input: virtualEntry,
// Budget the complete selected runtime, including internal fail-closed
// guards that a synthetic consumer cannot predict it will exercise.
treeshake: false,
output: {
format: "es",
entryFileNames: `${recipeId}.js`,
chunkFileNames: `${recipeId}-chunk-[hash].js`,
assetFileNames: `${recipeId}-asset-[name]-[hash][extname]`,
},
},
},
});
const emitted = emittedOutputs(buildResult);
if (emitted.length === 0) {
throw new TypeError("Optional recipe bundle emitted no output.");
}
const outputs = emitted
.map((output) => {
const bytes = outputBytes(output);
return Object.freeze({
fileName: output.fileName,
bytes: bytes.byteLength,
gzipBytes: gzipSync(bytes).byteLength,
sha256: createHash("sha256").update(bytes).digest("hex"),
});
})
.sort((left, right) => compareText(left.fileName, right.fileName));
const aggregateHash = createHash("sha256");
for (const output of outputs) {
aggregateHash.update(output.fileName);
aggregateHash.update("\0");
aggregateHash.update(output.sha256);
aggregateHash.update("\0");
}
const bytes = outputs.reduce(
(total, output) => total + output.bytes,
0,
);
const gzipBytes = outputs.reduce(
(total, output) => total + output.gzipBytes,
0,
);
return Object.freeze({
recipeId,
sourceRoots: Object.freeze([...sourceRoots]),
sourceFileCount: sourceFiles.length,
toolchain: Object.freeze({
bundler: "vite" as const,
viteVersion,
mode: "production" as const,
target: "es2022" as const,
format: "es" as const,
minifier: "esbuild" as const,
treeshake: false as const,
compression: "node-zlib-gzip" as const,
}),
outputs: Object.freeze(outputs),
bytes,
gzipBytes,
bundleBudgetGzipBytes,
remainingGzipBytes: bundleBudgetGzipBytes - gzipBytes,
sha256: aggregateHash.digest("hex"),
passed: gzipBytes <= bundleBudgetGzipBytes,
});
}
async function resolveSourceFiles(
workspaceRoot: string,
sourceRoots: readonly string[],
): Promise<readonly string[]> {
const sourceBoundary = await realpath(path.join(workspaceRoot, "src"));
const discovered: string[] = [];
for (const sourceRoot of sourceRoots) {
const target = path.resolve(workspaceRoot, sourceRoot);
assertInsideSourceBoundary(target, sourceBoundary);
const rootMetadata = await lstat(target);
if (rootMetadata.isSymbolicLink()) {
throw new TypeError("Optional recipe source root cannot be a symlink.");
}
assertInsideSourceBoundary(await realpath(target), sourceBoundary);
if (
rootMetadata.isFile() &&
(!SOURCE_EXTENSION.test(target) || DECLARATION_FILE.test(target))
) {
throw new TypeError("Optional recipe source root is not executable source.");
}
discovered.push(
...(await collectExecutableSources(target, sourceBoundary)),
);
}
const unique = [...new Set(discovered)].sort((left, right) =>
compareText(
normalizePath(path.relative(workspaceRoot, left)),
normalizePath(path.relative(workspaceRoot, right)),
),
);
if (unique.length === 0) {
throw new TypeError("Optional recipe source roots contain no executable source.");
}
return Object.freeze(unique);
}
async function collectExecutableSources(
target: string,
sourceBoundary: string,
): Promise<string[]> {
const metadata = await lstat(target);
if (metadata.isSymbolicLink()) {
throw new TypeError("Optional recipe source cannot be a symlink.");
}
assertInsideSourceBoundary(target, sourceBoundary);
if (metadata.isFile()) {
return SOURCE_EXTENSION.test(target) && !DECLARATION_FILE.test(target)
? [target]
: [];
}
if (!metadata.isDirectory()) return [];
const entries = (await readdir(target, { withFileTypes: true })).sort(
(left, right) => compareText(left.name, right.name),
);
const groups = await Promise.all(
entries.map((entry) =>
collectExecutableSources(
path.join(target, entry.name),
sourceBoundary,
),
),
);
return groups.flat();
}
function emittedOutputs(value: unknown): readonly EmittedOutput[] {
const buildOutputs = Array.isArray(value) ? value : [value];
const emitted: EmittedOutput[] = [];
for (const buildOutput of buildOutputs) {
if (!isRecord(buildOutput) || !Array.isArray(buildOutput.output)) {
throw new TypeError("Optional recipe bundle output is invalid.");
}
for (const output of buildOutput.output) {
if (!isRecord(output)) {
throw new TypeError("Optional recipe emitted output is invalid.");
}
if (
output.type === "chunk" &&
typeof output.fileName === "string" &&
typeof output.code === "string"
) {
emitted.push({
type: "chunk",
fileName: output.fileName,
code: output.code,
});
} else if (
output.type === "asset" &&
typeof output.fileName === "string" &&
(typeof output.source === "string" ||
output.source instanceof Uint8Array)
) {
emitted.push({
type: "asset",
fileName: output.fileName,
source: output.source,
});
} else {
throw new TypeError("Optional recipe emitted output shape is invalid.");
}
}
}
return emitted;
}
function outputBytes(output: EmittedOutput): Buffer {
if (output.type === "chunk") {
return Buffer.from(output.code, "utf8");
}
return Buffer.from(output.source);
}
function validateRecipeId(value: unknown): string {
if (typeof value !== "string" || !RECIPE_ID.test(value)) {
throw new TypeError("Optional recipe ID is invalid.");
}
return value;
}
function validateSourceRoots(value: unknown): readonly string[] {
if (
!Array.isArray(value) ||
value.length === 0 ||
value.length > 32 ||
value.some(
(sourceRoot) =>
typeof sourceRoot !== "string" ||
!sourceRoot.startsWith("src/") ||
sourceRoot.includes("\\") ||
sourceRoot
.split("/")
.some(
(segment) =>
segment.length === 0 || segment === "." || segment === "..",
),
) ||
new Set(value).size !== value.length
) {
throw new TypeError("Optional recipe source roots are invalid.");
}
return Object.freeze([...value].sort(compareText));
}
function assertInsideSourceBoundary(
target: string,
sourceBoundary: string,
): void {
const relative = path.relative(sourceBoundary, target);
if (
relative === ".." ||
relative.startsWith(`..${path.sep}`) ||
path.isAbsolute(relative)
) {
throw new TypeError("Optional recipe source escaped the source boundary.");
}
}
function viteSourceSpecifier(sourceFile: string): string {
return pathToFileURL(sourceFile).href;
}
function positiveSafeInteger(value: unknown, name: string): number {
if (!Number.isSafeInteger(value) || (value as number) < 1) {
throw new TypeError(`${name} is invalid.`);
}
return value as number;
}
function compareText(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}