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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/polite-eels-divide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---

feat(cli): implements content-addressable store for the dev CLI build outputs, reducing disk usage
32 changes: 25 additions & 7 deletions packages/cli-v3/src/build/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { DEFAULT_RUNTIME, ResolvedConfig } from "@trigger.dev/core/v3/build";
import { BuildManifest, BuildTarget, TaskFile } from "@trigger.dev/core/v3/schemas";
import * as esbuild from "esbuild";
import { createHash } from "node:crypto";
import { join, relative, resolve } from "node:path";
import { createFile } from "../utilities/fileSystem.js";
import { basename, join, relative, resolve } from "node:path";
import { createFile, createFileWithStore } from "../utilities/fileSystem.js";
import { logger } from "../utilities/logger.js";
import { resolveFileSources } from "../utilities/sourceFiles.js";
import { VERSION } from "../version.js";
Expand Down Expand Up @@ -37,6 +37,8 @@ export interface BundleOptions {
jsxAutomatic?: boolean;
watch?: boolean;
plugins?: esbuild.Plugin[];
/** Shared store directory for deduplicating chunk files via hardlinks */
storeDir?: string;
}

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

export class BundleError extends Error {
Expand Down Expand Up @@ -159,7 +163,8 @@ export async function bundleWorker(options: BundleOptions): Promise<BundleResult
options.target,
options.cwd,
options.resolvedConfig,
result
result,
options.storeDir
);

