374 lines
11 KiB
TypeScript
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);
|
|
}
|