11import { 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" ;
36import { logger } from "../utilities/logger.js" ;
47
58export 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+ }
0 commit comments