@jami-studio/core 0.92.36 → 0.92.38

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.
package/corpus/README.md CHANGED
@@ -27,5 +27,5 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
27
27
 
28
28
  ## Generated Counts
29
29
 
30
- - core files: 2158
31
- - template files: 5117
30
+ - core files: 2159
31
+ - template files: 5118
@@ -1,5 +1,17 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.92.38
4
+
5
+ ### Patch Changes
6
+
7
+ - 7e2841f: Cloudflare Pages static shell: render the real landing page instead of the hydration-mismatching manifest fallback — resolve framework-transitive packages (yjs, prosemirror) via module resolution hooks, follow same-origin index redirects with mount-prefix stripping, try both prefixed and unprefixed route shapes, and make the SSR heavy-lib stub callable/constructible so module-scope `new PluginKey(...)` in stubbed client code cannot crash the shell render. Manifest-fallback shells now emit `<html lang="en-US" dir="ltr" data-locale="en-US">` when the root route has a loader, matching the default hydration state. Fixes React #418 hydration errors on served workspace apps.
8
+
9
+ ## 0.92.37
10
+
11
+ ### Patch Changes
12
+
13
+ - c8694f1: Workspace deploy now caches per-app builds by content hash: unchanged apps (sources, workspace deps, lockfile, invocation env, builder version) reuse their previous build output instead of rebuilding. Disable with `--no-build-cache` or `AGENT_NATIVE_WORKSPACE_BUILD_CACHE=0`.
14
+
3
15
  ## 0.92.36
4
16
 
