@jami-studio/core 0.92.35 → 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.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +12 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/deploy/route-discovery.ts +26 -0
- package/corpus/core/src/deploy/workspace-build-cache.ts +305 -0
- package/corpus/core/src/deploy/workspace-deploy.ts +81 -3
- package/corpus/templates/calendar/app/root.tsx +4 -1
- package/corpus/templates/clips/app/root.tsx +4 -1
- package/corpus/templates/content/app/root.tsx +4 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/deploy/route-discovery.d.ts +7 -0
- package/dist/deploy/route-discovery.d.ts.map +1 -1
- package/dist/deploy/route-discovery.js +29 -0
- package/dist/deploy/route-discovery.js.map +1 -1
- package/dist/deploy/workspace-build-cache.d.ts +20 -0
- package/dist/deploy/workspace-build-cache.d.ts.map +1 -0
- package/dist/deploy/workspace-build-cache.js +258 -0
- package/dist/deploy/workspace-build-cache.js.map +1 -0
- package/dist/deploy/workspace-deploy.d.ts.map +1 -1
- package/dist/deploy/workspace-deploy.js +56 -3
- package/dist/deploy/workspace-deploy.js.map +1 -1
- package/dist/observability/routes.d.ts +5 -5
- package/dist/progress/routes.d.ts +1 -1
- package/package.json +1 -1
package/corpus/README.md
CHANGED
package/corpus/core/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @agent-native/core
|
|
2
2
|
|
|
3
|
+
## 0.92.37
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 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`.
|
|
8
|
+
|
|
9
|
+
## 0.92.36
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- dc8c05b: Serverless route discovery now mounts TOP-LEVEL `server/routes/*` files (e.g. analytics `track.post.ts` → `POST /track`) in the generated worker entry — previously only the `/api` and `/_agent-native` subtrees were scanned, so ingest routes living outside `/api` 404'd on every serverless deploy. Page catch-alls (`[...page].get.ts`) stay unmounted (the static app shell owns page serving), and non-api subdirectories are unchanged.
|
|
14
|
+
|
|
3
15
|
## 0.92.35
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/corpus/core/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.92.
|
|
3
|
+
"version": "0.92.37",
|
|
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": {
|
|
@@ -88,15 +88,41 @@ export interface DiscoveredRoute {
|
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
90
|
* Discover all API routes in a project's server/routes/ directory.
|
|
91
|
+
*
|
|
92
|
+
* Scans the `/api` and `/_agent-native` subtrees plus TOP-LEVEL route files
|
|
93
|
+
* (e.g. analytics `track.post.ts` → `POST /track`, an ingest endpoint that
|
|
94
|
+
* deliberately lives outside `/api`). Top-level catch-alls (`[...page].get.ts`
|
|
95
|
+
* — the SSR page fallback every template ships) are excluded: on serverless
|
|
96
|
+
* workers pages are served by the static app shell, and mounting the
|
|
97
|
+
* catch-all would shadow it.
|
|
91
98
|
*/
|
|
92
99
|
export async function discoverApiRoutes(
|
|
93
100
|
cwd: string,
|
|
94
101
|
): Promise<DiscoveredRoute[]> {
|
|
102
|
+
const fs = await getFs();
|
|
103
|
+
const routesDir = path.join(cwd, "server/routes");
|
|
95
104
|
const apiDir = path.join(cwd, "server/routes/api");
|
|
96
105
|
const agentNativeDir = path.join(cwd, "server/routes/_agent-native");
|
|
106
|
+
const topLevelFiles: string[] = [];
|
|
107
|
+
try {
|
|
108
|
+
if (fs.existsSync(routesDir)) {
|
|
109
|
+
for (const entry of fs.readdirSync(routesDir, { withFileTypes: true })) {
|
|
110
|
+
if (entry.isDirectory()) continue;
|
|
111
|
+
if (!entry.name.endsWith(".ts") && !entry.name.endsWith(".js")) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
// Skip page catch-alls — the static shell owns page serving.
|
|
115
|
+
if (entry.name.startsWith("[...")) continue;
|
|
116
|
+
topLevelFiles.push(entry.name);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// Edge runtime — no filesystem.
|
|
121
|
+
}
|
|
97
122
|
const routeFiles = [
|
|
98
123
|
...(await discoverFiles(apiDir, "api")),
|
|
99
124
|
...(await discoverFiles(agentNativeDir, "_agent-native")),
|
|
125
|
+
...topLevelFiles,
|
|
100
126
|
];
|
|
101
127
|
const routes: DiscoveredRoute[] = [];
|
|
102
128
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -239,7 +239,10 @@ export default function Root() {
|
|
|
239
239
|
}),
|
|
240
240
|
);
|
|
241
241
|
const location = useLocation();
|
|
242
|
-
|
|
242
|
+
// Static-shell serverless deployments never run the root loader (the
|
|
243
|
+
// client manifest strips hasLoader), so loader data can be null at
|
|
244
|
+
// hydration — fall back exactly like Layout does.
|
|
245
|
+
const loaderData = useLoaderData<typeof loader>() ?? DEFAULT_LOADER_DATA;
|
|
243
246
|
const isPublicPath = isPublicBookingPath(location.pathname);
|
|
244
247
|
|
|
245
248
|
return (
|
|
@@ -379,7 +379,10 @@ function AppContent() {
|
|
|
379
379
|
*/
|
|
380
380
|
export default function Root() {
|
|
381
381
|
const location = useLocation();
|
|
382
|
-
|
|
382
|
+
// Static-shell serverless deployments never run the root loader (the
|
|
383
|
+
// client manifest strips hasLoader), so loader data can be null at
|
|
384
|
+
// hydration — fall back exactly like Layout does.
|
|
385
|
+
const loaderData = useLoaderData<typeof loader>() ?? DEFAULT_LOADER_DATA;
|
|
383
386
|
const [queryClient] = useState(() => createAgentNativeQueryClient());
|
|
384
387
|
return (
|
|
385
388
|
<AppToolkitProvider>
|
|
@@ -547,7 +547,10 @@ export default function Root() {
|
|
|
547
547
|
const [queryClient] = useState(() => createAgentNativeQueryClient());
|
|
548
548
|
const [cmdkOpen, setCmdkOpen] = useState(false);
|
|
549
549
|
const location = useLocation();
|
|
550
|
-
|
|
550
|
+
// Static-shell serverless deployments never run the root loader (the
|
|
551
|
+
// client manifest strips hasLoader), so loader data can be null at
|
|
552
|
+
// hydration — fall back exactly like Layout does.
|
|
553
|
+
const loaderData = useLoaderData<typeof loader>() ?? DEFAULT_LOADER_DATA;
|
|
551
554
|
useCommandMenuShortcut(
|
|
552
555
|
useCallback(() => setCmdkOpen(true), []),
|
|
553
556
|
{
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* Body: { json: any, fieldName?: string, type?: "map"|"array", requestSource?: string }
|
|
14
14
|
*/
|
|
15
15
|
export declare const postCollabJson: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
16
|
-
ok?: undefined;
|
|
17
16
|
error: string;
|
|
17
|
+
ok?: undefined;
|
|
18
18
|
} | {
|
|
19
19
|
error?: undefined;
|
|
20
20
|
ok: boolean;
|
|
@@ -25,6 +25,13 @@ export interface DiscoveredRoute {
|
|
|
25
25
|
}
|
|
26
26
|
/**
|
|
27
27
|
* Discover all API routes in a project's server/routes/ directory.
|
|
28
|
+
*
|
|
29
|
+
* Scans the `/api` and `/_agent-native` subtrees plus TOP-LEVEL route files
|
|
30
|
+
* (e.g. analytics `track.post.ts` → `POST /track`, an ingest endpoint that
|
|
31
|
+
* deliberately lives outside `/api`). Top-level catch-alls (`[...page].get.ts`
|
|
32
|
+
* — the SSR page fallback every template ships) are excluded: on serverless
|
|
33
|
+
* workers pages are served by the static app shell, and mounting the
|
|
34
|
+
* catch-all would shadow it.
|
|
28
35
|
*/
|
|
29
36
|
export declare function discoverApiRoutes(cwd: string): Promise<DiscoveredRoute[]>;
|
|
30
37
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"route-discovery.d.ts","sourceRoot":"","sources":["../../src/deploy/route-discovery.ts"],"names":[],"mappings":"AAYA;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG;IAC/C,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACf,GAAG,IAAI,CA2BP;AAED;;GAEG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,MAAM,EACX,MAAM,SAAK,GACV,OAAO,CAAC,MAAM,EAAE,CAAC,CAkBnB;AAED,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,wCAAwC;IACxC,QAAQ,EAAE,MAAM,CAAC;IACjB,4BAA4B;IAC5B,OAAO,EAAE,MAAM,CAAC;CACjB;AAED
|
|
1
|
+
{"version":3,"file":"route-discovery.d.ts","sourceRoot":"","sources":["../../src/deploy/route-discovery.ts"],"names":[],"mappings":"AAYA;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG;IAC/C,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACf,GAAG,IAAI,CA2BP;AAED;;GAEG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,MAAM,EACX,MAAM,SAAK,GACV,OAAO,CAAC,MAAM,EAAE,CAAC,CAkBnB;AAED,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,wCAAwC;IACxC,QAAQ,EAAE,MAAM,CAAC;IACjB,4BAA4B;IAC5B,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;GASG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,eAAe,EAAE,CAAC,CAuC5B;AAED;;GAEG;AACH,wBAAsB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAapE;AASD;;;GAGG;AACH,eAAO,MAAM,uBAAuB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAY1D,CAAC;AAWF,MAAM,WAAW,gBAAgB;IAC/B,+CAA+C;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb,uCAAuC;IACvC,OAAO,EAAE,MAAM,CAAC;IAChB,kEAAkE;IAClE,MAAM,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAaD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,MAAM,GACd,KAAK,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAmB3C;AAyKD;;;;;;;;GAQG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAoB7B;AAED;;GAEG;AACH,wBAAsB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAmB7E"}
|
|
@@ -67,13 +67,42 @@ export async function discoverFiles(dir, prefix = "") {
|
|
|
67
67
|
}
|
|
68
68
|
/**
|
|
69
69
|
* Discover all API routes in a project's server/routes/ directory.
|
|
70
|
+
*
|
|
71
|
+
* Scans the `/api` and `/_agent-native` subtrees plus TOP-LEVEL route files
|
|
72
|
+
* (e.g. analytics `track.post.ts` → `POST /track`, an ingest endpoint that
|
|
73
|
+
* deliberately lives outside `/api`). Top-level catch-alls (`[...page].get.ts`
|
|
74
|
+
* — the SSR page fallback every template ships) are excluded: on serverless
|
|
75
|
+
* workers pages are served by the static app shell, and mounting the
|
|
76
|
+
* catch-all would shadow it.
|
|
70
77
|
*/
|
|
71
78
|
export async function discoverApiRoutes(cwd) {
|
|
79
|
+
const fs = await getFs();
|
|
80
|
+
const routesDir = path.join(cwd, "server/routes");
|
|
72
81
|
const apiDir = path.join(cwd, "server/routes/api");
|
|
73
82
|
const agentNativeDir = path.join(cwd, "server/routes/_agent-native");
|
|
83
|
+
const topLevelFiles = [];
|
|
84
|
+
try {
|
|
85
|
+
if (fs.existsSync(routesDir)) {
|
|
86
|
+
for (const entry of fs.readdirSync(routesDir, { withFileTypes: true })) {
|
|
87
|
+
if (entry.isDirectory())
|
|
88
|
+
continue;
|
|
89
|
+
if (!entry.name.endsWith(".ts") && !entry.name.endsWith(".js")) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
// Skip page catch-alls — the static shell owns page serving.
|
|
93
|
+
if (entry.name.startsWith("[..."))
|
|
94
|
+
continue;
|
|
95
|
+
topLevelFiles.push(entry.name);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Edge runtime — no filesystem.
|
|
101
|
+
}
|
|
74
102
|
const routeFiles = [
|
|
75
103
|
...(await discoverFiles(apiDir, "api")),
|
|
76
104
|
...(await discoverFiles(agentNativeDir, "_agent-native")),
|
|
105
|
+
...topLevelFiles,
|
|
77
106
|
];
|
|
78
107
|
const routes = [];
|
|
79
108
|
for (const relFile of routeFiles) {
|