Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions packages/cli-core/files/references.json
Original file line number Diff line number Diff line change
Expand Up @@ -1242,7 +1242,9 @@
"version": "5.2.1",
"files": [
"/packages/create-webiny-project/package.json",
"/packages/create-webiny-project/package.json"
"/scripts/buildPackages/package.json",
"/packages/create-webiny-project/package.json",
"/scripts/buildPackages/package.json"
]
},
{
Expand Down Expand Up @@ -2700,7 +2702,9 @@
"version": "4.0.9",
"files": [
"/packages/create-webiny-project/package.json",
"/packages/create-webiny-project/package.json"
"/scripts/buildPackages/package.json",
"/packages/create-webiny-project/package.json",
"/scripts/buildPackages/package.json"
]
},
{
Expand Down Expand Up @@ -7709,7 +7713,8 @@
{
"version": "5.2.1",
"files": [
{ "file": "/packages/create-webiny-project/package.json", "types": ["dependencies"] }
{ "file": "/packages/create-webiny-project/package.json", "types": ["dependencies"] },
{ "file": "/scripts/buildPackages/package.json", "types": ["dependencies"] }
]
}
]
Expand Down Expand Up @@ -7766,7 +7771,11 @@
{
"version": "4.0.9",
"files": [
{ "file": "/packages/create-webiny-project/package.json", "types": ["devDependencies"] }
{
"file": "/packages/create-webiny-project/package.json",
"types": ["devDependencies"]
},
{ "file": "/scripts/buildPackages/package.json", "types": ["devDependencies"] }
]
}
]
Expand Down
2 changes: 2 additions & 0 deletions scripts/buildPackages/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"chalk": "^5.6.2",
"execa": "^9.6.1",
"fs-extra": "^11.3.6",
"js-yaml": "^5.2.1",
"listr2": "^10.2.2",
"load-json-file": "^7.0.1",
"node-notifier": "^10.0.1",
Expand All @@ -20,6 +21,7 @@
"yargs": "^18.0.0"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/yargs": "^17.0.35"
}
}
16 changes: 8 additions & 8 deletions scripts/buildPackages/src/buildPackages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { writeJsonFileSync } from "write-json-file";
import { Listr, ListrTask } from "listr2";
import { getBatches } from "./getBatches";
import { META_FILE_PATH } from "./constants";
import { getPackageSourceHash } from "./getPackageSourceHash";
import { getBuildMeta } from "./getBuildMeta";
import { buildPackage } from "./buildSinglePackage";
import { getHardwareInfo } from "./getHardwareInfo";
Expand Down Expand Up @@ -63,7 +62,7 @@ export const buildPackages = async () => {
}
}