5
17
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.92.36",
3
+ "version": "0.92.38",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/studio-jami/jami-studio#readme",
6
6
  "bugs": {
@@ -1759,8 +1759,15 @@ export function generateCloudflarePagesStaticShellFromManifest(
1759
1759
  const encodedInitialState = rootRoute.hasLoader
1760
1760
  ? DEFAULT_ROOT_LOADER_REACT_ROUTER_TURBO_STREAM
1761
1761
  : EMPTY_REACT_ROUTER_TURBO_STREAM;
1762
-
1763
- return `<!DOCTYPE html><html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"/><link rel="manifest" href="/manifest.json"/><link rel="icon" type="image/svg+xml" href="/favicon.svg"/>${modulePreloads}${stylesheets}</head><body><div style="display:flex;align-items:center;justify-content:center;height:100vh;width:100%"><svg role="status" aria-label="Loading" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="animation:an-spin 1s linear infinite;opacity:0.7"><path d="M21 12a9 9 0 1 1-6.219-8.56"></path></svg><style>@keyframes an-spin { to { transform: rotate(360deg) } } @media (prefers-color-scheme: dark) { html { background: #09090b; color: #fafafa } }</style></div><script>window.__reactRouterContext = ${JSON.stringify(context)};window.__reactRouterContext.stream = new ReadableStream({start(controller){window.__reactRouterContext.streamController = controller;}}).pipeThrough(new TextEncoderStream());</script><script type="module" async="">${routeModuleScript}</script><!--$--><script>window.__reactRouterContext.streamController.enqueue(${JSON.stringify(encodedInitialState)});</script><!--$--><script>window.__reactRouterContext.streamController.close();</script><!--/$--><!--/$--></body></html>`;
1762
+ // Match the <html> attributes the root route renders during hydration with
1763
+ // the embedded default root-loader state (locale/dir), so recovering from
1764
+ // the fallback shell does not add an attribute mismatch on top of the
1765
+ // structural one.
1766
+ const htmlTag = rootRoute.hasLoader
1767
+ ? '<html lang="en-US" dir="ltr" data-locale="en-US">'
1768
+ : '<html lang="en">';
1769
+
1770
+ return `<!DOCTYPE html>${htmlTag}<head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"/><link rel="manifest" href="/manifest.json"/><link rel="icon" type="image/svg+xml" href="/favicon.svg"/>${modulePreloads}${stylesheets}</head><body><div style="display:flex;align-items:center;justify-content:center;height:100vh;width:100%"><svg role="status" aria-label="Loading" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="animation:an-spin 1s linear infinite;opacity:0.7"><path d="M21 12a9 9 0 1 1-6.219-8.56"></path></svg><style>@keyframes an-spin { to { transform: rotate(360deg) } } @media (prefers-color-scheme: dark) { html { background: #09090b; color: #fafafa } }</style></div><script>window.__reactRouterContext = ${JSON.stringify(context)};window.__reactRouterContext.stream = new ReadableStream({start(controller){window.__reactRouterContext.streamController = controller;}}).pipeThrough(new TextEncoderStream());</script><script type="module" async="">${routeModuleScript}</script><!--$--><script>window.__reactRouterContext.streamController.enqueue(${JSON.stringify(encodedInitialState)});</script><!--$--><script>window.__reactRouterContext.streamController.close();</script><!--/$--><!--/$--></body></html>`;
1764
1771
  }
1765
1772
 
1766
1773
  function writeCloudflarePagesStaticShell({
@@ -1784,32 +1791,118 @@ function writeCloudflarePagesStaticShell({
1784
1791
  renderScript,
1785
1792
  `
1786
1793
  import fs from "node:fs";
1787
- import { createRequire } from "node:module";
1794
+ import Module, { createRequire } from "node:module";
1788
1795
  import { pathToFileURL } from "node:url";
1789
1796
 
1790
1797
  const cwd = ${JSON.stringify(cwd)};
1791
1798
  const serverEntry = ${JSON.stringify(serverEntry)};
1792
1799
  const outFile = ${JSON.stringify(outFile)};
1793
1800
  const basePath = ${JSON.stringify(basePath)};
1801
+ // The framework package's own module URL: bundled server chunks can import
1802
+ // packages (yjs, prosemirror-*) that are transitive framework dependencies
1803
+ // and not resolvable from the app directory under strict pnpm layouts. When
1804
+ // app-relative resolution fails, retry from the framework's own context.
1805
+ const frameworkParentUrl = ${JSON.stringify(import.meta.url)};
1806
+
1807
+ if (typeof Module.registerHooks === "function") {
1808
+ Module.registerHooks({
1809
+ resolve(specifier, context, nextResolve) {
1810
+ try {
1811
+ return nextResolve(specifier, context);
1812
+ } catch (error) {
1813
+ if (context?.parentURL && context.parentURL !== frameworkParentUrl) {
1814
+ try {
1815
+ return nextResolve(specifier, { ...context, parentURL: frameworkParentUrl });
1816
+ } catch {}
1817
+ }
1818
+ throw error;
1819
+ }
1820
+ },
1821
+ });
1822
+ }
1794
1823
 
1795
1824
  const requireFromApp = createRequire(cwd + "/package.json");
1796
1825
  const reactRouterEntry = requireFromApp.resolve("react-router");
1797
1826
  const { createRequestHandler } = await import(pathToFileURL(reactRouterEntry).href);
1798
1827
  const serverBuild = await import(pathToFileURL(serverEntry).href);
1799
1828
  const handler = createRequestHandler(serverBuild, "production");
1800
- const pathname = basePath ? basePath + "/" : "/";
1801
- const response = await handler(
1802
- new Request(new URL(pathname, "https://agent-native.local"), {
1803
- headers: { "X-React-Router-SPA-Mode": "yes" },
1804
- }),
1805
- );
1806
- const html = await response.text();
1807
1829
 
1808
- if (!html || !html.includes("__reactRouterContext") || !html.includes("entry.client")) {
1809
- throw new Error("React Router did not render a usable Cloudflare Pages static shell");
1830
+ const origin = "https://agent-native.local";
1831
+ const normalizedBase = basePath ? basePath.replace(/\\/+$/, "") : "";
1832
+
1833
+ function isUsable(html) {
1834
+ return Boolean(
1835
+ html && html.includes("__reactRouterContext") && html.includes("entry.client"),
1836
+ );
1810
1837
  }
1811
1838
 
1812
- fs.writeFileSync(outFile, html);
1839
+ // Render a path, following same-origin redirects (index routes commonly
1840
+ // redirect "/" to the real landing page). Server builds compiled with an
1841
+ // unprefixed router basename ("/") emit redirect targets that still carry
1842
+ // the mount prefix — strip it before re-requesting.
1843
+ async function renderPath(startPath) {
1844
+ let pathname = startPath;
1845
+ let response;
1846
+ for (let hop = 0; hop < 5; hop++) {
1847
+ response = await handler(
1848
+ new Request(new URL(pathname, origin), {
1849
+ headers: { "X-React-Router-SPA-Mode": "yes" },
1850
+ }),
1851
+ );
1852
+ if (response.status < 300 || response.status >= 400) break;
1853
+ const location = response.headers.get("location");
1854
+ if (!location) break;
1855
+ const target = new URL(location, new URL(pathname, origin));
1856
+ if (target.origin !== origin) break;
1857
+ let nextPath = target.pathname;
1858
+ if (
1859
+ normalizedBase &&
1860
+ (nextPath === normalizedBase || nextPath.startsWith(normalizedBase + "/")) &&
1861
+ !(startPath === normalizedBase + "/" || startPath.startsWith(normalizedBase + "/"))
1862
+ ) {
1863
+ nextPath = nextPath.slice(normalizedBase.length) || "/";
1864
+ }
1865
+ if (nextPath === pathname) break;
1866
+ pathname = nextPath;
1867
+ }
1868
+ const html = await response.text();
1869
+ return { pathname, status: response.status, html };
1870
+ }
1871
+
1872
+ // Route matching varies per template: some builds register routes under the
1873
+ // mount prefix, others compile with basename "/" and rely on the worker
1874
+ // entry stripping the prefix. Try both shapes and prefer a 2xx render;
1875
+ // otherwise keep the first usable render of any status (apps without an
1876
+ // index route ship their root-boundary render as the shell).
1877
+ const candidates = normalizedBase ? [normalizedBase + "/", "/"] : ["/"];
1878
+ let best = null;
1879
+ for (const candidate of candidates) {
1880
+ let result;
1881
+ try {
1882
+ result = await renderPath(candidate);
1883
+ } catch (error) {
1884
+ console.warn("[deploy] Static shell render threw at " + candidate + ": " + (error && error.message ? error.message : error));
1885
+ continue;
1886
+ }
1887
+ if (!isUsable(result.html)) continue;
1888
+ if (result.status >= 200 && result.status < 300) {
1889
+ best = result;
1890
+ break;
1891
+ }
1892
+ if (!best) best = result;
1893
+ }
1894
+
1895
+ if (!best) {
1896
+ throw new Error(
1897
+ "React Router did not render a usable Cloudflare Pages static shell for any of: " +
1898
+ candidates.join(", "),
1899
+ );
1900
+ }
1901
+
1902
+ console.log(
1903
+ "[deploy] Static shell rendered from " + best.pathname + " (status " + best.status + ").",
1904
+ );
1905
+ fs.writeFileSync(outFile, best.html);
1813
1906
  process.exit(0);
1814
1907
  `,
1815
1908
  );
@@ -0,0 +1,305 @@
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
+
34
+ /** Directories inside an app that are build outputs or dependencies — never
35
+ * build inputs. Everything else in the app dir participates in the hash. */
36
+ const EXCLUDED_APP_DIRS = new Set([
37
+ "node_modules",
38
+ "dist",
39
+ "build",
40
+ ".output",
41
+ ".deploy-tmp",
42
+ ".netlify",
43
+ ".vercel",
44
+ ".react-router",
45
+ ".cache",
46
+ ".turbo",
47
+ ".git",
48
+ ]);
49
+
50
+ /** Ambient process.env prefixes that can be baked into app bundles. */
51
+ const ENV_PREFIXES = ["VITE_", "AGENT_NATIVE_", "WORKSPACE_", "NETLIFY_"];
52
+ const ENV_EXACT_KEYS = [
53
+ "APP_URL",
54
+ "BETTER_AUTH_URL",
55
+ "DATABASE_URL",
56
+ "NITRO_PRESET",
57
+ "NODE_ENV",
58
+ ];
59
+
60
+ const STAMP_DIR = path.join("node_modules", ".cache", "agent-native");
61
+ const STAMP_FILE = "workspace-build.json";
62
+
63
+ export interface WorkspaceBuildCacheOptions {
64
+ workspaceRoot: string;
65
+ appDir: string;
66
+ app: string;
67
+ preset: string;
68
+ /** The exact env block the app build is invoked with (delta over
69
+ * process.env computed by the deploy orchestrator). */
70
+ buildEnv: Record<string, string | undefined>;
71
+ /** Version of the framework package performing the build. */
72
+ builderVersion: string;
73
+ }
74
+
75
+ export function isWorkspaceBuildCacheEnabled(rawArgs: string[]): boolean {
76
+ if (rawArgs.includes("--no-build-cache")) return false;
77
+ const env = process.env.AGENT_NATIVE_WORKSPACE_BUILD_CACHE;
78
+ if (env === "0" || env === "false") return false;
79
+ return true;
80
+ }
81
+
82
+ /** Compute the cache key for one app build. Returns null when hashing is not
83
+ * possible (treat as cache miss — never as a hit). */
84
+ export function computeWorkspaceAppBuildHash(
85
+ opts: WorkspaceBuildCacheOptions,
86
+ ): string | null {
87
+ try {
88
+ const hash = crypto.createHash("sha256");
89
+ hash.update(`builder:${opts.builderVersion}\0`);
90
+ hash.update(`preset:${opts.preset}\0`);
91
+
92
+ // 1. App sources.
93
+ hashDirInto(hash, opts.appDir, opts.appDir);
94
+
95
+ // 2. workspace:* dependency packages (source-level inputs the lockfile
96
+ // does not pin).
97
+ for (const depDir of resolveWorkspaceDepDirs(
98
+ opts.workspaceRoot,
99
+ opts.appDir,
100
+ )) {
101
+ hash.update(`workspace-dep:${path.basename(depDir)}\0`);
102
+ hashDirInto(hash, depDir, depDir);
103
+ }
104
+
105
+ // 3. Root lockfile — pins every registry dependency (framework runtime
106
+ // included), so a core bump or any dep change invalidates all apps.
107
+ for (const lock of ["pnpm-lock.yaml", "package-lock.json", "yarn.lock"]) {
108
+ const lockPath = path.join(opts.workspaceRoot, lock);
109
+ if (fs.existsSync(lockPath)) {
110
+ hash.update(`lock:${lock}\0`);
111
+ hash.update(fs.readFileSync(lockPath));
112
+ hash.update("\0");
113
+ }
114
+ }
115
+
116
+ // 4. Invocation env: the computed per-app block plus ambient bakeable
117
+ // env. Values are hashed, never stored.
118
+ const envEntries: string[] = [];
119
+ for (const [key, value] of Object.entries(opts.buildEnv)) {
120
+ if (value !== undefined) envEntries.push(`${key}=${value}`);
121
+ }
122
+ for (const [key, value] of Object.entries(process.env)) {
123
+ if (value === undefined) continue;
124
+ if (key in opts.buildEnv) continue; // computed block wins
125
+ const relevant =
126
+ ENV_EXACT_KEYS.includes(key) ||
127
+ ENV_PREFIXES.some((p) => key.startsWith(p));
128
+ if (relevant) envEntries.push(`${key}=${value}`);
129
+ }
130
+ envEntries.sort();
131
+ hash.update(`env:${envEntries.join("\n")}\0`);
132
+
133
+ return hash.digest("hex");
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ /** True when the app's previous build can be reused as-is. */
140
+ export function workspaceAppBuildCacheHit(
141
+ opts: WorkspaceBuildCacheOptions,
142
+ hashValue: string | null,
143
+ ): boolean {
144
+ if (!hashValue) return false;
145
+ const stamp = readStamp(opts.appDir);
146
+ if (!stamp || stamp.hash !== hashValue || stamp.preset !== opts.preset) {
147
+ return false;
148
+ }
149
+ return requiredOutputsExist(opts.appDir, opts.preset);
150
+ }
151
+
152
+ /** Record a successful build so the next identical run can skip it. */
153
+ export function writeWorkspaceAppBuildStamp(
154
+ opts: WorkspaceBuildCacheOptions,
155
+ hashValue: string | null,
156
+ ): void {
157
+ if (!hashValue) return;
158
+ try {
159
+ const dir = path.join(opts.appDir, STAMP_DIR);
160
+ fs.mkdirSync(dir, { recursive: true });
161
+ fs.writeFileSync(
162
+ path.join(dir, STAMP_FILE),
163
+ JSON.stringify(
164
+ {
165
+ hash: hashValue,
166
+ preset: opts.preset,
167
+ builderVersion: opts.builderVersion,
168
+ createdAt: new Date().toISOString(),
169
+ },
170
+ null,
171
+ 2,
172
+ ),
173
+ );
174
+ } catch {
175
+ // Best-effort: a missing stamp only costs a rebuild next time.
176
+ }
177
+ }
178
+
179
+ function readStamp(
180
+ appDir: string,
181
+ ): { hash: string; preset: string } | null {
182
+ try {
183
+ const raw = fs.readFileSync(
184
+ path.join(appDir, STAMP_DIR, STAMP_FILE),
185
+ "utf-8",
186
+ );
187
+ const parsed = JSON.parse(raw) as { hash?: unknown; preset?: unknown };
188
+ if (typeof parsed.hash !== "string" || typeof parsed.preset !== "string") {
189
+ return null;
190
+ }
191
+ return { hash: parsed.hash, preset: parsed.preset };
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ function requiredOutputsExist(appDir: string, preset: string): boolean {
198
+ if (preset === "vercel") {
199
+ return fs.existsSync(path.join(appDir, ".vercel", "output"));
200
+ }
201
+ const buildOut =
202
+ fs.existsSync(path.join(appDir, "dist")) ||
203
+ fs.existsSync(path.join(appDir, ".output"));
204
+ if (!buildOut) return false;
205
+ if (preset === "netlify") {
206
+ return fs.existsSync(
207
+ path.join(appDir, ".netlify", "functions-internal", "server"),
208
+ );
209
+ }
210
+ return true;
211
+ }
212
+
213
+ /** Deterministically hash every build-input file under `dir`. */
214
+ function hashDirInto(
215
+ hash: crypto.Hash,
216
+ dir: string,
217
+ rootDir: string,
218
+ ): void {
219
+ let entries: fs.Dirent[];
220
+ try {
221
+ entries = fs.readdirSync(dir, { withFileTypes: true });
222
+ } catch {
223
+ return;
224
+ }
225
+ entries.sort((a, b) => a.name.localeCompare(b.name));
226
+ for (const entry of entries) {
227
+ if (entry.name.startsWith(".DS_")) continue;
228
+ const full = path.join(dir, entry.name);
229
+ if (entry.isDirectory()) {
230
+ if (dir === rootDir && EXCLUDED_APP_DIRS.has(entry.name)) continue;
231
+ // Nested node_modules (e.g. inside checked-in fixtures) are still deps.
232
+ if (entry.name === "node_modules") continue;
233
+ hashDirInto(hash, full, rootDir);
234
+ } else if (entry.isFile()) {
235
+ const rel = path.relative(rootDir, full).split(path.sep).join("/");
236
+ hash.update(`f:${rel}\0`);
237
+ try {
238
+ hash.update(fs.readFileSync(full));
239
+ } catch {
240
+ hash.update("<unreadable>");
241
+ }
242
+ hash.update("\0");
243
+ }
244
+ // Symlinks and other entry types are skipped: their targets are either
245
+ // covered elsewhere (workspace deps) or not build inputs.
246
+ }
247
+ }
248
+
249
+ /** Resolve the app's `workspace:*` dependencies to their package dirs. */
250
+ function resolveWorkspaceDepDirs(
251
+ workspaceRoot: string,
252
+ appDir: string,
253
+ ): string[] {
254
+ const dirs: string[] = [];
255
+ let pkg: {
256
+ dependencies?: Record<string, string>;
257
+ devDependencies?: Record<string, string>;
258
+ };
259
+ try {
260
+ pkg = JSON.parse(
261
+ fs.readFileSync(path.join(appDir, "package.json"), "utf-8"),
262
+ );
263
+ } catch {
264
+ return dirs;
265
+ }
266
+ const workspaceDepNames = Object.entries({
267
+ ...pkg.dependencies,
268
+ ...pkg.devDependencies,
269
+ })
270
+ .filter(([, spec]) => typeof spec === "string" && spec.startsWith("workspace:"))
271
+ .map(([name]) => name);
272
+ if (workspaceDepNames.length === 0) return dirs;
273
+
274
+ const packagesDir = path.join(workspaceRoot, "packages");
275
+ let candidates: string[] = [];
276
+ try {
277
+ candidates = fs
278
+ .readdirSync(packagesDir, { withFileTypes: true })
279
+ .filter((e) => e.isDirectory())
280
+ .map((e) => path.join(packagesDir, e.name));
281
+ } catch {
282
+ return dirs;
283
+ }
284
+ const byName = new Map<string, string>();
285
+ for (const candidate of candidates) {
286
+ try {
287
+ const name = (
288
+ JSON.parse(
289
+ fs.readFileSync(path.join(candidate, "package.json"), "utf-8"),
290
+ ) as { name?: string }
291
+ ).name;
292
+ if (typeof name === "string") byName.set(name, candidate);
293
+ } catch {
294
+ // skip unreadable packages
295
+ }
296
+ }
297
+ for (const depName of workspaceDepNames.sort()) {
298
+ const dir = byName.get(depName);
299
+ if (dir) dirs.push(dir);
300
+ // Unresolvable workspace deps fall back to lockfile coverage; the sorted
301
+ // dep-name list itself is not hashed separately because package.json is
302
+ // already part of the app-source hash.
303
+ }
304
+ return dirs;
305
+ }
@@ -17,9 +17,16 @@
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
 
21
22
  import { AGENT_CHAT_PROCESS_RUN_PATH } from "../agent/durable-background.js";
22
23
  import { findWorkspaceRoot } from "../scripts/utils.js";
24
+ import {
25
+ computeWorkspaceAppBuildHash,
26
+ isWorkspaceBuildCacheEnabled,
27
+ workspaceAppBuildCacheHit,
28
+ writeWorkspaceAppBuildStamp,
29
+ } from "./workspace-build-cache.js";
23
30
  import {
24
31
  DEFAULT_WORKSPACE_APP_AUDIENCE,
25
32
  normalizeWorkspaceAppAudience,
@@ -64,6 +71,19 @@ const WORKSPACE_APPS_MANIFEST_DIR = ".agent-native";
64
71
  const WORKSPACE_APPS_MANIFEST_FILE = "workspace-apps.json";
65
72
  const VERCEL_OUTPUT_DIR = ".vercel/output";
66
73
 
74
+ // Version of this package — folds into the build-cache key so upgrading the
75
+ // framework invalidates every cached app build.
76
+ let BUILDER_VERSION = "unknown";
77
+ try {
78
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
79
+ // dist/deploy/workspace-deploy.js → ../../package.json
80
+ BUILDER_VERSION = (
81
+ JSON.parse(
82
+ fs.readFileSync(path.resolve(__dirname, "../../package.json"), "utf-8"),
83
+ ) as { version?: string }
84
+ ).version ?? "unknown";
85
+ } catch {}
86
+
67
87
  interface WorkspaceAppManifestEntry {
68
88
  id: string;
69
89
  name: string;
@@ -152,9 +172,24 @@ export async function runWorkspaceDeploy(
152
172
  `[workspace-deploy] Building ${apps.length} app(s) for preset=${preset}`,
153
173
  );
154
174
 
175
+ const buildCacheEnabled = isWorkspaceBuildCacheEnabled(rawArgs);
176
+ if (!buildCacheEnabled) {
177
+ console.log(
178
+ `[workspace-deploy] Build cache disabled — rebuilding every app.`,
179
+ );
180
+ }
181
+ let cachedCount = 0;
155
182
  const execFile = opts.execFile ?? execFileSync;
156
183
  for (const app of apps) {
157
- buildOneApp(workspaceRoot, app, preset, execFile, workspaceApps);
184
+ const skipped = buildOneApp(
185
+ workspaceRoot,
186
+ app,
187
+ preset,
188
+ execFile,
189
+ workspaceApps,
190
+ buildCacheEnabled,
191
+ );
192
+ if (skipped) cachedCount++;
158
193
  moveAppBuildIntoWorkspaceOutput(
159
194
  workspaceRoot,
160
195
  app,
@@ -164,6 +199,11 @@ export async function runWorkspaceDeploy(
164
199
  workspaceApps,
165
200
  );
166
201
  }
202
+ if (buildCacheEnabled && cachedCount > 0) {
203
+ console.log(
204
+ `[workspace-deploy] Build cache: reused ${cachedCount}/${apps.length} unchanged app build(s), rebuilt ${apps.length - cachedCount}.`,
205
+ );
206
+ }
167
207
  writeWorkspaceAppManifests(
168
208
  workspaceRoot,
169
209
  distDir,
@@ -212,7 +252,8 @@ function buildOneApp(
212
252
  preset: WorkspaceDeployPreset,
213
253
  execFile: typeof execFileSync,
214
254
  workspaceApps: WorkspaceAppManifestEntry[],
215
- ): void {
255
+ buildCacheEnabled: boolean,
256
+ ): boolean {
216
257
  const appDir = path.join(workspaceRoot, "apps", app);
217
258
  const workspaceAppAudience = workspaceAppAudienceForApp(workspaceApps, app);
218
259
  const workspaceAppRouteAccess = workspaceAppRouteAccessForApp(
@@ -277,6 +318,27 @@ function buildOneApp(
277
318
  env.DATABASE_URL;
278
319
  }
279
320
 
321
+ // Content-hash cache: when the app's sources, workspace deps, lockfile,
322
+ // and invocation env are byte-identical to the previous successful build
323
+ // (and its output still exists), reuse it instead of rebuilding.
324
+ const cacheOpts = {
325
+ workspaceRoot,
326
+ appDir,
327
+ app,
328
+ preset,
329
+ buildEnv: buildCacheEnvDelta(env),
330
+ builderVersion: BUILDER_VERSION,
331
+ };
332
+ const buildHash = buildCacheEnabled
333
+ ? computeWorkspaceAppBuildHash(cacheOpts)
334
+ : null;
335
+ if (buildCacheEnabled && workspaceAppBuildCacheHit(cacheOpts, buildHash)) {
336
+ console.log(
337
+ `[workspace-deploy] ${app} unchanged — reusing cached build (base=/${app}, preset=${preset})`,
338
+ );
339
+ return true;
340
+ }
341
+
280
342
  console.log(
281
343
  `[workspace-deploy] Building ${app} (base=/${app}, preset=${preset})`,
282
344
  );
@@ -298,7 +360,10 @@ function buildOneApp(
298
360
  env,
299
361
  stdio: "inherit",
300
362
  });
301
- return;
363
+ if (buildCacheEnabled) {
364
+ writeWorkspaceAppBuildStamp(cacheOpts, buildHash);
365
+ }
366
+ return false;
302
367
  } catch (error) {
303
368
  const status = (error as { status?: number | null })?.status ?? null;
304
369
  const isNativeCrash =
@@ -1123,6 +1188,19 @@ function netlifyFunctionsDir(workspaceRoot: string): string {
1123
1188
  return path.join(workspaceRoot, ".netlify", "functions-internal");
1124
1189
  }
1125
1190
 
1191
+ /** The per-app build env is `{...process.env, ...computed}` — only the
1192
+ * computed delta belongs in the cache key (ambient env is covered by the
1193
+ * cache module's own prefix filter, machine noise like PATH stays out). */
1194
+ function buildCacheEnvDelta(
1195
+ env: NodeJS.ProcessEnv,
1196
+ ): Record<string, string | undefined> {
1197
+ const delta: Record<string, string | undefined> = {};
1198
+ for (const [key, value] of Object.entries(env)) {
1199
+ if (process.env[key] !== value) delta[key] = value;
1200
+ }
1201
+ return delta;
1202
+ }
1203
+
1126
1204
  function cleanAppBuildOutputs(appDir: string): void {
1127
1205
  // .deploy-tmp: a crashed prior per-app build leaves partial artifacts that
1128
1206
  // silently poison the next build for that app (observed on Windows: stale
@@ -1923,15 +1923,18 @@ function ssrStubPlugin(packages: string[]): Plugin | null {
1923
1923
  load(id) {
1924
1924
  if (id !== STUB_ID) return null;
1925
1925
  // Proxy that answers any property access with itself — lets dead
1926
- // import/re-export chains parse without blowing up, and still throws
1927
- // if code actually tries to call any of it on the server.
1926
+ // import/re-export chains parse without blowing up. Callable and
1927
+ // constructible (module-scope `new PluginKey(...)` in stubbed client
1928
+ // code must not crash server-side shell renders); every call returns
1929
+ // another stub.
1928
1930
  return (
1929
1931
  "const handler = { get(_, p) { " +
1930
1932
  "if (p === Symbol.toPrimitive) return () => ''; " +
1931
1933
  "if (p === 'then') return undefined; " +
1932
- "return new Proxy(() => {}, handler); " +
1933
- "} };" +
1934
- "const stub = new Proxy(() => {}, handler);" +
1934
+ "return new Proxy(function () {}, handler); " +
1935
+ "}, apply() { return new Proxy(function () {}, handler); }, " +
1936
+ "construct() { return new Proxy(function () {}, handler); } };" +
1937
+ "const stub = new Proxy(function () {}, handler);" +
1935
1938
  "export default stub;" +
1936
1939
  namedExports.map((name) => `export const ${name} = stub;`).join("")
1937
1940
  );
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-12
4
+ ---
5
+
6
+ Image uploads now work on serverless runtimes without native image processing - dimensions are read from file headers and thumbnails fall back to the original image