Skip to content

Commit 64cc995

Browse files
committed
feat(cli): implements content-addressable store for the dev CLI build outputs, reducing disk usage
1 parent 9f27422 commit 64cc995

File tree

8 files changed

+192
-16
lines changed

8 files changed

+192
-16
lines changed

.changeset/polite-eels-divide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"trigger.dev": patch
3+
---
4+
5+
feat(cli): implements content-addressable store for the dev CLI build outputs, reducing disk usage

packages/cli-v3/src/build/bundle.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { DEFAULT_RUNTIME, ResolvedConfig } from "@trigger.dev/core/v3/build";
33
import { BuildManifest, BuildTarget, TaskFile } from "@trigger.dev/core/v3/schemas";
44
import * as esbuild from "esbuild";
55
import { createHash } from "node:crypto";
6-
import { join, relative, resolve } from "node:path";
7-
import { createFile } from "../utilities/fileSystem.js";
6+
import { basename, join, relative, resolve } from "node:path";
7+
import { createFile, createFileWithStore } from "../utilities/fileSystem.js";
88
import { logger } from "../utilities/logger.js";
99
import { resolveFileSources } from "../utilities/sourceFiles.js";
1010
import { VERSION } from "../version.js";
@@ -37,6 +37,8 @@ export interface BundleOptions {
3737
jsxAutomatic?: boolean;
3838
watch?: boolean;
3939
plugins?: esbuild.Plugin[];
40+
/** Shared store directory for deduplicating chunk files via hardlinks */
41+
storeDir?: string;
4042
}
4143

