@jami-studio/core 0.92.36 → 0.92.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Content-hash build cache for `agent-native deploy` workspace builds.
3
+ *
4
+ * Rebuilding every app on every workspace deploy is pure waste when only a
5
+ * few apps changed. Each app's build is a deterministic function of:
6
+ *
7
+ * 1. The app directory's source files (excluding build outputs + deps).
8
+ * 2. The workspace-local packages it depends on via `workspace:*`.
9
+ * 3. The root lockfile (captures every registry dependency version,
10
+ * including the framework runtime itself).
11
+ * 4. The environment the build is invoked with: the deploy preset, the
12
+ * computed per-app env block (base path, audience, workspace manifest,
13
+ * gateway URL, ...) and any ambient env Vite/Nitro can bake into the
14
+ * bundle (`VITE_*`, `AGENT_NATIVE_*`, `WORKSPACE_*`, ...).
15
+ * 5. The version of this package doing the building.
16
+ *
17
+ * When all of that is byte-identical to the previous run AND the app's
18
+ * previous build output still exists on disk, the build is skipped and the
19
+ * existing output is reused. The workspace output assembly (copy into
20
+ * dist/, manifests, dedupe, routing) always runs fresh — it is cheap and
21
+ * keeps the artifact deterministic.
22
+ *
23
+ * Safety posture: a false MISS costs one redundant rebuild; a false HIT
24
+ * ships a stale artifact. Every choice below prefers false misses — full
25
+ * content hashing (no mtimes), lockfile-wide invalidation, and the entire
26
+ * invocation env block folded into the key.
27
+ *
28
+ * Opt out with `--no-build-cache` or AGENT_NATIVE_WORKSPACE_BUILD_CACHE=0.
29
+ */
30
+ import crypto from "crypto";
31
+ import fs from "fs";
32
+ import path from "path";
33
+ /** Directories inside an app that are build outputs or dependencies — never
34
+ * build inputs. Everything else in the app dir participates in the hash. */
35
+ const EXCLUDED_APP_DIRS = new Set([
36
+ "node_modules",
37
+ "dist",
38
+ "build",
39
+ ".output",
40
+ ".deploy-tmp",
41
+ ".netlify",
42
+ ".vercel",
43
+ ".react-router",
44
+ ".cache",
45
+ ".turbo",
46
+ ".git",
47
+ ]);
48
+ /** Ambient process.env prefixes that can be baked into app bundles. */
49
+ const ENV_PREFIXES = ["VITE_", "AGENT_NATIVE_", "WORKSPACE_", "NETLIFY_"];
50
+ const ENV_EXACT_KEYS = [
51
+ "APP_URL",
52
+ "BETTER_AUTH_URL",
53
+ "DATABASE_URL",
54
+ "NITRO_PRESET",
55
+ "NODE_ENV",
56
+ ];
57
+ const STAMP_DIR = path.join("node_modules", ".cache", "agent-native");
58
+ const STAMP_FILE = "workspace-build.json";
59
+ export function isWorkspaceBuildCacheEnabled(rawArgs) {
60
+ if (rawArgs.includes("--no-build-cache"))
61
+ return false;
62
+ const env = process.env.AGENT_NATIVE_WORKSPACE_BUILD_CACHE;
63
+ if (env === "0" || env === "false")
64
+ return false;
65
+ return true;
66
+ }
67
+ /** Compute the cache key for one app build. Returns null when hashing is not
68
+ * possible (treat as cache miss — never as a hit). */
69
+ export function computeWorkspaceAppBuildHash(opts) {
70
+ try {
71
+ const hash = crypto.createHash("sha256");
72
+ hash.update(`builder:${opts.builderVersion}\0`);
73
+ hash.update(`preset:${opts.preset}\0`);
74
+ // 1. App sources.
75
+ hashDirInto(hash, opts.appDir, opts.appDir);
76
+ // 2. workspace:* dependency packages (source-level inputs the lockfile
77
+ // does not pin).
78
+ for (const depDir of resolveWorkspaceDepDirs(opts.workspaceRoot, opts.appDir)) {
79
+ hash.update(`workspace-dep:${path.basename(depDir)}\0`);
80
+ hashDirInto(hash, depDir, depDir);
81
+ }
82
+ // 3. Root lockfile — pins every registry dependency (framework runtime
83
+ // included), so a core bump or any dep change invalidates all apps.
84
+ for (const lock of ["pnpm-lock.yaml", "package-lock.json", "yarn.lock"]) {
85
+ const lockPath = path.join(opts.workspaceRoot, lock);
86
+ if (fs.existsSync(lockPath)) {
87
+ hash.update(`lock:${lock}\0`);
88
+ hash.update(fs.readFileSync(lockPath));
89
+ hash.update("\0");
90
+ }
91
+ }
92
+ // 4. Invocation env: the computed per-app block plus ambient bakeable
93
+ // env. Values are hashed, never stored.
94
+ const envEntries = [];
95
+ for (const [key, value] of Object.entries(opts.buildEnv)) {
96
+ if (value !== undefined)
97
+ envEntries.push(`${key}=${value}`);
98
+ }
99
+ for (const [key, value] of Object.entries(process.env)) {
100
+ if (value === undefined)
101
+ continue;
102
+ if (key in opts.buildEnv)
103
+ continue; // computed block wins
104
+ const relevant = ENV_EXACT_KEYS.includes(key) ||
105
+ ENV_PREFIXES.some((p) => key.startsWith(p));
106
+ if (relevant)
107
+ envEntries.push(`${key}=${value}`);
108
+ }
109
+ envEntries.sort();
110
+ hash.update(`env:${envEntries.join("\n")}\0`);
111
+ return hash.digest("hex");
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ }
117
+ /** True when the app's previous build can be reused as-is. */
118
+ export function workspaceAppBuildCacheHit(opts, hashValue) {
119
+ if (!hashValue)
120
+ return false;
121
+ const stamp = readStamp(opts.appDir);
122
+ if (!stamp || stamp.hash !== hashValue || stamp.preset !== opts.preset) {
123
+ return false;
124
+ }
125
+ return requiredOutputsExist(opts.appDir, opts.preset);
126
+ }
127
+ /** Record a successful build so the next identical run can skip it. */
128
+ export function writeWorkspaceAppBuildStamp(opts, hashValue) {
129
+ if (!hashValue)
130
+ return;
131
+ try {
132
+ const dir = path.join(opts.appDir, STAMP_DIR);
133
+ fs.mkdirSync(dir, { recursive: true });
134
+ fs.writeFileSync(path.join(dir, STAMP_FILE), JSON.stringify({
135
+ hash: hashValue,
136
+ preset: opts.preset,
137
+ builderVersion: opts.builderVersion,
138
+ createdAt: new Date().toISOString(),
139
+ }, null, 2));
140
+ }
141
+ catch {
142
+ // Best-effort: a missing stamp only costs a rebuild next time.
143
+ }
144
+ }
145
+ function readStamp(appDir) {
146
+ try {
147
+ const raw = fs.readFileSync(path.join(appDir, STAMP_DIR, STAMP_FILE), "utf-8");
148
+ const parsed = JSON.parse(raw);
149
+ if (typeof parsed.hash !== "string" || typeof parsed.preset !== "string") {
150
+ return null;
151
+ }
152
+ return { hash: parsed.hash, preset: parsed.preset };
153
+ }
154
+ catch {
155
+ return null;
156
+ }
157
+ }
158
+ function requiredOutputsExist(appDir, preset) {
159
+ if (preset === "vercel") {
160
+ return fs.existsSync(path.join(appDir, ".vercel", "output"));
161
+ }
162
+ const buildOut = fs.existsSync(path.join(appDir, "dist")) ||
163
+ fs.existsSync(path.join(appDir, ".output"));
164
+ if (!buildOut)
165
+ return false;
166
+ if (preset === "netlify") {
167
+ return fs.existsSync(path.join(appDir, ".netlify", "functions-internal", "server"));
168
+ }
169
+ return true;
170
+ }
171
+ /** Deterministically hash every build-input file under `dir`. */
172
+ function hashDirInto(hash, dir, rootDir) {
173
+ let entries;
174
+ try {
175
+ entries = fs.readdirSync(dir, { withFileTypes: true });
176
+ }
177
+ catch {
178
+ return;
179
+ }
180
+ entries.sort((a, b) => a.name.localeCompare(b.name));
181
+ for (const entry of entries) {
182
+ if (entry.name.startsWith(".DS_"))
183
+ continue;
184
+ const full = path.join(dir, entry.name);
185
+ if (entry.isDirectory()) {
186
+ if (dir === rootDir && EXCLUDED_APP_DIRS.has(entry.name))
187
+ continue;
188
+ // Nested node_modules (e.g. inside checked-in fixtures) are still deps.
189
+ if (entry.name === "node_modules")
190
+ continue;
191
+ hashDirInto(hash, full, rootDir);
192
+ }
193
+ else if (entry.isFile()) {
194
+ const rel = path.relative(rootDir, full).split(path.sep).join("/");
195
+ hash.update(`f:${rel}\0`);
196
+ try {
197
+ hash.update(fs.readFileSync(full));
198
+ }
199
+ catch {
200
+ hash.update("<unreadable>");
201
+ }
202
+ hash.update("\0");
203
+ }
204
+ // Symlinks and other entry types are skipped: their targets are either
205
+ // covered elsewhere (workspace deps) or not build inputs.
206
+ }
207
+ }
208
+ /** Resolve the app's `workspace:*` dependencies to their package dirs. */
209
+ function resolveWorkspaceDepDirs(workspaceRoot, appDir) {
210
+ const dirs = [];
211
+ let pkg;
212
+ try {
213
+ pkg = JSON.parse(fs.readFileSync(path.join(appDir, "package.json"), "utf-8"));
214
+ }
215
+ catch {
216
+ return dirs;
217
+ }
218
+ const workspaceDepNames = Object.entries({
219
+ ...pkg.dependencies,
220
+ ...pkg.devDependencies,
221
+ })
222
+ .filter(([, spec]) => typeof spec === "string" && spec.startsWith("workspace:"))
223
+ .map(([name]) => name);
224
+ if (workspaceDepNames.length === 0)
225
+ return dirs;
226
+ const packagesDir = path.join(workspaceRoot, "packages");
227
+ let candidates = [];
228
+ try {
229
+ candidates = fs
230
+ .readdirSync(packagesDir, { withFileTypes: true })
231
+ .filter((e) => e.isDirectory())
232
+ .map((e) => path.join(packagesDir, e.name));
233
+ }
234
+ catch {
235
+ return dirs;
236
+ }
237
+ const byName = new Map();
238
+ for (const candidate of candidates) {
239
+ try {
240
+ const name = JSON.parse(fs.readFileSync(path.join(candidate, "package.json"), "utf-8")).name;
241
+ if (typeof name === "string")
242
+ byName.set(name, candidate);
243
+ }
244
+ catch {
245
+ // skip unreadable packages
246
+ }
247
+ }
248
+ for (const depName of workspaceDepNames.sort()) {
249
+ const dir = byName.get(depName);
250
+ if (dir)
251
+ dirs.push(dir);
252
+ // Unresolvable workspace deps fall back to lockfile coverage; the sorted
253
+ // dep-name list itself is not hashed separately because package.json is
254
+ // already part of the app-source hash.
255
+ }
256
+ return dirs;
257
+ }
258
+ //# sourceMappingURL=workspace-build-cache.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspace-build-cache.js","sourceRoot":"","sources":["../../src/deploy/workspace-build-cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,OAAO,MAAM,MAAM,QAAQ,CAAC;AAC5B,OAAO,EAAE,MAAM,IAAI,CAAC;AACpB,OAAO,IAAI,MAAM,MAAM,CAAC;AAExB;4EAC4E;AAC5E,MAAM,iBAAiB,GAAG,IAAI,GAAG,CAAC;IAChC,cAAc;IACd,MAAM;IACN,OAAO;IACP,SAAS;IACT,aAAa;IACb,UAAU;IACV,SAAS;IACT,eAAe;IACf,QAAQ;IACR,QAAQ;IACR,MAAM;CACP,CAAC,CAAC;AAEH,uEAAuE;AACvE,MAAM,YAAY,GAAG,CAAC,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,UAAU,CAAC,CAAC;AAC1E,MAAM,cAAc,GAAG;IACrB,SAAS;IACT,iBAAiB;IACjB,cAAc;IACd,cAAc;IACd,UAAU;CACX,CAAC;AAEF,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,cAAc,EAAE,QAAQ,EAAE,cAAc,CAAC,CAAC;AACtE,MAAM,UAAU,GAAG,sBAAsB,CAAC;AAc1C,MAAM,UAAU,4BAA4B,CAAC,OAAiB;IAC5D,IAAI,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAC;QAAE,OAAO,KAAK,CAAC;IACvD,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,kCAAkC,CAAC;IAC3D,IAAI,GAAG,KAAK,GAAG,IAAI,GAAG,KAAK,OAAO;QAAE,OAAO,KAAK,CAAC;IACjD,OAAO,IAAI,CAAC;AACd,CAAC;AAED;sDACsD;AACtD,MAAM,UAAU,4BAA4B,CAC1C,IAAgC;IAEhC,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC;QACzC,IAAI,CAAC,MAAM,CAAC,WAAW,IAAI,CAAC,cAAc,IAAI,CAAC,CAAC;QAChD,IAAI,CAAC,MAAM,CAAC,UAAU,IAAI,CAAC,MAAM,IAAI,CAAC,CAAC;QAEvC,kBAAkB;QAClB,WAAW,CAAC,IAAI,EAAE,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC;QAE5C,uEAAuE;QACvE,oBAAoB;QACpB,KAAK,MAAM,MAAM,IAAI,uBAAuB,CAC1C,IAAI,CAAC,aAAa,EAClB,IAAI,CAAC,MAAM,CACZ,EAAE,CAAC;YACF,IAAI,CAAC,MAAM,CAAC,iBAAiB,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;YACxD,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;QACpC,CAAC;QAED,uEAAuE;QACvE,uEAAuE;QACvE,KAAK,MAAM,IAAI,IAAI,CAAC,gBAAgB,EAAE,mBAAmB,EAAE,WAAW,CAAC,EAAE,CAAC;YACxE,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,aAAa,EAAE,IAAI,CAAC,CAAC;YACrD,IAAI,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;gBAC5B,IAAI,CAAC,MAAM,CAAC,QAAQ,IAAI,IAAI,CAAC,CAAC;gBAC9B,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC,CAAC;gBACvC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;YACpB,CAAC;QACH,CAAC;QAED,sEAAsE;QACtE,2CAA2C;QAC3C,MAAM,UAAU,GAAa,EAAE,CAAC;QAChC,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC;YACzD,IAAI,KAAK,KAAK,SAAS;gBAAE,UAAU,CAAC,IAAI,CAAC,GAAG,GAAG,IAAI,KAAK,EAAE,CAAC,CAAC;QAC9D,CAAC;QACD,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC;YACvD,IAAI,KAAK,KAAK,SAAS;gBAAE,SAAS;YAClC,IAAI,GAAG,IAAI,IAAI,CAAC,QAAQ;gBAAE,SAAS,CAAC,sBAAsB;YAC1D,MAAM,QAAQ,GACZ,cAAc,CAAC,QAAQ,CAAC,GAAG,CAAC;gBAC5B,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC;YAC9C,IAAI,QAAQ;gBAAE,UAAU,CAAC,IAAI,CAAC,GAAG,GAAG,IAAI,KAAK,EAAE,CAAC,CAAC;QACnD,CAAC;QACD,UAAU,CAAC,IAAI,EAAE,CAAC;QAClB,IAAI,CAAC,MAAM,CAAC,OAAO,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAE9C,OAAO,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAC5B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,8DAA8D;AAC9D,MAAM,UAAU,yBAAyB,CACvC,IAAgC,EAChC,SAAwB;IAExB,IAAI,CAAC,SAAS;QAAE,OAAO,KAAK,CAAC;IAC7B,MAAM,KAAK,GAAG,SAAS,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IACrC,IAAI,CAAC,KAAK,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS,IAAI,KAAK,CAAC,MAAM,KAAK,IAAI,CAAC,MAAM,EAAE,CAAC;QACvE,OAAO,KAAK,CAAC;IACf,CAAC;IACD,OAAO,oBAAoB,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC;AACxD,CAAC;AAED,uEAAuE;AACvE,MAAM,UAAU,2BAA2B,CACzC,IAAgC,EAChC,SAAwB;IAExB,IAAI,CAAC,SAAS;QAAE,OAAO;IACvB,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;QAC9C,EAAE,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QACvC,EAAE,CAAC,aAAa,CACd,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,UAAU,CAAC,EAC1B,IAAI,CAAC,SAAS,CACZ;YACE,IAAI,EAAE,SAAS;YACf,MAAM,EAAE,IAAI,CAAC,MAAM;YACnB,cAAc,EAAE,IAAI,CAAC,cAAc;YACnC,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;SACpC,EACD,IAAI,EACJ,CAAC,CACF,CACF,CAAC;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,+DAA+D;IACjE,CAAC;AACH,CAAC;AAED,SAAS,SAAS,CAChB,MAAc;IAEd,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,EAAE,CAAC,YAAY,CACzB,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,SAAS,EAAE,UAAU,CAAC,EACxC,OAAO,CACR,CAAC;QACF,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAyC,CAAC;QACvE,IAAI,OAAO,MAAM,CAAC,IAAI,KAAK,QAAQ,IAAI,OAAO,MAAM,CAAC,MAAM,KAAK,QAAQ,EAAE,CAAC;YACzE,OAAO,IAAI,CAAC;QACd,CAAC;QACD,OAAO,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC;IACtD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,SAAS,oBAAoB,CAAC,MAAc,EAAE,MAAc;IAC1D,IAAI,MAAM,KAAK,QAAQ,EAAE,CAAC;QACxB,OAAO,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,CAAC,CAAC,CAAC;IAC/D,CAAC;IACD,MAAM,QAAQ,GACZ,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QACxC,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC;IAC9C,IAAI,CAAC,QAAQ;QAAE,OAAO,KAAK,CAAC;IAC5B,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QACzB,OAAO,EAAE,CAAC,UAAU,CAClB,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,UAAU,EAAE,oBAAoB,EAAE,QAAQ,CAAC,CAC9D,CAAC;IACJ,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,iEAAiE;AACjE,SAAS,WAAW,CAClB,IAAiB,EACjB,GAAW,EACX,OAAe;IAEf,IAAI,OAAoB,CAAC;IACzB,IAAI,CAAC;QACH,OAAO,GAAG,EAAE,CAAC,WAAW,CAAC,GAAG,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC,CAAC;IACzD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO;IACT,CAAC;IACD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;IACrD,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;QAC5B,IAAI,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC;YAAE,SAAS;QAC5C,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC;QACxC,IAAI,KAAK,CAAC,WAAW,EAAE,EAAE,CAAC;YACxB,IAAI,GAAG,KAAK,OAAO,IAAI,iBAAiB,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC;gBAAE,SAAS;YACnE,wEAAwE;YACxE,IAAI,KAAK,CAAC,IAAI,KAAK,cAAc;gBAAE,SAAS;YAC5C,WAAW,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,CAAC,CAAC;QACnC,CAAC;aAAM,IAAI,KAAK,CAAC,MAAM,EAAE,EAAE,CAAC;YAC1B,MAAM,GAAG,GAAG,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YACnE,IAAI,CAAC,MAAM,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC;YAC1B,IAAI,CAAC;gBACH,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC;YACrC,CAAC;YAAC,MAAM,CAAC;gBACP,IAAI,CAAC,MAAM,CAAC,cAAc,CAAC,CAAC;YAC9B,CAAC;YACD,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;QACpB,CAAC;QACD,uEAAuE;QACvE,0DAA0D;IAC5D,CAAC;AACH,CAAC;AAED,0EAA0E;AAC1E,SAAS,uBAAuB,CAC9B,aAAqB,EACrB,MAAc;IAEd,MAAM,IAAI,GAAa,EAAE,CAAC;IAC1B,IAAI,GAGH,CAAC;IACF,IAAI,CAAC;QACH,GAAG,GAAG,IAAI,CAAC,KAAK,CACd,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,cAAc,CAAC,EAAE,OAAO,CAAC,CAC5D,CAAC;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;IACD,MAAM,iBAAiB,GAAG,MAAM,CAAC,OAAO,CAAC;QACvC,GAAG,GAAG,CAAC,YAAY;QACnB,GAAG,GAAG,CAAC,eAAe;KACvB,CAAC;SACC,MAAM,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,OAAO,IAAI,KAAK,QAAQ,IAAI,IAAI,CAAC,UAAU,CAAC,YAAY,CAAC,CAAC;SAC/E,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC;IACzB,IAAI,iBAAiB,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAEhD,MAAM,WAAW,GAAG,IAAI,CAAC,IAAI,CAAC,aAAa,EAAE,UAAU,CAAC,CAAC;IACzD,IAAI,UAAU,GAAa,EAAE,CAAC;IAC9B,IAAI,CAAC;QACH,UAAU,GAAG,EAAE;aACZ,WAAW,CAAC,WAAW,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC;aACjD,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC;aAC9B,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;IAChD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;IACD,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAC;IACzC,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;QACnC,IAAI,CAAC;YACH,MAAM,IAAI,GACR,IAAI,CAAC,KAAK,CACR,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,cAAc,CAAC,EAAE,OAAO,CAAC,CAEjE,CAAC,IAAI,CAAC;YACP,IAAI,OAAO,IAAI,KAAK,QAAQ;gBAAE,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC;QAC5D,CAAC;QAAC,MAAM,CAAC;YACP,2BAA2B;QAC7B,CAAC;IACH,CAAC;IACD,KAAK,MAAM,OAAO,IAAI,iBAAiB,CAAC,IAAI,EAAE,EAAE,CAAC;QAC/C,MAAM,GAAG,GAAG,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QAChC,IAAI,GAAG;YAAE,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACxB,yEAAyE;QACzE,wEAAwE;QACxE,uCAAuC;IACzC,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC","sourcesContent":["/**\r\n * Content-hash build cache for `agent-native deploy` workspace builds.\r\n *\r\n * Rebuilding every app on every workspace deploy is pure waste when only a\r\n * few apps changed. Each app's build is a deterministic function of:\r\n *\r\n * 1. The app directory's source files (excluding build outputs + deps).\r\n * 2. The workspace-local packages it depends on via `workspace:*`.\r\n * 3. The root lockfile (captures every registry dependency version,\r\n * including the framework runtime itself).\r\n * 4. The environment the build is invoked with: the deploy preset, the\r\n * computed per-app env block (base path, audience, workspace manifest,\r\n * gateway URL, ...) and any ambient env Vite/Nitro can bake into the\r\n * bundle (`VITE_*`, `AGENT_NATIVE_*`, `WORKSPACE_*`, ...).\r\n * 5. The version of this package doing the building.\r\n *\r\n * When all of that is byte-identical to the previous run AND the app's\r\n * previous build output still exists on disk, the build is skipped and the\r\n * existing output is reused. The workspace output assembly (copy into\r\n * dist/, manifests, dedupe, routing) always runs fresh — it is cheap and\r\n * keeps the artifact deterministic.\r\n *\r\n * Safety posture: a false MISS costs one redundant rebuild; a false HIT\r\n * ships a stale artifact. Every choice below prefers false misses — full\r\n * content hashing (no mtimes), lockfile-wide invalidation, and the entire\r\n * invocation env block folded into the key.\r\n *\r\n * Opt out with `--no-build-cache` or AGENT_NATIVE_WORKSPACE_BUILD_CACHE=0.\r\n */\r\nimport crypto from \"crypto\";\r\nimport fs from \"fs\";\r\nimport path from \"path\";\r\n\r\n/** Directories inside an app that are build outputs or dependencies — never\r\n * build inputs. Everything else in the app dir participates in the hash. */\r\nconst EXCLUDED_APP_DIRS = new Set([\r\n \"node_modules\",\r\n \"dist\",\r\n \"build\",\r\n \".output\",\r\n \".deploy-tmp\",\r\n \".netlify\",\r\n \".vercel\",\r\n \".react-router\",\r\n \".cache\",\r\n \".turbo\",\r\n \".git\",\r\n]);\r\n\r\n/** Ambient process.env prefixes that can be baked into app bundles. */\r\nconst ENV_PREFIXES = [\"VITE_\", \"AGENT_NATIVE_\", \"WORKSPACE_\", \"NETLIFY_\"];\r\nconst ENV_EXACT_KEYS = [\r\n \"APP_URL\",\r\n \"BETTER_AUTH_URL\",\r\n \"DATABASE_URL\",\r\n \"NITRO_PRESET\",\r\n \"NODE_ENV\",\r\n];\r\n\r\nconst STAMP_DIR = path.join(\"node_modules\", \".cache\", \"agent-native\");\r\nconst STAMP_FILE = \"workspace-build.json\";\r\n\r\nexport interface WorkspaceBuildCacheOptions {\r\n workspaceRoot: string;\r\n appDir: string;\r\n app: string;\r\n preset: string;\r\n /** The exact env block the app build is invoked with (delta over\r\n * process.env computed by the deploy orchestrator). */\r\n buildEnv: Record<string, string | undefined>;\r\n /** Version of the framework package performing the build. */\r\n builderVersion: string;\r\n}\r\n\r\nexport function isWorkspaceBuildCacheEnabled(rawArgs: string[]): boolean {\r\n if (rawArgs.includes(\"--no-build-cache\")) return false;\r\n const env = process.env.AGENT_NATIVE_WORKSPACE_BUILD_CACHE;\r\n if (env === \"0\" || env === \"false\") return false;\r\n return true;\r\n}\r\n\r\n/** Compute the cache key for one app build. Returns null when hashing is not\r\n * possible (treat as cache miss — never as a hit). */\r\nexport function computeWorkspaceAppBuildHash(\r\n opts: WorkspaceBuildCacheOptions,\r\n): string | null {\r\n try {\r\n const hash = crypto.createHash(\"sha256\");\r\n hash.update(`builder:${opts.builderVersion}\\0`);\r\n hash.update(`preset:${opts.preset}\\0`);\r\n\r\n // 1. App sources.\r\n hashDirInto(hash, opts.appDir, opts.appDir);\r\n\r\n // 2. workspace:* dependency packages (source-level inputs the lockfile\r\n // does not pin).\r\n for (const depDir of resolveWorkspaceDepDirs(\r\n opts.workspaceRoot,\r\n opts.appDir,\r\n )) {\r\n hash.update(`workspace-dep:${path.basename(depDir)}\\0`);\r\n hashDirInto(hash, depDir, depDir);\r\n }\r\n\r\n // 3. Root lockfile — pins every registry dependency (framework runtime\r\n // included), so a core bump or any dep change invalidates all apps.\r\n for (const lock of [\"pnpm-lock.yaml\", \"package-lock.json\", \"yarn.lock\"]) {\r\n const lockPath = path.join(opts.workspaceRoot, lock);\r\n if (fs.existsSync(lockPath)) {\r\n hash.update(`lock:${lock}\\0`);\r\n hash.update(fs.readFileSync(lockPath));\r\n hash.update(\"\\0\");\r\n }\r\n }\r\n\r\n // 4. Invocation env: the computed per-app block plus ambient bakeable\r\n // env. Values are hashed, never stored.\r\n const envEntries: string[] = [];\r\n for (const [key, value] of Object.entries(opts.buildEnv)) {\r\n if (value !== undefined) envEntries.push(`${key}=${value}`);\r\n }\r\n for (const [key, value] of Object.entries(process.env)) {\r\n if (value === undefined) continue;\r\n if (key in opts.buildEnv) continue; // computed block wins\r\n const relevant =\r\n ENV_EXACT_KEYS.includes(key) ||\r\n ENV_PREFIXES.some((p) => key.startsWith(p));\r\n if (relevant) envEntries.push(`${key}=${value}`);\r\n }\r\n envEntries.sort();\r\n hash.update(`env:${envEntries.join(\"\\n\")}\\0`);\r\n\r\n return hash.digest(\"hex\");\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\n/** True when the app's previous build can be reused as-is. */\r\nexport function workspaceAppBuildCacheHit(\r\n opts: WorkspaceBuildCacheOptions,\r\n hashValue: string | null,\r\n): boolean {\r\n if (!hashValue) return false;\r\n const stamp = readStamp(opts.appDir);\r\n if (!stamp || stamp.hash !== hashValue || stamp.preset !== opts.preset) {\r\n return false;\r\n }\r\n return requiredOutputsExist(opts.appDir, opts.preset);\r\n}\r\n\r\n/** Record a successful build so the next identical run can skip it. */\r\nexport function writeWorkspaceAppBuildStamp(\r\n opts: WorkspaceBuildCacheOptions,\r\n hashValue: string | null,\r\n): void {\r\n if (!hashValue) return;\r\n try {\r\n const dir = path.join(opts.appDir, STAMP_DIR);\r\n fs.mkdirSync(dir, { recursive: true });\r\n fs.writeFileSync(\r\n path.join(dir, STAMP_FILE),\r\n JSON.stringify(\r\n {\r\n hash: hashValue,\r\n preset: opts.preset,\r\n builderVersion: opts.builderVersion,\r\n createdAt: new Date().toISOString(),\r\n },\r\n null,\r\n 2,\r\n ),\r\n );\r\n } catch {\r\n // Best-effort: a missing stamp only costs a rebuild next time.\r\n }\r\n}\r\n\r\nfunction readStamp(\r\n appDir: string,\r\n): { hash: string; preset: string } | null {\r\n try {\r\n const raw = fs.readFileSync(\r\n path.join(appDir, STAMP_DIR, STAMP_FILE),\r\n \"utf-8\",\r\n );\r\n const parsed = JSON.parse(raw) as { hash?: unknown; preset?: unknown };\r\n if (typeof parsed.hash !== \"string\" || typeof parsed.preset !== \"string\") {\r\n return null;\r\n }\r\n return { hash: parsed.hash, preset: parsed.preset };\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\nfunction requiredOutputsExist(appDir: string, preset: string): boolean {\r\n if (preset === \"vercel\") {\r\n return fs.existsSync(path.join(appDir, \".vercel\", \"output\"));\r\n }\r\n const buildOut =\r\n fs.existsSync(path.join(appDir, \"dist\")) ||\r\n fs.existsSync(path.join(appDir, \".output\"));\r\n if (!buildOut) return false;\r\n if (preset === \"netlify\") {\r\n return fs.existsSync(\r\n path.join(appDir, \".netlify\", \"functions-internal\", \"server\"),\r\n );\r\n }\r\n return true;\r\n}\r\n\r\n/** Deterministically hash every build-input file under `dir`. */\r\nfunction hashDirInto(\r\n hash: crypto.Hash,\r\n dir: string,\r\n rootDir: string,\r\n): void {\r\n let entries: fs.Dirent[];\r\n try {\r\n entries = fs.readdirSync(dir, { withFileTypes: true });\r\n } catch {\r\n return;\r\n }\r\n entries.sort((a, b) => a.name.localeCompare(b.name));\r\n for (const entry of entries) {\r\n if (entry.name.startsWith(\".DS_\")) continue;\r\n const full = path.join(dir, entry.name);\r\n if (entry.isDirectory()) {\r\n if (dir === rootDir && EXCLUDED_APP_DIRS.has(entry.name)) continue;\r\n // Nested node_modules (e.g. inside checked-in fixtures) are still deps.\r\n if (entry.name === \"node_modules\") continue;\r\n hashDirInto(hash, full, rootDir);\r\n } else if (entry.isFile()) {\r\n const rel = path.relative(rootDir, full).split(path.sep).join(\"/\");\r\n hash.update(`f:${rel}\\0`);\r\n try {\r\n hash.update(fs.readFileSync(full));\r\n } catch {\r\n hash.update(\"<unreadable>\");\r\n }\r\n hash.update(\"\\0\");\r\n }\r\n // Symlinks and other entry types are skipped: their targets are either\r\n // covered elsewhere (workspace deps) or not build inputs.\r\n }\r\n}\r\n\r\n/** Resolve the app's `workspace:*` dependencies to their package dirs. */\r\nfunction resolveWorkspaceDepDirs(\r\n workspaceRoot: string,\r\n appDir: string,\r\n): string[] {\r\n const dirs: string[] = [];\r\n let pkg: {\r\n dependencies?: Record<string, string>;\r\n devDependencies?: Record<string, string>;\r\n };\r\n try {\r\n pkg = JSON.parse(\r\n fs.readFileSync(path.join(appDir, \"package.json\"), \"utf-8\"),\r\n );\r\n } catch {\r\n return dirs;\r\n }\r\n const workspaceDepNames = Object.entries({\r\n ...pkg.dependencies,\r\n ...pkg.devDependencies,\r\n })\r\n .filter(([, spec]) => typeof spec === \"string\" && spec.startsWith(\"workspace:\"))\r\n .map(([name]) => name);\r\n if (workspaceDepNames.length === 0) return dirs;\r\n\r\n const packagesDir = path.join(workspaceRoot, \"packages\");\r\n let candidates: string[] = [];\r\n try {\r\n candidates = fs\r\n .readdirSync(packagesDir, { withFileTypes: true })\r\n .filter((e) => e.isDirectory())\r\n .map((e) => path.join(packagesDir, e.name));\r\n } catch {\r\n return dirs;\r\n }\r\n const byName = new Map<string, string>();\r\n for (const candidate of candidates) {\r\n try {\r\n const name = (\r\n JSON.parse(\r\n fs.readFileSync(path.join(candidate, \"package.json\"), \"utf-8\"),\r\n ) as { name?: string }\r\n ).name;\r\n if (typeof name === \"string\") byName.set(name, candidate);\r\n } catch {\r\n // skip unreadable packages\r\n }\r\n }\r\n for (const depName of workspaceDepNames.sort()) {\r\n const dir = byName.get(depName);\r\n if (dir) dirs.push(dir);\r\n // Unresolvable workspace deps fall back to lockfile coverage; the sorted\r\n // dep-name list itself is not hashed separately because package.json is\r\n // already part of the app-source hash.\r\n }\r\n return dirs;\r\n}\r\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"workspace-deploy.d.ts","sourceRoot":"","sources":["../../src/deploy/workspace-deploy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAqB7C,MAAM,MAAM,qBAAqB,GAAG,kBAAkB,GAAG,SAAS,GAAG,QAAQ,CAAC;AAiD9E,MAAM,WAAW,sBAAsB;IACrC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,qEAAqE;IACrE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,yDAAyD;IACzD,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,qDAAqD;IACrD,MAAM,CAAC,EAAE,qBAAqB,CAAC;IAC/B,qDAAqD;IACrD,QAAQ,CAAC,EAAE,OAAO,YAAY,CAAC;CAChC;AAED,wBAAsB,kBAAkB,CACtC,IAAI,GAAE,sBAA2B,GAChC,OAAO,CAAC,IAAI,CAAC,CA0Gf;AA0MD;;;;;;;;;;;GAWG;AACH,wBAAgB,4BAA4B,CAC1C,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,GACb,MAAM,EAAE,CAmCV"}
1
+ {"version":3,"file":"workspace-deploy.d.ts","sourceRoot":"","sources":["../../src/deploy/workspace-deploy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AA4B7C,MAAM,MAAM,qBAAqB,GAAG,kBAAkB,GAAG,SAAS,GAAG,QAAQ,CAAC;AA8D9E,MAAM,WAAW,sBAAsB;IACrC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,qEAAqE;IACrE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,yDAAyD;IACzD,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,qDAAqD;IACrD,MAAM,CAAC,EAAE,qBAAqB,CAAC;IAC/B,qDAAqD;IACrD,QAAQ,CAAC,EAAE,OAAO,YAAY,CAAC;CAChC;AAED,wBAAsB,kBAAkB,CACtC,IAAI,GAAE,sBAA2B,GAChC,OAAO,CAAC,IAAI,CAAC,CA8Hf;AAmOD;;;;;;;;;;;GAWG;AACH,wBAAgB,4BAA4B,CAC1C,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,GACb,MAAM,EAAE,CAmCV"}
@@ -17,8 +17,10 @@
17
17
  import { execFileSync } from "child_process";
18
18
  import fs from "fs";
19
19
  import path from "path";
20
+ import { fileURLToPath } from "url";
20
21
  import { AGENT_CHAT_PROCESS_RUN_PATH } from "../agent/durable-background.js";
21
22
  import { findWorkspaceRoot } from "../scripts/utils.js";
23
+ import { computeWorkspaceAppBuildHash, isWorkspaceBuildCacheEnabled, workspaceAppBuildCacheHit, writeWorkspaceAppBuildStamp, } from "./workspace-build-cache.js";
22
24
  import { DEFAULT_WORKSPACE_APP_AUDIENCE, normalizeWorkspaceAppAudience, normalizeWorkspaceAppPathList, workspaceAppAudienceFromPackageJson, workspaceAppRouteAccessFromPackageJson, } from "../shared/workspace-app-audience.js";
23
25
  import { DISPATCH_WORKSPACE_ROOT_REDIRECTS } from "../shared/workspace-app-id.js";
24
26
  import { collectImmutableAssetPaths, IMMUTABLE_ASSET_CACHE_HEADERS, } from "./immutable-assets.js";
@@ -48,6 +50,15 @@ const WORKSPACE_APPS_ENV_KEY = "AGENT_NATIVE_WORKSPACE_APPS_JSON";
48
50
  const WORKSPACE_APPS_MANIFEST_DIR = ".agent-native";
49
51
  const WORKSPACE_APPS_MANIFEST_FILE = "workspace-apps.json";
50
52
  const VERCEL_OUTPUT_DIR = ".vercel/output";
53
+ // Version of this package — folds into the build-cache key so upgrading the
54
+ // framework invalidates every cached app build.
55
+ let BUILDER_VERSION = "unknown";
56
+ try {
57
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
58
+ // dist/deploy/workspace-deploy.js → ../../package.json
59
+ BUILDER_VERSION = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../../package.json"), "utf-8")).version ?? "unknown";
60
+ }
61
+ catch { }
51
62
  export async function runWorkspaceDeploy(opts = {}) {
52
63
  const workspaceRoot = opts.workspaceRoot ?? findWorkspaceRoot(process.cwd()) ?? process.cwd();
53
64
  const appsDir = path.join(workspaceRoot, "apps");
@@ -89,11 +100,21 @@ export async function runWorkspaceDeploy(opts = {}) {
89
100
  fs.mkdirSync(functionsDir, { recursive: true });
90
101
  }
91
102
  console.log(`[workspace-deploy] Building ${apps.length} app(s) for preset=${preset}`);
103
+ const buildCacheEnabled = isWorkspaceBuildCacheEnabled(rawArgs);
104
+ if (!buildCacheEnabled) {
105
+ console.log(`[workspace-deploy] Build cache disabled — rebuilding every app.`);
106
+ }
107
+ let cachedCount = 0;
92
108
  const execFile = opts.execFile ?? execFileSync;
93
109
  for (const app of apps) {
94
- buildOneApp(workspaceRoot, app, preset, execFile, workspaceApps);
110
+ const skipped = buildOneApp(workspaceRoot, app, preset, execFile, workspaceApps, buildCacheEnabled);
111
+ if (skipped)
112
+ cachedCount++;
95
113
  moveAppBuildIntoWorkspaceOutput(workspaceRoot, app, preset, distDir, vercelOutputDir, workspaceApps);
96
114
  }
115
+ if (buildCacheEnabled && cachedCount > 0) {
116
+ console.log(`[workspace-deploy] Build cache: reused ${cachedCount}/${apps.length} unchanged app build(s), rebuilt ${apps.length - cachedCount}.`);
117
+ }
97
118
  writeWorkspaceAppManifests(workspaceRoot, distDir, apps, workspaceApps, preset);
98
119
  if (preset === "netlify") {
99
120
  writeNetlifyRedirects(distDir, apps);
@@ -124,7 +145,7 @@ export async function runWorkspaceDeploy(opts = {}) {
124
145
  }
125
146
  console.log(`All apps live at https://<origin>/<app-name>/*. Log in once on any app\nand the session is shared across the workspace.`);
126
147
  }
127
- function buildOneApp(workspaceRoot, app, preset, execFile, workspaceApps) {
148
+ function buildOneApp(workspaceRoot, app, preset, execFile, workspaceApps, buildCacheEnabled) {
128
149
  const appDir = path.join(workspaceRoot, "apps", app);
129
150
  const workspaceAppAudience = workspaceAppAudienceForApp(workspaceApps, app);
130
151
  const workspaceAppRouteAccess = workspaceAppRouteAccessForApp(workspaceApps, app);
@@ -174,6 +195,24 @@ function buildOneApp(workspaceRoot, app, preset, execFile, workspaceApps) {
174
195
  process.env.DATABASE_URL ??
175
196
  env.DATABASE_URL;
176
197
  }
198
+ // Content-hash cache: when the app's sources, workspace deps, lockfile,
199
+ // and invocation env are byte-identical to the previous successful build
200
+ // (and its output still exists), reuse it instead of rebuilding.
201
+ const cacheOpts = {
202
+ workspaceRoot,
203
+ appDir,
204
+ app,
205
+ preset,
206
+ buildEnv: buildCacheEnvDelta(env),
207
+ builderVersion: BUILDER_VERSION,
208
+ };
209
+ const buildHash = buildCacheEnabled
210
+ ? computeWorkspaceAppBuildHash(cacheOpts)
211
+ : null;
212
+ if (buildCacheEnabled && workspaceAppBuildCacheHit(cacheOpts, buildHash)) {
213
+ console.log(`[workspace-deploy] ${app} unchanged — reusing cached build (base=/${app}, preset=${preset})`);
214
+ return true;
215
+ }
177
216
  console.log(`[workspace-deploy] Building ${app} (base=/${app}, preset=${preset})`);
178
217
  cleanAppBuildOutputs(appDir);
179
218
  // Windows: app builds occasionally die with a native access violation
@@ -191,7 +230,10 @@ function buildOneApp(workspaceRoot, app, preset, execFile, workspaceApps) {
191
230
  env,
192
231
  stdio: "inherit",
193
232
  });
194
- return;
233
+ if (buildCacheEnabled) {
234
+ writeWorkspaceAppBuildStamp(cacheOpts, buildHash);
235
+ }
236
+ return false;
195
237
  }
196
238
  catch (error) {
197
239
  const status = error?.status ?? null;
@@ -854,6 +896,17 @@ function netlifyPublicRootAssetPaths(app, staticDir) {
854
896
  function netlifyFunctionsDir(workspaceRoot) {
855
897
  return path.join(workspaceRoot, ".netlify", "functions-internal");
856
898
  }
899
+ /** The per-app build env is `{...process.env, ...computed}` — only the
900
+ * computed delta belongs in the cache key (ambient env is covered by the
901
+ * cache module's own prefix filter, machine noise like PATH stays out). */
902
+ function buildCacheEnvDelta(env) {
903
+ const delta = {};
904
+ for (const [key, value] of Object.entries(env)) {
905
+ if (process.env[key] !== value)
906
+ delta[key] = value;
907
+ }
908
+ return delta;
909
+ }
857
910
  function cleanAppBuildOutputs(appDir) {
858
911
  // .deploy-tmp: a crashed prior per-app build leaves partial artifacts that
859
912
  // silently poison the next build for that app (observed on Windows: stale