const { batches, packagesNoCache, allPackages } = await getBatches({
const { batches, packagesNoCache, allPackages, buildKeys } = await getBatches({
cache: options.cache ?? true,
packagesWhitelist,
rebuildDependents: options.rebuildDependents
Expand All @@ -89,12 +88,13 @@ export const buildPackages = async () => {
try {
await buildPackage(pkg, options.buildOverrides, "inherit", options.safeReplace);

// Record the source hash in build meta so a later build treats this
// Record the dependency-aware build key so a later build treats this
// package as a cache hit (the cache→dist copy is then skipped when
// dist already matches — content hash).
const sourceHash = await getPackageSourceHash(pkg);
const meta = getBuildMeta();
meta.packages[pkg.packageJson.name] = { sourceHash };
meta.packages[pkg.packageJson.name] = {
sourceHash: buildKeys.get(pkg.name) ?? ""
};
writeJsonFileSync(META_FILE_PATH, meta);

sendNotification(`Webiny Build (${projectFolder})`, "Build completed successfully");
Expand Down Expand Up @@ -137,12 +137,12 @@ export const buildPackages = async () => {
options.safeReplace
);

// Store package hash
const sourceHash = await getPackageSourceHash(pkg);
// Store the dependency-aware build key.
const key = buildKeys.get(pkg.name) ?? "";
await queueMetaWrite(async () => {
const currentMeta = getBuildMeta();
currentMeta.packages[pkg.packageJson.name] = {
sourceHash
sourceHash: key
};
return writeJsonFileSync(META_FILE_PATH, currentMeta);
});
Expand Down
29 changes: 19 additions & 10 deletions scripts/buildPackages/src/getBatches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import { WorkspaceGraph } from "../../utils/WorkspaceGraph.js";
import { Package } from "./types";
import { CACHE_FOLDER_PATH } from "./constants";
import { getBuildOutputFolder } from "./getBuildOutputFolder";
import { getPackageSourceHash } from "./getPackageSourceHash";
import { getBuildMeta } from "./getBuildMeta";
import { getPackageCacheFolderPath } from "./getPackageCacheFolderPath";
import { distMatchesCache, recordCacheHash } from "./distContentHash";
import { getEffectiveHashes, getOwnHashes, isDepAwareKeyEnabled } from "./getEffectiveHashes";

const { green } = chalk;

Expand All @@ -25,11 +25,11 @@ export async function getBatches(options: GetBatchesOptions = {}) {
const packagesNoCache: Package[] = [];
const packagesUseCache: Package[] = [];

let workspacesPackages = (
getPackages({
includes: ["/packages/"]
}) as Package[]
).filter(pkg => pkg.mustBuild);
const allWorkspacePackages = getPackages({
includes: ["/packages/"]
}) as Package[];

let workspacesPackages = allWorkspacePackages.filter(pkg => pkg.mustBuild);

const packagesWhitelist = options.packagesWhitelist;
if (Array.isArray(packagesWhitelist) && packagesWhitelist.length) {
Expand All @@ -46,6 +46,14 @@ export async function getBatches(options: GetBatchesOptions = {}) {
ignore: ["@webiny/project-utils"]
});

// Build key per package. Default: own-source hash (original behavior).
// Experimental (WEBINY_EXPERIMENTAL_DEP_AWARE_CACHE): a dependency-aware key
// that also changes when any transitive dependency changes, so dependents of
// a changed package are detected as misses without `--rebuild-dependents`.
const buildKeys = isDepAwareKeyEnabled()
? await getEffectiveHashes(allWorkspacePackages)
: await getOwnHashes(allWorkspacePackages);

// 1. Determine for which packages we can use the cached built code, and for which we need to execute build.
if (!useCache) {
workspacesPackages.forEach(pkg => packagesNoCache.push(pkg));
Expand All @@ -57,11 +65,11 @@ export async function getBatches(options: GetBatchesOptions = {}) {
continue;
}

const sourceHash = await getPackageSourceHash(workspacePackage);
const key = buildKeys.get(workspacePackage.name) ?? "";

const packageMeta = metaJson.packages[workspacePackage.packageJson.name] || {};

if (packageMeta.sourceHash === sourceHash) {
if (packageMeta.sourceHash === key) {
packagesUseCache.push(workspacePackage);
} else {
packagesNoCache.push(workspacePackage);
Expand Down Expand Up @@ -150,7 +158,7 @@ export async function getBatches(options: GetBatchesOptions = {}) {

// 3. Where needed, let's build and update the cache.
if (packagesNoCache.length === 0) {
return { batches: [], packagesNoCache, allPackages: workspacesPackages };
return { batches: [], packagesNoCache, allPackages: workspacesPackages, buildKeys };
}

const rawPackagesList = workspaceGraph.toposort();
Expand Down Expand Up @@ -186,6 +194,7 @@ export async function getBatches(options: GetBatchesOptions = {}) {
return {
batches,
packagesNoCache,
allPackages: workspacesPackages
allPackages: workspacesPackages,
buildKeys
};
}
164 changes: 164 additions & 0 deletions scripts/buildPackages/src/getEffectiveHashes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { createHash } from "node:crypto";
import path from "node:path";
import fs from "fs-extra";
import { load as loadYaml } from "js-yaml";
import { WorkspaceGraph } from "../../utils/WorkspaceGraph.js";
import { PROJECT_ROOT } from "../../utils/getPackages.js";
import { getPackageSourceHash } from "./getPackageSourceHash";
import type { Package } from "./types";

/**
* Whether the experimental dependency-aware build key is enabled. OFF by
* default: the build key is each package's own-source hash (original behavior),
* and `--rebuild-dependents` remains the mechanism for rebuilding dependents.
*
* Enable with `WEBINY_EXPERIMENTAL_DEP_AWARE_CACHE=true` (or `1`) to make a
* plain `yarn build` rebuild dependents of any changed package automatically.
*/
export function isDepAwareKeyEnabled(): boolean {
const value = process.env.WEBINY_EXPERIMENTAL_DEP_AWARE_CACHE;
return value === "true" || value === "1";
}

/**
* Own-source hash per package (no dependency folding) — the original,
* non-dependency-aware build key. Used when the experimental key is disabled.
*/
export async function getOwnHashes(allPackages: Package[]): Promise<Map<string, string>> {
const map = new Map<string, string>();
await Promise.all(
allPackages.map(async pkg => {
map.set(pkg.name, await getPackageSourceHash(pkg));
})
);
return map;
}

/**
* Parses `yarn.lock` into a map of dependency descriptor → a token that changes
* whenever the *resolved* package changes (its checksum, falling back to
* resolution/version). Lets a package's key reflect not just the declared
* version range (already covered by hashing its package.json) but what that
* range actually resolves to — closing the gap where a floating range or a
* lockfile update pulls new third-party code without any package.json edit.
*/
function buildLockResolutionMap(): Map<string, string> {
const map = new Map<string, string>();
const lockPath = path.join(PROJECT_ROOT, "yarn.lock");
if (!fs.existsSync(lockPath)) {
return map;
}

const doc = loadYaml(fs.readFileSync(lockPath, "utf8")) as Record<string, any> | null;
if (!doc) {
return map;
}

for (const [key, entry] of Object.entries(doc)) {
if (key === "__metadata" || !entry) {
continue;
}
const token = entry.checksum || entry.resolution || entry.version || "";
// A single entry may cover several comma-separated descriptors.
for (const descriptor of key.split(", ")) {
map.set(descriptor.trim(), token);
}
}

return map;
}

/**
* Hash of a package's resolved third-party (non-workspace) dependencies. Folded
* into its build key so a lockfile change to any of them invalidates only the
* packages that actually depend on it — not the whole repo.
*/
function thirdPartyDepsHash(
pkg: Package,
workspaceNames: Set<string>,
lockMap: Map<string, string>
): string {
const json = pkg.packageJson || {};
const deps: Record<string, string> = {
...json.dependencies,
...json.devDependencies
};

const parts: string[] = [];
for (const [name, range] of Object.entries(deps)) {
// Workspace deps are captured by folding their keys; skip them here.
if (
workspaceNames.has(name) ||
(typeof range === "string" && range.startsWith("workspace:"))
) {
continue;
}
const resolved = lockMap.get(`${name}@npm:${range}`) ?? String(range);
parts.push(`${name}@${resolved}`);
}

parts.sort();
return createHash("sha256").update(parts.join("\0")).digest("hex");
}

/**
* Computes each package's *effective* build key:
*
* key(pkg) = hash(own source hash + resolved third-party deps + sorted keys
* of its workspace deps)
*
* Unlike the bare own-source hash, this changes whenever ANY (transitive)
* workspace dependency OR any resolved third-party dependency changes — so a
* dependent is a cache miss and gets rebuilt even when its own source is
* untouched. Relies on the workspace dependency graph (package.json), which
* `adio` keeps in sync with actual imports.
*
* Keys are computed once over the toposort (deps before dependents), so each is
* derived exactly once. Own-source hashes are computed in parallel.
*/
export async function getEffectiveHashes(allPackages: Package[]): Promise<Map<string, string>> {
const graph = new WorkspaceGraph({ ignore: ["@webiny/project-utils"] });
// name -> direct workspace deps, insertion order = topological (deps first).
const sorted = graph.toposort() as Record<string, string[]>;

const byName = new Map(allPackages.map(pkg => [pkg.name, pkg]));
const names = Object.keys(sorted);
const workspaceNames = new Set(names);
const lockMap = buildLockResolutionMap();

// Own-source hash per package (parallel).
const ownHashes = new Map<string, string>();
await Promise.all(
names.map(async name => {
const pkg = byName.get(name);
ownHashes.set(name, pkg ? await getPackageSourceHash(pkg) : "");
})
);

// Resolved third-party dependency hash per package.
const thirdParty = new Map<string, string>();
for (const name of names) {
const pkg = byName.get(name);
thirdParty.set(name, pkg ? thirdPartyDepsHash(pkg, workspaceNames, lockMap) : "");
}

// Effective key per package, folding in dependency keys (topo order).
const keys = new Map<string, string>();
for (const name of names) {
const depKeys = (sorted[name] || [])
.map(dep => keys.get(dep) ?? ownHashes.get(dep) ?? "")
.sort();

const hash = createHash("sha256");
hash.update(ownHashes.get(name) ?? "");
hash.update("\0lock\0");
hash.update(thirdParty.get(name) ?? "");
for (const depKey of depKeys) {
hash.update("\0");
hash.update(depKey);
}
keys.set(name, hash.digest("hex"));
}

return keys;
}
2 changes: 2 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -10244,11 +10244,13 @@ __metadata:
version: 0.0.0-use.local
resolution: "@webiny-scripts/build-packages@workspace:scripts/buildPackages"
dependencies:
"@types/js-yaml": "npm:^4.0.9"
"@types/yargs": "npm:^17.0.35"
"@webiny/stdlib": "npm:^0.0.9"
chalk: "npm:^5.6.2"
execa: "npm:^9.6.1"
fs-extra: "npm:^11.3.6"
js-yaml: "npm:^5.2.1"
listr2: "npm:^10.2.2"
load-json-file: "npm:^7.0.1"
node-notifier: "npm:^10.0.1"
Expand Down
Loading