| … | … | @@ -38,6 +38,15 @@ const CHUNK_THRESHOLD = 1048576 // 1 MB — files below this transfer as whole b |
| 38 | 38 | |
| 39 | 39 | // ── Helpers ────────────────────────────────────────────────── |
| 40 | 40 | |
| 41 | // Walks `dir` and returns an array of entries describing every file and |
| 42 | // symlink found. Symlink ENTRIES are returned with the link-target string |
| 43 | // as their served content (not the file they resolve to) — this matches |
| 44 | // hyper-overlay/lib/tree.js:walkTree, which records KIND_SYMLINK entries |
| 45 | // the same way. Without this, the link-target string's content hash is |
| 46 | // never indexed for serving, so any client materializing a tree with |
| 47 | // symlinks hangs on the symlink's content-request (root cause of the |
| 48 | // "install stuck at SunshineClouds2" hang). Uses lstatSync so symlinks |
| 49 | // are NOT followed. |
| 41 | 50 | function walkDir (dir, prefix) { |
| 42 | 51 | prefix = prefix || '' |
| 43 | 52 | const results = [] |
| … | … | @@ -45,9 +54,16 @@ function walkDir (dir, prefix) { |
| 45 | 54 | const full = path.join(dir, entry) |
| 46 | 55 | const rel = prefix ? prefix + '/' + entry : entry |
| 47 | 56 | try { |
| 48 | | const stat = fs.statSync(full) |
| 49 | | if (stat.isDirectory()) results.push(...walkDir(full, rel)) |
| 50 | | else if (stat.isFile()) results.push({ path: rel, size: stat.size }) |
| 57 | const stat = fs.lstatSync(full) |
| 58 | if (stat.isSymbolicLink()) { |
| 59 | const linkTarget = fs.readlinkSync(full) |
| 60 | const targetBytes = Buffer.from(linkTarget, 'utf8') |
| 61 | results.push({ path: rel, size: targetBytes.length, isSymlink: true, linkTarget }) |
| 62 | } else if (stat.isDirectory()) { |
| 63 | results.push(...walkDir(full, rel)) |
| 64 | } else if (stat.isFile()) { |
| 65 | results.push({ path: rel, size: stat.size }) |
| 66 | } |
| 51 | 67 | } catch {} |
| 52 | 68 | } |
| 53 | 69 | return results |
| … | … | @@ -148,6 +164,10 @@ export class DeployServer extends ReadyResource { |
| 148 | 164 | } |
| 149 | 165 | if (this._fileIndex) await this._fileIndex.close() |
| 150 | 166 | if (this._ownStore && this._corestore) await this._corestore.close() |
| 167 | if (this._symlinkBlobsDir) { |
| 168 | try { fs.rmSync(this._symlinkBlobsDir, { recursive: true, force: true }) } catch {} |
| 169 | this._symlinkBlobsDir = null |
| 170 | } |
| 151 | 171 | } |
| 152 | 172 | |
| 153 | 173 | get topicHex () { |
| … | … | @@ -167,20 +187,41 @@ export class DeployServer extends ReadyResource { |
| 167 | 187 | const files = walkDir(this.dir) |
| 168 | 188 | let totalSize = 0 |
| 169 | 189 | |
| 190 | // Scratch dir for symlink-target blobs. Each unique link-target |
| 191 | // string gets materialized to <scratch>/<contentHash> so that the |
| 192 | // existing diskPath-based content-request flow can serve it via |
| 193 | // fs.readFileSync. Single source of bytes; the protocol layer does |
| 194 | // not need to know about symlinks. |
| 195 | if (!this._symlinkBlobsDir) { |
| 196 | this._symlinkBlobsDir = path.join(os.tmpdir(), 'hyper-deploy-symlinks-' + crypto.randomBytes(8).toString('hex')) |
| 197 | fs.mkdirSync(this._symlinkBlobsDir, { recursive: true }) |
| 198 | } |
| 199 | |
| 170 | 200 | for (let i = 0; i < files.length; i++) { |
| 171 | 201 | const f = files[i] |
| 172 | | const fullPath = path.join(this.dir, f.path) |
| 173 | | const contentHash = hashFile(fullPath) |
| 202 | let diskPath, contentHash, mtime |
| 203 | if (f.isSymlink) { |
| 204 | const linkBytes = Buffer.from(f.linkTarget, 'utf8') |
| 205 | contentHash = hashChunk(linkBytes) |
| 206 | diskPath = path.join(this._symlinkBlobsDir, contentHash) |
| 207 | if (!fs.existsSync(diskPath)) fs.writeFileSync(diskPath, linkBytes) |
| 208 | mtime = Date.now() |
| 209 | } else { |
| 210 | diskPath = path.join(this.dir, f.path) |
| 211 | contentHash = hashFile(diskPath) |
| 212 | mtime = fs.statSync(diskPath).mtimeMs |
| 213 | } |
| 214 | f.diskPath = diskPath |
| 174 | 215 | |
| 175 | 216 | await this._fileIndex.putFile(f.path, { |
| 176 | 217 | contentHash, |
| 177 | 218 | size: f.size, |
| 178 | | mtime: fs.statSync(fullPath).mtimeMs |
| 219 | mtime |
| 179 | 220 | }) |
| 180 | 221 | |
| 181 | 222 | // For large files, prepare chunk map via TransferManager |
| 182 | 223 | if (f.size >= CHUNK_THRESHOLD) { |
| 183 | | await this._transferManager.prepareFile(fullPath, f.path) |
| 224 | await this._transferManager.prepareFile(diskPath, f.path) |
| 184 | 225 | } |
| 185 | 226 | |
| 186 | 227 | totalSize += f.size |
| … | … | @@ -191,6 +232,7 @@ export class DeployServer extends ReadyResource { |
| 191 | 232 | files: files.map(f => ({ |
| 192 | 233 | path: f.path, |
| 193 | 234 | size: f.size, |
| 235 | diskPath: f.diskPath, // points to symlink blob OR source file, used by _onConnection |
| 194 | 236 | hash: null // populated lazily from fileIndex |
| 195 | 237 | })), |
| 196 | 238 | totalSize, |
| … | … | @@ -253,7 +295,11 @@ export class DeployServer extends ReadyResource { |
| 253 | 295 | const filePaths = new Map() |
| 254 | 296 | for (const f of (this.manifest && this.manifest.files) || []) { |
| 255 | 297 | if (f.hash) { |
| 256 | | const diskPath = path.join(this.dir, f.path) |
| 298 | // f.diskPath points to the symlink-target blob for symlink |
| 299 | // entries and to the actual source file for regular files. |
| 300 | // Falls back to dir-relative path for back-compat with any |
| 301 | // older manifest shapes that lack f.diskPath. |
| 302 | const diskPath = f.diskPath || path.join(this.dir, f.path) |
| 257 | 303 | contentHashPaths.set(f.hash, diskPath) |
| 258 | 304 | filePaths.set(f.path, diskPath) |
| 259 | 305 | } |