if (!bundleResult) {
Expand Down Expand Up @@ -233,14 +238,23 @@ export async function getBundleResultFromBuild(
target: BuildTarget,
workingDir: string,
resolvedConfig: ResolvedConfig,
result: esbuild.BuildResult<{ metafile: true; write: false }>
result: esbuild.BuildResult<{ metafile: true; write: false }>,
storeDir?: string
): Promise<Omit<BundleResult, "stop"> | undefined> {
const hasher = createHash("md5");
const outputHashes: Record<string, string> = {};

for (const outputFile of result.outputFiles) {
hasher.update(outputFile.hash);

await createFile(outputFile.path, outputFile.contents);
// Store the hash for each output file (keyed by path)
outputHashes[outputFile.path] = outputFile.hash;

if (storeDir) {
// Use content-addressable store with esbuild's built-in hash for ALL files
await createFileWithStore(outputFile.path, outputFile.contents, storeDir, outputFile.hash);
} else {
await createFile(outputFile.path, outputFile.contents);
}
}

const files: Array<{ entry: string; out: string }> = [];
Expand Down Expand Up @@ -308,6 +322,7 @@ export async function getBundleResultFromBuild(
initEntryPoint,
contentHash: hasher.digest("hex"),
metafile: result.metafile,
outputHashes,
};
}

Expand Down Expand Up @@ -354,6 +369,7 @@ export async function createBuildManifestFromBundle({
target,
envVars,
sdkVersion,
storeDir,
}: {
bundle: BundleResult;
destination: string;
Expand All @@ -364,6 +380,7 @@ export async function createBuildManifestFromBundle({
target: BuildTarget;
envVars?: Record<string, string>;
sdkVersion?: string;
storeDir?: string;
}): Promise<BuildManifest> {
const buildManifest: BuildManifest = {
contentHash: bundle.contentHash,
Expand Down Expand Up @@ -397,11 +414,12 @@ export async function createBuildManifestFromBundle({
otelImportHook: {
include: resolvedConfig.instrumentedPackageNames ?? [],
},
outputHashes: bundle.outputHashes,
};

if (!workerDir) {
return buildManifest;
}

return copyManifestToDir(buildManifest, destination, workerDir);
return copyManifestToDir(buildManifest, destination, workerDir, storeDir);
}
85 changes: 80 additions & 5 deletions packages/cli-v3/src/build/manifests.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
import { BuildManifest } from "@trigger.dev/core/v3/schemas";
import { cp } from "node:fs/promises";
import { cp, link, mkdir, readdir, readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { logger } from "../utilities/logger.js";
import { sanitizeHashForFilename } from "../utilities/fileSystem.js";

export async function copyManifestToDir(
manifest: BuildManifest,
source: string,
destination: string
destination: string,
storeDir?: string
): Promise<BuildManifest> {
// Copy the dir in destination to workerDir
await cp(source, destination, { recursive: true });
// Copy the dir from source to destination
// If storeDir is provided, create hardlinks for files that exist in the store
if (storeDir) {
await copyDirWithStore(source, destination, storeDir, manifest.outputHashes);
} else {
await cp(source, destination, { recursive: true });
}

logger.debug("Copied manifest to dir", { source, destination });
logger.debug("Copied manifest to dir", { source, destination, storeDir });

// Then update the manifest to point to the new workerDir
const updatedManifest = { ...manifest };
Expand All @@ -37,3 +47,68 @@ export async function copyManifestToDir(

return updatedManifest;
}

/**
* Computes a hash of file contents to use as content-addressable key.
* This is a fallback for when outputHashes is not available.
*/
async function computeFileHash(filePath: string): Promise<string> {
const contents = await readFile(filePath);
return createHash("sha256").update(contents).digest("hex").slice(0, 16);
}

/**
* Recursively copies a directory, using hardlinks for files that exist in the store.
* This preserves disk space savings from the content-addressable store.
*
* @param source - Source directory path
* @param destination - Destination directory path
* @param storeDir - Content-addressable store directory
* @param outputHashes - Optional map of file paths to their content hashes (from BuildManifest)
*/
async function copyDirWithStore(
source: string,
destination: string,
storeDir: string,
outputHashes?: Record<string, string>
): Promise<void> {
await mkdir(destination, { recursive: true });

const entries = await readdir(source, { withFileTypes: true });

for (const entry of entries) {
const sourcePath = join(source, entry.name);
const destPath = join(destination, entry.name);

if (entry.isDirectory()) {
// Recursively copy subdirectories
await copyDirWithStore(sourcePath, destPath, storeDir, outputHashes);
} else if (entry.isFile()) {
// Try to get hash from manifest first, otherwise compute it
const contentHash = outputHashes?.[sourcePath] ?? (await computeFileHash(sourcePath));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

rg -n -A3 -B3 'outputHashes' --type=ts -g '!**/test/**' packages/cli-v3/src/build/

Repository: triggerdotdev/trigger.dev

Length of output: 5461


🏁 Script executed:

# Find where outputHashes is assigned/populated in bundle
rg -n 'outputHashes\s*[:=]' --type=ts -g '!**/test/**' packages/cli-v3/src/

Repository: triggerdotdev/trigger.dev

Length of output: 155


🏁 Script executed:

# Check the context of line 88 in manifests.ts to understand sourcePath format
sed -n '60,115p' packages/cli-v3/src/build/manifests.ts

Repository: triggerdotdev/trigger.dev

Length of output: 2142


Fix outputHashes key format mismatch at line 88.

The outputHashes keys are populated from outputFile.path (esbuild output, typically relative paths like "index.js"), but the lookup uses sourcePath (a full joined path like "/path/to/source/index.js"). These formats don't match, so the optional chain always falls back to computeFileHash, defeating the cache. Use consistent path formats—either normalize both to relative paths or ensure both are full paths.

🤖 Prompt for AI Agents
In packages/cli-v3/src/build/manifests.ts around line 88, the lookup uses
sourcePath (an absolute/joined path) against outputHashes keys (esbuild output
relative paths), causing a mismatch; normalize the key formats before lookup by
converting sourcePath to the same relative form used as outputHashes keys (e.g.,
const relativeSource = path.relative(process.cwd(), sourcePath) or
path.posix.normalize(path.relative(buildRoot, sourcePath))), then use
outputHashes[relativeSource] ?? await computeFileHash(sourcePath); ensure you
import/use node's path and handle separators consistently (posix if output keys
are posix-style).

// Sanitize hash to be filesystem-safe (base64 can contain / and +)
const safeHash = sanitizeHashForFilename(contentHash);
const storePath = join(storeDir, safeHash);

if (existsSync(storePath)) {
// Create hardlink to store file
// Fall back to copy if hardlink fails (e.g., on Windows or cross-device)
try {
await link(storePath, destPath);
} catch (linkError) {
try {
await cp(storePath, destPath);
} catch (copyError) {
throw linkError; // Rethrow original error if copy also fails
}
}
} else {
// File wasn't in the store - copy normally
await cp(sourcePath, destPath);
}
} else if (entry.isSymbolicLink()) {
// Preserve symbolic links (e.g., node_modules links)
await cp(sourcePath, destPath, { verbatimSymlinks: true });
}
}
}
19 changes: 17 additions & 2 deletions packages/cli-v3/src/dev/devSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ import { createExternalsBuildExtension, resolveAlwaysExternal } from "../build/e
import { type DevCommandOptions } from "../commands/dev.js";
import { eventBus } from "../utilities/eventBus.js";
import { logger } from "../utilities/logger.js";
import { clearTmpDirs, EphemeralDirectory, getTmpDir } from "../utilities/tempDirectories.js";
import {
clearTmpDirs,
EphemeralDirectory,
getStoreDir,
getTmpDir,
} from "../utilities/tempDirectories.js";
import { startDevOutput } from "./devOutput.js";
import { startWorkerRuntime } from "./devSupervisor.js";
import { startMcpServer, stopMcpServer } from "./mcpServer.js";
Expand Down Expand Up @@ -53,6 +58,8 @@ export async function startDevSession({
}: DevSessionOptions): Promise<DevSessionInstance> {
clearTmpDirs(rawConfig.workingDir);
const destination = getTmpDir(rawConfig.workingDir, "build", keepTmpFiles);
// Create shared store directory for deduplicating chunk files across rebuilds
const storeDir = getStoreDir(rawConfig.workingDir);

const runtime = await startWorkerRuntime({
name,
Expand Down Expand Up @@ -102,6 +109,7 @@ export async function startDevSession({
workerDir: workerDir?.path,
environment: "dev",
target: "dev",
storeDir,
});

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

async function updateBuild(build: esbuild.BuildResult, workerDir: EphemeralDirectory) {
const bundle = await getBundleResultFromBuild("dev", rawConfig.workingDir, rawConfig, build);
const bundle = await getBundleResultFromBuild(
"dev",
rawConfig.workingDir,
rawConfig,
build,
storeDir
);

if (bundle) {
await updateBundle({ ...bundle, stop: undefined }, workerDir);
Expand Down Expand Up @@ -190,6 +204,7 @@ export async function startDevSession({
jsxFactory: rawConfig.build.jsx.factory,
jsxFragment: rawConfig.build.jsx.fragment,
jsxAutomatic: rawConfig.build.jsx.automatic,
storeDir,
});

await updateBundle(bundleResult);
Expand Down
71 changes: 71 additions & 0 deletions packages/cli-v3/src/utilities/fileSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,77 @@ export async function createFile(
return path;
}

/**
* Sanitizes a hash to be safe for use as a filename.
* esbuild's hashes are base64-encoded and may contain `/` and `+` characters.
*/
export function sanitizeHashForFilename(hash: string): string {
return hash.replace(/\//g, "_").replace(/\+/g, "-");
}

/**
* Creates a file using a content-addressable store for deduplication.
* Files are stored by their content hash, so identical content is only stored once.
* The build directory gets a hardlink to the stored file.
*
* @param filePath - The destination path for the file
* @param contents - The file contents to write
* @param storeDir - The shared store directory for deduplication
* @param contentHash - The content hash (e.g., from esbuild's outputFile.hash)
* @returns The destination file path
*/
export async function createFileWithStore(
filePath: string,
contents: string | NodeJS.ArrayBufferView,
storeDir: string,
contentHash: string
): Promise<string> {
// Sanitize hash to be filesystem-safe (base64 can contain / and +)
const safeHash = sanitizeHashForFilename(contentHash);
// Store files by their content hash for true content-addressable storage
const storePath = pathModule.join(storeDir, safeHash);

// Ensure build directory exists
await fsModule.mkdir(pathModule.dirname(filePath), { recursive: true });

// Remove existing file at destination if it exists (hardlinks fail on existing files)
if (fsSync.existsSync(filePath)) {
await fsModule.unlink(filePath);
}

// Check if content already exists in store by hash
if (fsSync.existsSync(storePath)) {
// Create hardlink from build path to store path
// Fall back to copy if hardlink fails (e.g., on Windows or cross-device)
try {
await fsModule.link(storePath, filePath);
} catch (linkError) {
try {
await fsModule.copyFile(storePath, filePath);
} catch (copyError) {
throw linkError; // Rethrow original error if copy also fails
}
}
return filePath;
}

// Write to store first (using hash as filename)
await fsModule.writeFile(storePath, contents);
// Create hardlink in build directory (with original filename)
// Fall back to copy if hardlink fails (e.g., on Windows or cross-device)
try {
await fsModule.link(storePath, filePath);
} catch (linkError) {
try {
await fsModule.copyFile(storePath, filePath);
} catch (copyError) {
throw linkError; // Rethrow original error if copy also fails
}
}

return filePath;
}

export function isDirectory(configPath: string) {
try {
return fs.statSync(configPath).isDirectory();
Expand Down
12 changes: 12 additions & 0 deletions packages/cli-v3/src/utilities/tempDirectories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,15 @@ export function clearTmpDirs(projectRoot: string | undefined) {
// This sometimes fails on Windows with EBUSY
}
}

/**
* Gets the shared store directory for content-addressable build outputs.
* This directory persists across rebuilds and is used to deduplicate
* identical chunk files between build versions.
*/
export function getStoreDir(projectRoot: string | undefined): string {
projectRoot ??= process.cwd();
const storeDir = path.join(projectRoot, ".trigger", "tmp", "store");
fs.mkdirSync(storeDir, { recursive: true });
return storeDir;
}
2 changes: 2 additions & 0 deletions packages/core/src/v3/schemas/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export const BuildManifest = z.object({
exclude: z.array(z.string()).optional(),
})
.optional(),
/** Maps output file paths to their content hashes for deduplication during dev */
outputHashes: z.record(z.string()).optional(),
});

export type BuildManifest = z.infer<typeof BuildManifest>;
Expand Down
2 changes: 0 additions & 2 deletions references/d3-chat/src/trigger/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,6 @@ export const todoChat = schemaTask({
run: async ({ input, userId }, { signal }) => {
metadata.set("user_id", userId);

logger.info("todoChat: starting", { input, userId });

const system = `
You are a SQL (postgres) expert who can turn natural language descriptions for a todo app
into a SQL query which can then be executed against a SQL database. Here is the schema:
Expand Down