4244
export type BundleResult = {
@@ -51,6 +53,8 @@ export type BundleResult = {
5153
indexControllerEntryPoint: string | undefined;
5254
initEntryPoint: string | undefined;
5355
stop: (() => Promise<void>) | undefined;
56+
/** Maps output file paths to their content hashes for deduplication */
57+
outputHashes: Record<string, string>;
5458
};
5559

5660
export class BundleError extends Error {
@@ -159,7 +163,8 @@ export async function bundleWorker(options: BundleOptions): Promise<BundleResult
159163
options.target,
160164
options.cwd,
161165
options.resolvedConfig,
162-
result
166+
result,
167+
options.storeDir
163168
);
164169

165170
if (!bundleResult) {
@@ -233,14 +238,23 @@ export async function getBundleResultFromBuild(
233238
target: BuildTarget,
234239
workingDir: string,
235240
resolvedConfig: ResolvedConfig,
236-
result: esbuild.BuildResult<{ metafile: true; write: false }>
241+
result: esbuild.BuildResult<{ metafile: true; write: false }>,
242+
storeDir?: string
237243
): Promise<Omit<BundleResult, "stop"> | undefined> {
238244
const hasher = createHash("md5");
245+
const outputHashes: Record<string, string> = {};
239246

240247
for (const outputFile of result.outputFiles) {
241248
hasher.update(outputFile.hash);
242-
243-
await createFile(outputFile.path, outputFile.contents);
249+
// Store the hash for each output file (keyed by path)
250+
outputHashes[outputFile.path] = outputFile.hash;
251+
252+
if (storeDir) {
253+
// Use content-addressable store with esbuild's built-in hash for ALL files
254+
await createFileWithStore(outputFile.path, outputFile.contents, storeDir, outputFile.hash);
255+
} else {
256+
await createFile(outputFile.path, outputFile.contents);
257+
}
244258
}
245259

246260
const files: Array<{ entry: string; out: string }> = [];
@@ -308,6 +322,7 @@ export async function getBundleResultFromBuild(
308322
initEntryPoint,
309323
contentHash: hasher.digest("hex"),
310324
metafile: result.metafile,
325+
outputHashes,
311326
};
312327
}
313328

@@ -354,6 +369,7 @@ export async function createBuildManifestFromBundle({
354369
target,
355370
envVars,
356371
sdkVersion,
372+
storeDir,
357373
}: {
358374
bundle: BundleResult;
359375
destination: string;
@@ -364,6 +380,7 @@ export async function createBuildManifestFromBundle({
364380
target: BuildTarget;
365381
envVars?: Record<string, string>;
366382
sdkVersion?: string;
383+
storeDir?: string;
367384
}): Promise<BuildManifest> {
368385
const buildManifest: BuildManifest = {
369386
contentHash: bundle.contentHash,
@@ -397,11 +414,12 @@ export async function createBuildManifestFromBundle({
397414
otelImportHook: {
398415
include: resolvedConfig.instrumentedPackageNames ?? [],
399416
},
417+
outputHashes: bundle.outputHashes,
400418
};
401419

402420
if (!workerDir) {
403421
return buildManifest;
404422
}
405423

406-
return copyManifestToDir(buildManifest, destination, workerDir);
424+
return copyManifestToDir(buildManifest, destination, workerDir, storeDir);
407425
}

packages/cli-v3/src/build/manifests.ts

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,25 @@
11
import { BuildManifest } from "@trigger.dev/core/v3/schemas";
2-
import { cp } from "node:fs/promises";
2+
import { cp, link, mkdir, readdir, readFile } from "node:fs/promises";
3+
import { createHash } from "node:crypto";
4+
import { existsSync } from "node:fs";
5+
import { join } from "node:path";
36
import { logger } from "../utilities/logger.js";
47

58
export async function copyManifestToDir(
69
manifest: BuildManifest,
710
source: string,
8-
destination: string
11+
destination: string,
12+
storeDir?: string
913
): Promise<BuildManifest> {
10-
// Copy the dir in destination to workerDir
11-
await cp(source, destination, { recursive: true });
14+
// Copy the dir from source to destination
15+
// If storeDir is provided, create hardlinks for files that exist in the store
16+
if (storeDir) {
17+
await copyDirWithStore(source, destination, storeDir, manifest.outputHashes);
18+
} else {
19+
await cp(source, destination, { recursive: true });
20+
}
1221

13-
logger.debug("Copied manifest to dir", { source, destination });
22+
logger.debug("Copied manifest to dir", { source, destination, storeDir });
1423

1524
// Then update the manifest to point to the new workerDir
1625
const updatedManifest = { ...manifest };
@@ -37,3 +46,67 @@ export async function copyManifestToDir(
3746

3847
return updatedManifest;
3948
}
49+
50+
/**
51+
* Sanitizes a hash to be safe for use as a filename.
52+
* esbuild's hashes are base64-encoded and may contain `/` and `+` characters.
53+
*/
54+
function sanitizeHashForFilename(hash: string): string {
55+
return hash.replace(/\//g, "_").replace(/\+/g, "-");
56+
}
57+
58+
/**
59+
* Computes a hash of file contents to use as content-addressable key.
60+
* This is a fallback for when outputHashes is not available.
61+
*/
62+
async function computeFileHash(filePath: string): Promise<string> {
63+
const contents = await readFile(filePath);
64+
return createHash("sha256").update(contents).digest("hex").slice(0, 16);
65+
}
66+
67+
/**
68+
* Recursively copies a directory, using hardlinks for files that exist in the store.
69+
* This preserves disk space savings from the content-addressable store.
70+
*
71+
* @param source - Source directory path
72+
* @param destination - Destination directory path
73+
* @param storeDir - Content-addressable store directory
74+
* @param outputHashes - Optional map of file paths to their content hashes (from BuildManifest)
75+
*/
76+
async function copyDirWithStore(
77+
source: string,
78+
destination: string,
79+
storeDir: string,
80+
outputHashes?: Record<string, string>
81+
): Promise<void> {
82+
await mkdir(destination, { recursive: true });
83+
84+
const entries = await readdir(source, { withFileTypes: true });
85+
86+
for (const entry of entries) {
87+
const sourcePath = join(source, entry.name);
88+
const destPath = join(destination, entry.name);
89+
90+
if (entry.isDirectory()) {
91+
// Recursively copy subdirectories
92+
await copyDirWithStore(sourcePath, destPath, storeDir, outputHashes);
93+
} else if (entry.isFile()) {
94+
// Try to get hash from manifest first, otherwise compute it
95+
const contentHash = outputHashes?.[sourcePath] ?? (await computeFileHash(sourcePath));
96+
// Sanitize hash to be filesystem-safe (base64 can contain / and +)
97+
const safeHash = sanitizeHashForFilename(contentHash);
98+
const storePath = join(storeDir, safeHash);
99+
100+
if (existsSync(storePath)) {
101+
// Create hardlink to store file
102+
await link(storePath, destPath);
103+
} else {
104+
// File wasn't in the store - copy normally
105+
await cp(sourcePath, destPath);
106+
}
107+
} else if (entry.isSymbolicLink()) {
108+
// Preserve symbolic links (e.g., node_modules links)
109+
await cp(sourcePath, destPath, { verbatimSymlinks: true });
110+
}
111+
}
112+
}

packages/cli-v3/src/dev/devSession.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@ import { createExternalsBuildExtension, resolveAlwaysExternal } from "../build/e
2020
import { type DevCommandOptions } from "../commands/dev.js";
2121
import { eventBus } from "../utilities/eventBus.js";
2222
import { logger } from "../utilities/logger.js";
23-
import { clearTmpDirs, EphemeralDirectory, getTmpDir } from "../utilities/tempDirectories.js";
23+
import {
24+
clearTmpDirs,
25+
EphemeralDirectory,
26+
getStoreDir,
27+
getTmpDir,
28+
} from "../utilities/tempDirectories.js";
2429
import { startDevOutput } from "./devOutput.js";
2530
import { startWorkerRuntime } from "./devSupervisor.js";
2631
import { startMcpServer, stopMcpServer } from "./mcpServer.js";
@@ -53,6 +58,8 @@ export async function startDevSession({
5358
}: DevSessionOptions): Promise<DevSessionInstance> {
5459
clearTmpDirs(rawConfig.workingDir);
5560
const destination = getTmpDir(rawConfig.workingDir, "build", keepTmpFiles);
61+
// Create shared store directory for deduplicating chunk files across rebuilds
62+
const storeDir = getStoreDir(rawConfig.workingDir);
5663

5764
const runtime = await startWorkerRuntime({
5865
name,
@@ -102,6 +109,7 @@ export async function startDevSession({
102109
workerDir: workerDir?.path,
103110
environment: "dev",
104111
target: "dev",
112+
storeDir,
105113
});
106114

107115
logger.debug("Created build manifest from bundle", { buildManifest });
@@ -131,7 +139,13 @@ export async function startDevSession({
131139
}
132140

133141
async function updateBuild(build: esbuild.BuildResult, workerDir: EphemeralDirectory) {
134-
const bundle = await getBundleResultFromBuild("dev", rawConfig.workingDir, rawConfig, build);
142+
const bundle = await getBundleResultFromBuild(
143+
"dev",
144+
rawConfig.workingDir,
145+
rawConfig,
146+
build,
147+
storeDir
148+
);
135149

136150
if (bundle) {
137151
await updateBundle({ ...bundle, stop: undefined }, workerDir);
@@ -190,6 +204,7 @@ export async function startDevSession({
190204
jsxFactory: rawConfig.build.jsx.factory,
191205
jsxFragment: rawConfig.build.jsx.fragment,
192206
jsxAutomatic: rawConfig.build.jsx.automatic,
207+
storeDir,
193208
});
194209

195210
await updateBundle(bundleResult);

packages/cli-v3/src/utilities/fileSystem.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,59 @@ export async function createFile(
1616
return path;
1717
}
1818

19+
/**
20+
* Sanitizes a hash to be safe for use as a filename.
21+
* esbuild's hashes are base64-encoded and may contain `/` and `+` characters.
22+
*/
23+
function sanitizeHashForFilename(hash: string): string {
24+
return hash.replace(/\//g, "_").replace(/\+/g, "-");
25+
}
26+
27+
/**
28+
* Creates a file using a content-addressable store for deduplication.
29+
* Files are stored by their content hash, so identical content is only stored once.
30+
* The build directory gets a hardlink to the stored file.
31+
*
32+
* @param filePath - The destination path for the file
33+
* @param contents - The file contents to write
34+
* @param storeDir - The shared store directory for deduplication
35+
* @param contentHash - The content hash (e.g., from esbuild's outputFile.hash)
36+
* @returns The destination file path
37+
*/
38+
export async function createFileWithStore(
39+
filePath: string,
40+
contents: string | NodeJS.ArrayBufferView,
41+
storeDir: string,
42+
contentHash: string
43+
): Promise<string> {
44+
// Sanitize hash to be filesystem-safe (base64 can contain / and +)
45+
const safeHash = sanitizeHashForFilename(contentHash);
46+
// Store files by their content hash for true content-addressable storage
47+
const storePath = pathModule.join(storeDir, safeHash);
48+
49+
// Ensure build directory exists
50+
await fsModule.mkdir(pathModule.dirname(filePath), { recursive: true });
51+
52+
// Remove existing file at destination if it exists (hardlinks fail on existing files)
53+
if (fsSync.existsSync(filePath)) {
54+
await fsModule.unlink(filePath);
55+
}
56+
57+
// Check if content already exists in store by hash
58+
if (fsSync.existsSync(storePath)) {
59+
// Create hardlink from build path to store path
60+
await fsModule.link(storePath, filePath);
61+
return filePath;
62+
}
63+
64+
// Write to store first (using hash as filename)
65+
await fsModule.writeFile(storePath, contents);
66+
// Create hardlink in build directory (with original filename)
67+
await fsModule.link(storePath, filePath);
68+
69+
return filePath;
70+
}
71+
1972
export function isDirectory(configPath: string) {
2073
try {
2174
return fs.statSync(configPath).isDirectory();

packages/cli-v3/src/utilities/tempDirectories.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,15 @@ export function clearTmpDirs(projectRoot: string | undefined) {
5858
// This sometimes fails on Windows with EBUSY
5959
}
6060
}
61+
62+
/**
63+
* Gets the shared store directory for content-addressable build outputs.
64+
* This directory persists across rebuilds and is used to deduplicate
65+
* identical chunk files between build versions.
66+
*/
67+
export function getStoreDir(projectRoot: string | undefined): string {
68+
projectRoot ??= process.cwd();
69+
const storeDir = path.join(projectRoot, ".trigger", "tmp", "store");
70+
fs.mkdirSync(storeDir, { recursive: true });
71+
return storeDir;
72+
}

packages/core/src/v3/schemas/build.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ export const BuildManifest = z.object({
6868
exclude: z.array(z.string()).optional(),
6969
})
7070
.optional(),
71+
/** Maps output file paths to their content hashes for deduplication during dev */
72+
outputHashes: z.record(z.string()).optional(),
7173
});
7274

7375
export type BuildManifest = z.infer<typeof BuildManifest>;

references/d3-chat/src/trigger/chat.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,6 @@ export const todoChat = schemaTask({
199199
run: async ({ input, userId }, { signal }) => {
200200
metadata.set("user_id", userId);
201201

202-
logger.info("todoChat: starting", { input, userId });
203-
204202
const system = `
205203
You are a SQL (postgres) expert who can turn natural language descriptions for a todo app
206204
into a SQL query which can then be executed against a SQL database. Here is the schema:

0 commit comments

Comments
 (0)