@git.zone/tsbundle 2.11.3 → 2.12.0

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.
Files changed (37) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/interfaces/index.d.ts +1 -0
  3. package/dist_ts/mod_custom/index.d.ts +10 -3
  4. package/dist_ts/mod_custom/index.js +277 -86
  5. package/dist_ts/mod_esbuild/index.child.d.ts +4 -8
  6. package/dist_ts/mod_esbuild/index.child.js +51 -44
  7. package/dist_ts/mod_esbuild/plugins.d.ts +5 -2
  8. package/dist_ts/mod_esbuild/plugins.js +5 -3
  9. package/dist_ts/mod_esbuild/workerplugin.d.ts +12 -0
  10. package/dist_ts/mod_esbuild/workerplugin.js +254 -0
  11. package/dist_ts/mod_output/artifactpublisher.d.ts +43 -0
  12. package/dist_ts/mod_output/artifactpublisher.js +925 -0
  13. package/dist_ts/mod_output/index.d.ts +1 -0
  14. package/dist_ts/mod_output/index.js +11 -3
  15. package/dist_ts/mod_rolldown/index.child.d.ts +4 -2
  16. package/dist_ts/mod_rolldown/index.child.js +47 -36
  17. package/dist_ts/mod_rspack/index.child.d.ts +4 -2
  18. package/dist_ts/mod_rspack/index.child.js +73 -115
  19. package/dist_ts/plugins.d.ts +3 -1
  20. package/dist_ts/plugins.js +4 -2
  21. package/dist_ts/tsbundle.class.tsbundle.js +98 -16
  22. package/package.json +10 -9
  23. package/readme.hints.md +20 -4
  24. package/readme.md +23 -6
  25. package/third-party-notices.md +29 -0
  26. package/ts/00_commitinfo_data.ts +1 -1
  27. package/ts/interfaces/index.ts +1 -0
  28. package/ts/mod_custom/index.ts +347 -96
  29. package/ts/mod_esbuild/index.child.ts +78 -47
  30. package/ts/mod_esbuild/plugins.ts +9 -2
  31. package/ts/mod_esbuild/workerplugin.ts +328 -0
  32. package/ts/mod_output/artifactpublisher.ts +1185 -0
  33. package/ts/mod_output/index.ts +10 -2
  34. package/ts/mod_rolldown/index.child.ts +65 -42
  35. package/ts/mod_rspack/index.child.ts +95 -127
  36. package/ts/plugins.ts +3 -1
  37. package/ts/tsbundle.class.tsbundle.ts +102 -14
@@ -2,20 +2,36 @@ import * as plugins from './plugins.js';
2
2
  import * as paths from '../paths.js';
3
3
  import * as interfaces from '../interfaces/index.js';
4
4
  import { TsBundle } from '../tsbundle.class.tsbundle.js';
5
- import { HtmlHandler } from '../mod_html/index.js';
6
5
  import { Base64TsOutput } from '../mod_output/index.js';
7
-
8
- const TEMP_DIR = '.nogit/tsbundle-temp';
6
+ import {
7
+ chunkManifestFile,
8
+ createProcessOwnership,
9
+ getChunkNamespace,
10
+ isProcessOwnershipActive,
11
+ publishGeneratedArtifacts,
12
+ startOwnershipHeartbeat,
13
+ withArtifactPublicationLocks,
14
+ type IAdditionalArtifact,
15
+ } from '../mod_output/artifactpublisher.js';
16
+
17
+ const legacyTempDir = '.nogit/tsbundle-temp';
18
+ const localTempPrefix = 'tsbundle-temp-';
19
+ const cleanupTempPrefix = '.tsbundle-temp-cleanup-';
9
20
  const toolCacheMarkerFile = '.gitzone-tool-cache.json';
10
21
  const toolCacheOwner = '@git.zone/tsbundle';
11
22
  const tempCacheKind = 'bundle-temp-workspace';
12
23
  const staleTempThresholdMs = 24 * 60 * 60 * 1000;
24
+ const minimumTempLeaseTimeoutMs = 2 * 60 * 1000;
13
25
 
14
26
  interface IToolCacheMarker {
15
27
  owner: string;
16
28
  kind: string;
17
29
  safeToPrune: boolean;
18
30
  createdAt: string;
31
+ invocationId?: string;
32
+ leaseManaged?: boolean;
33
+ processId?: number;
34
+ processIdentity?: string;
19
35
  schemaVersion: 1;
20
36
  }
21
37
 
@@ -23,6 +39,7 @@ interface ITempWorkspace {
23
39
  tempDir: string;
24
40
  canCleanupWorkspace: boolean;
25
41
  cleanupOnFailure: boolean;
42
+ marker?: IToolCacheMarker;
26
43
  }
27
44
 
28
45
  export class CustomBundleHandler {
@@ -30,6 +47,7 @@ export class CustomBundleHandler {
30
47
  private config!: interfaces.ITsbundleConfig;
31
48
  private cliOptions: interfaces.ICustomBundleCliOptions;
32
49
  private didRunStartupCleanup = false;
50
+ private readonly invocationId = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
33
51
 
34
52
  constructor(cwd: string = paths.cwd, cliOptions: interfaces.ICustomBundleCliOptions = {}) {
35
53
  this.cwd = cwd;
@@ -78,16 +96,17 @@ export class CustomBundleHandler {
78
96
  const tempWorkspace = await this.prepareTempWorkspace();
79
97
  const tempDir = tempWorkspace.tempDir;
80
98
  const tempBundlePath = plugins.path.join(tempDir, 'bundle.js');
81
-
82
- // A marked workspace can survive a failed or explicitly retained build. Clear
83
- // only the files this build owns so an old map cannot leak into new output.
84
- await plugins.fsPromises.rm(tempBundlePath, { force: true });
85
- await plugins.fsPromises.rm(`${tempBundlePath}.map`, { force: true });
99
+ const stopTempHeartbeat = tempWorkspace.marker
100
+ ? startOwnershipHeartbeat(plugins.path.join(tempDir, toolCacheMarkerFile))
101
+ : undefined;
86
102
 
87
103
  // Build the bundle to temp location
88
104
  const tsbundle = new TsBundle();
89
105
  let completedSuccessfully = false;
90
106
  try {
107
+ // A marked workspace can survive a failed or explicitly retained build.
108
+ // Clear prior generated artifacts so stale chunks cannot leak into output.
109
+ await this.clearTempBuildArtifacts(tempDir);
91
110
  await tsbundle.build(
92
111
  this.cwd,
93
112
  bundleConfig.from,
@@ -96,6 +115,7 @@ export class CustomBundleHandler {
96
115
  bundler,
97
116
  production: bundleConfig.production || false,
98
117
  sourcemap: bundleConfig.sourcemap !== false,
118
+ chunkOwner: plugins.path.basename(bundleConfig.to),
99
119
  }
100
120
  );
101
121
 
@@ -106,18 +126,19 @@ export class CustomBundleHandler {
106
126
  }
107
127
 
108
128
  completedSuccessfully = true;
109
- } catch (error: any) {
129
+ } catch (error: unknown) {
110
130
  console.error(`\n\x1b[31m❌ Bundle failed: ${bundleConfig.from} -> ${bundleConfig.to}\x1b[0m`);
111
131
  // Don't re-print error details - they were already shown by the child process.
112
132
  // Propagate the failure so package builds cannot succeed without their bundle.
113
133
  throw error;
114
134
  } finally {
135
+ stopTempHeartbeat?.();
115
136
  if (completedSuccessfully && tempWorkspace.canCleanupWorkspace && !this.shouldKeepTemp(bundleConfig)) {
116
- await this.cleanupTempWorkspace('successful bundle', tempDir);
137
+ await this.cleanupTempWorkspace('successful bundle', tempDir, tempWorkspace.marker);
117
138
  } else if (completedSuccessfully) {
118
139
  console.log(`Keeping tsbundle temp workspace: ${tempDir}`);
119
140
  } else if (tempWorkspace.cleanupOnFailure && !this.shouldKeepTemp(bundleConfig)) {
120
- await this.cleanupTempWorkspace('failed bundle', tempDir);
141
+ await this.cleanupTempWorkspace('failed bundle', tempDir, tempWorkspace.marker);
121
142
  }
122
143
  }
123
144
  }
@@ -132,42 +153,77 @@ export class CustomBundleHandler {
132
153
  return false;
133
154
  }
134
155
 
135
- const tempDir = this.getTempDir();
136
- const marker = await this.readTempMarker(tempDir);
156
+ let didCleanup = await this.cleanupStaleMarkedTempWorkspaces(
157
+ this.getProjectTempRoot(),
158
+ thresholdMsArg,
159
+ );
160
+ didCleanup = await this.cleanupStaleMarkedTempWorkspaces(
161
+ plugins.os.tmpdir(),
162
+ thresholdMsArg,
163
+ ) || didCleanup;
164
+
165
+ const legacyTempPath = this.getTempDir();
166
+ const marker = await this.readTempMarker(legacyTempPath);
137
167
  if (!marker) {
138
- return false;
168
+ return didCleanup;
139
169
  }
140
170
 
141
171
  const createdAt = Date.parse(marker.createdAt);
142
- if (Number.isFinite(createdAt) && Date.now() - createdAt < thresholdMsArg) {
143
- return false;
172
+ if (!Number.isFinite(createdAt) || Date.now() - createdAt < thresholdMsArg) {
173
+ return didCleanup;
174
+ }
175
+ if (this.isTempMarkerActive(marker, legacyTempPath, thresholdMsArg)) {
176
+ return didCleanup;
144
177
  }
145
178
 
146
- await this.cleanupTempWorkspace('stale marked temp workspace');
147
- return true;
179
+ didCleanup = await this.cleanupTempWorkspace(
180
+ 'stale marked temp workspace',
181
+ legacyTempPath,
182
+ marker,
183
+ ) || didCleanup;
184
+ return didCleanup;
148
185
  }
149
186
 
150
187
  private getTempDir(): string {
151
- return plugins.path.join(this.cwd, TEMP_DIR);
188
+ return plugins.path.join(this.cwd, legacyTempDir);
152
189
  }
153
190
 
154
- private assertExpectedTempDir(tempDirArg: string): void {
155
- const expected = plugins.path.resolve(this.cwd, TEMP_DIR);
156
- const actual = plugins.path.resolve(tempDirArg);
157
- if (actual !== expected) {
158
- throw new Error(`Refusing to clean unexpected tsbundle temp path: ${tempDirArg}`);
159
- }
191
+ private getProjectTempRoot(): string {
192
+ return plugins.path.join(this.cwd, '.nogit');
160
193
  }
161
194
 
162
195
  private assertManagedTempDir(tempDirArg: string): void {
163
- const expected = plugins.path.resolve(this.cwd, TEMP_DIR);
196
+ const expected = plugins.path.resolve(this.cwd, legacyTempDir);
164
197
  const actual = plugins.path.resolve(tempDirArg);
198
+ const projectTempRoot = plugins.path.resolve(this.getProjectTempRoot());
165
199
  const osTempRoot = plugins.path.resolve(plugins.os.tmpdir());
166
- const isIsolatedTemp = plugins.path.dirname(actual) === osTempRoot
167
- && plugins.path.basename(actual).startsWith('tsbundle-temp-');
168
- if (actual !== expected && !isIsolatedTemp) {
200
+ const isExpectedTemp = actual === expected;
201
+ const basename = plugins.path.basename(actual);
202
+ const hasManagedName = basename.startsWith(localTempPrefix)
203
+ || basename.startsWith(cleanupTempPrefix);
204
+ const isProjectTemp = plugins.path.dirname(actual) === projectTempRoot && hasManagedName;
205
+ const isOsTemp = plugins.path.dirname(actual) === osTempRoot && hasManagedName;
206
+ if (!isExpectedTemp && !isProjectTemp && !isOsTemp) {
169
207
  throw new Error(`Refusing to clean unexpected tsbundle temp path: ${tempDirArg}`);
170
208
  }
209
+
210
+ const stat = plugins.fsSync.lstatSync(actual);
211
+ if (stat.isSymbolicLink()) {
212
+ throw new Error(`Refusing to use symbolic-link temp path: ${tempDirArg}`);
213
+ }
214
+
215
+ const physicalActual = plugins.fsSync.realpathSync(actual);
216
+ const physicalParent = plugins.fsSync.realpathSync(plugins.path.dirname(actual));
217
+ const physicalCwd = plugins.fsSync.realpathSync(this.cwd);
218
+ const physicalExpected = plugins.path.join(physicalCwd, legacyTempDir);
219
+ const physicalProjectTempRoot = plugins.path.join(physicalCwd, '.nogit');
220
+ const physicalOsTempRoot = plugins.fsSync.realpathSync(osTempRoot);
221
+ const isPhysicalExpected = isExpectedTemp && physicalActual === physicalExpected;
222
+ const isPhysicalProjectTemp = isProjectTemp && physicalParent === physicalProjectTempRoot;
223
+ const isPhysicalOsTemp = isOsTemp && physicalParent === physicalOsTempRoot;
224
+ if (!isPhysicalExpected && !isPhysicalProjectTemp && !isPhysicalOsTemp) {
225
+ throw new Error(`Refusing to use temp path outside its physical owner: ${tempDirArg}`);
226
+ }
171
227
  }
172
228
 
173
229
  private isTruthy(valueArg: unknown): boolean {
@@ -181,6 +237,43 @@ export class CustomBundleHandler {
181
237
  return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'y';
182
238
  }
183
239
 
240
+ private hasErrorCode(errorArg: unknown): errorArg is NodeJS.ErrnoException {
241
+ return typeof errorArg === 'object' && errorArg !== null && 'code' in errorArg;
242
+ }
243
+
244
+ private isTempMarkerActive(
245
+ markerArg: IToolCacheMarker,
246
+ tempDirArg: string,
247
+ leaseTimeoutMsArg: number,
248
+ ): boolean {
249
+ if (!markerArg.processId) {
250
+ return false;
251
+ }
252
+ return isProcessOwnershipActive(
253
+ {
254
+ leaseManaged: markerArg.leaseManaged,
255
+ processId: markerArg.processId,
256
+ processIdentity: markerArg.processIdentity,
257
+ },
258
+ plugins.path.join(tempDirArg, toolCacheMarkerFile),
259
+ Math.max(leaseTimeoutMsArg, minimumTempLeaseTimeoutMs),
260
+ );
261
+ }
262
+
263
+ private markersMatch(
264
+ firstMarkerArg: IToolCacheMarker,
265
+ secondMarkerArg: IToolCacheMarker,
266
+ ): boolean {
267
+ return firstMarkerArg.owner === secondMarkerArg.owner
268
+ && firstMarkerArg.kind === secondMarkerArg.kind
269
+ && firstMarkerArg.createdAt === secondMarkerArg.createdAt
270
+ && firstMarkerArg.invocationId === secondMarkerArg.invocationId
271
+ && firstMarkerArg.processId === secondMarkerArg.processId
272
+ && firstMarkerArg.processIdentity === secondMarkerArg.processIdentity
273
+ && firstMarkerArg.leaseManaged === secondMarkerArg.leaseManaged
274
+ && firstMarkerArg.schemaVersion === secondMarkerArg.schemaVersion;
275
+ }
276
+
184
277
  private shouldKeepTemp(bundleConfigArg?: interfaces.IBundleConfig): boolean {
185
278
  return this.isTruthy(process.env.TSBUNDLE_KEEP_TEMP)
186
279
  || this.cliOptions.keepTemp === true
@@ -190,73 +283,184 @@ export class CustomBundleHandler {
190
283
  }
191
284
 
192
285
  private async prepareTempWorkspace(): Promise<ITempWorkspace> {
193
- const tempDirArg = this.getTempDir();
194
- this.assertExpectedTempDir(tempDirArg);
195
- const marker = await this.readTempMarker(tempDirArg);
196
- if (marker) {
197
- return { tempDir: tempDirArg, canCleanupWorkspace: true, cleanupOnFailure: false };
198
- }
286
+ return await this.createIsolatedTempWorkspace();
287
+ }
199
288
 
200
- let entries: string[] = [];
289
+ private async createIsolatedTempWorkspace(): Promise<ITempWorkspace> {
290
+ const projectTempRoot = this.getProjectTempRoot();
291
+ await plugins.fsPromises.mkdir(projectTempRoot, { recursive: true });
292
+ const rootStat = plugins.fsSync.lstatSync(projectTempRoot);
293
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
294
+ throw new Error(`Refusing to use unsafe project temp root: ${projectTempRoot}`);
295
+ }
296
+ const expectedPhysicalRoot = plugins.path.join(plugins.fsSync.realpathSync(this.cwd), '.nogit');
297
+ if (plugins.fsSync.realpathSync(projectTempRoot) !== expectedPhysicalRoot) {
298
+ throw new Error(`Refusing to use project temp root outside the project: ${projectTempRoot}`);
299
+ }
300
+ const tempDir = await plugins.fsPromises.mkdtemp(
301
+ plugins.path.join(projectTempRoot, localTempPrefix),
302
+ );
201
303
  try {
202
- entries = await plugins.fsPromises.readdir(tempDirArg);
203
- } catch {
204
- await plugins.fsPromises.mkdir(tempDirArg, { recursive: true });
304
+ const marker = await this.writeTempMarker(tempDir);
305
+ return {
306
+ tempDir,
307
+ canCleanupWorkspace: true,
308
+ cleanupOnFailure: true,
309
+ marker,
310
+ };
311
+ } catch (error: unknown) {
312
+ await plugins.fsPromises.rm(tempDir, { recursive: true, force: true });
313
+ throw error;
205
314
  }
315
+ }
206
316
 
207
- if (entries.length > 0) {
208
- const isolatedTempDir = await this.createIsolatedTempWorkspace();
209
- console.log(`tsbundle: leaving existing unmarked temp workspace untouched: ${tempDirArg}`);
210
- console.log(`tsbundle: using isolated marked temp workspace: ${isolatedTempDir}`);
211
- return { tempDir: isolatedTempDir, canCleanupWorkspace: true, cleanupOnFailure: true };
317
+ private async clearTempBuildArtifacts(tempDirArg: string): Promise<void> {
318
+ this.assertManagedTempDir(tempDirArg);
319
+ const entries = await plugins.fsPromises.readdir(tempDirArg, { withFileTypes: true });
320
+ for (const entry of entries) {
321
+ if (entry.name === toolCacheMarkerFile) {
322
+ continue;
323
+ }
324
+ await plugins.fsPromises.rm(plugins.path.join(tempDirArg, entry.name), {
325
+ recursive: true,
326
+ force: true,
327
+ });
212
328
  }
213
-
214
- await this.writeTempMarker(tempDirArg);
215
- return { tempDir: tempDirArg, canCleanupWorkspace: true, cleanupOnFailure: false };
216
329
  }
217
330
 
218
- private async createIsolatedTempWorkspace(): Promise<string> {
219
- const tempDir = await plugins.fsPromises.mkdtemp(plugins.path.join(plugins.os.tmpdir(), 'tsbundle-temp-'));
220
- await this.writeTempMarker(tempDir);
221
- return tempDir;
331
+ private async listBundleArtifacts(tempDirArg: string): Promise<string[]> {
332
+ const result: string[] = [];
333
+ const collect = async (directoryArg: string): Promise<void> => {
334
+ const entries = await plugins.fsPromises.readdir(directoryArg, { withFileTypes: true });
335
+ for (const entry of entries) {
336
+ if (entry.name === toolCacheMarkerFile || entry.name === chunkManifestFile) {
337
+ continue;
338
+ }
339
+ const entryPath = plugins.path.join(directoryArg, entry.name);
340
+ if (entry.isDirectory()) {
341
+ await collect(entryPath);
342
+ } else if (entry.isFile()) {
343
+ result.push(entryPath);
344
+ }
345
+ }
346
+ };
347
+ await collect(tempDirArg);
348
+ return result.sort((a, b) => a.localeCompare(b));
222
349
  }
223
350
 
224
- private async writeTempMarker(tempDirArg: string): Promise<void> {
351
+ private async writeTempMarker(
352
+ tempDirArg: string,
353
+ exclusive = false,
354
+ ): Promise<IToolCacheMarker> {
355
+ await plugins.fsPromises.mkdir(tempDirArg, { recursive: true });
225
356
  this.assertManagedTempDir(tempDirArg);
226
357
  const marker: IToolCacheMarker = {
227
358
  owner: toolCacheOwner,
228
359
  kind: tempCacheKind,
229
360
  safeToPrune: true,
230
361
  createdAt: new Date().toISOString(),
362
+ invocationId: this.invocationId,
363
+ ...createProcessOwnership(),
231
364
  schemaVersion: 1,
232
365
  };
233
- await plugins.fsPromises.mkdir(tempDirArg, { recursive: true });
234
366
  await plugins.fsPromises.writeFile(
235
367
  plugins.path.join(tempDirArg, toolCacheMarkerFile),
236
368
  `${JSON.stringify(marker, null, 2)}\n`,
369
+ { flag: exclusive ? 'wx' : 'w', mode: 0o600 },
237
370
  );
371
+ return marker;
238
372
  }
239
373
 
240
374
  private async readTempMarker(tempDirArg: string): Promise<IToolCacheMarker | undefined> {
241
- this.assertManagedTempDir(tempDirArg);
242
375
  try {
376
+ this.assertManagedTempDir(tempDirArg);
243
377
  const markerRaw = await plugins.fsPromises.readFile(plugins.path.join(tempDirArg, toolCacheMarkerFile), 'utf8');
244
- const marker = JSON.parse(markerRaw) as Partial<IToolCacheMarker>;
378
+ let marker: Partial<IToolCacheMarker>;
379
+ try {
380
+ marker = JSON.parse(markerRaw) as Partial<IToolCacheMarker>;
381
+ } catch {
382
+ return undefined;
383
+ }
245
384
  if (
246
385
  marker.owner === toolCacheOwner &&
247
386
  marker.kind === tempCacheKind &&
248
- marker.safeToPrune === true &&
249
- marker.schemaVersion === 1
387
+ marker.safeToPrune === true &&
388
+ (marker.invocationId === undefined || (
389
+ typeof marker.invocationId === 'string'
390
+ && /^[A-Za-z0-9._-]{1,128}$/.test(marker.invocationId)
391
+ )) &&
392
+ (marker.processId === undefined || (
393
+ typeof marker.processId === 'number'
394
+ && Number.isSafeInteger(marker.processId)
395
+ && marker.processId > 0
396
+ )) &&
397
+ (marker.processIdentity === undefined || typeof marker.processIdentity === 'string') &&
398
+ (marker.leaseManaged === undefined || typeof marker.leaseManaged === 'boolean') &&
399
+ marker.schemaVersion === 1
250
400
  ) {
251
401
  return marker as IToolCacheMarker;
252
402
  }
253
- } catch {
254
- return undefined;
403
+ } catch (error: unknown) {
404
+ if (this.hasErrorCode(error) && error.code === 'ENOENT') {
405
+ return undefined;
406
+ }
407
+ throw error;
255
408
  }
256
409
 
257
410
  return undefined;
258
411
  }
259
412
 
413
+ private async cleanupStaleMarkedTempWorkspaces(
414
+ rootDirectoryArg: string,
415
+ thresholdMsArg: number,
416
+ ): Promise<boolean> {
417
+ let didCleanup = false;
418
+ let entries;
419
+ try {
420
+ entries = await plugins.fsPromises.readdir(rootDirectoryArg, { withFileTypes: true });
421
+ } catch (error: unknown) {
422
+ if (this.hasErrorCode(error) && error.code === 'ENOENT') {
423
+ return false;
424
+ }
425
+ throw error;
426
+ }
427
+ for (const entry of entries) {
428
+ if (
429
+ !entry.isDirectory()
430
+ || (!entry.name.startsWith(localTempPrefix) && !entry.name.startsWith(cleanupTempPrefix))
431
+ ) {
432
+ continue;
433
+ }
434
+ const tempDir = plugins.path.join(rootDirectoryArg, entry.name);
435
+ let marker: IToolCacheMarker | undefined;
436
+ try {
437
+ marker = await this.readTempMarker(tempDir);
438
+ } catch (error: unknown) {
439
+ if (this.hasErrorCode(error) && error.code === 'ENOENT') {
440
+ continue;
441
+ }
442
+ throw error;
443
+ }
444
+ if (!marker) {
445
+ continue;
446
+ }
447
+ const createdAt = Date.parse(marker.createdAt);
448
+ if (
449
+ !Number.isFinite(createdAt)
450
+ || Date.now() - createdAt < thresholdMsArg
451
+ || this.isTempMarkerActive(marker, tempDir, thresholdMsArg)
452
+ ) {
453
+ continue;
454
+ }
455
+ didCleanup = await this.cleanupTempWorkspace(
456
+ 'stale isolated temp workspace',
457
+ tempDir,
458
+ marker,
459
+ ) || didCleanup;
460
+ }
461
+ return didCleanup;
462
+ }
463
+
260
464
  private async getDirectorySize(pathArg: string): Promise<number> {
261
465
  let total = 0;
262
466
  let entries;
@@ -294,15 +498,53 @@ export class CustomBundleHandler {
294
498
  return `${value.toFixed(decimals)} ${units[unitIndex]}`;
295
499
  }
296
500
 
297
- private async cleanupTempWorkspace(reasonArg: string, tempDir = this.getTempDir()): Promise<void> {
298
- this.assertManagedTempDir(tempDir);
501
+ private async cleanupTempWorkspace(
502
+ reasonArg: string,
503
+ tempDir = this.getTempDir(),
504
+ expectedMarkerArg?: IToolCacheMarker,
505
+ ): Promise<boolean> {
506
+ try {
507
+ this.assertManagedTempDir(tempDir);
508
+ } catch (error: unknown) {
509
+ if (this.hasErrorCode(error) && error.code === 'ENOENT') {
510
+ return false;
511
+ }
512
+ throw error;
513
+ }
299
514
  const marker = await this.readTempMarker(tempDir);
300
- if (!marker) {
301
- return;
515
+ if (!marker || (expectedMarkerArg && !this.markersMatch(marker, expectedMarkerArg))) {
516
+ return false;
302
517
  }
303
- const reclaimedBytes = await this.getDirectorySize(tempDir);
304
- await plugins.fsPromises.rm(tempDir, { recursive: true, force: true });
518
+ const quarantinePath = plugins.path.join(
519
+ plugins.path.dirname(tempDir),
520
+ `${cleanupTempPrefix}${this.invocationId}-${Math.random().toString(36).slice(2)}`,
521
+ );
522
+ try {
523
+ await plugins.fsPromises.rename(tempDir, quarantinePath);
524
+ } catch (error: unknown) {
525
+ if (this.hasErrorCode(error) && error.code === 'ENOENT') {
526
+ return false;
527
+ }
528
+ throw error;
529
+ }
530
+
531
+ const quarantinedMarker = await this.readTempMarker(quarantinePath);
532
+ if (!quarantinedMarker || !this.markersMatch(marker, quarantinedMarker)) {
533
+ try {
534
+ if (!plugins.fsSync.existsSync(tempDir)) {
535
+ await plugins.fsPromises.rename(quarantinePath, tempDir);
536
+ }
537
+ } catch (error: unknown) {
538
+ if (!this.hasErrorCode(error) || !['EEXIST', 'ENOENT'].includes(error.code || '')) {
539
+ throw error;
540
+ }
541
+ }
542
+ return false;
543
+ }
544
+ const reclaimedBytes = await this.getDirectorySize(quarantinePath);
545
+ await plugins.fsPromises.rm(quarantinePath, { recursive: true, force: true });
305
546
  console.log(`Cleaned tsbundle temp workspace after ${reasonArg}, reclaimed ${this.formatBytes(reclaimedBytes)}`);
547
+ return true;
306
548
  }
307
549
 
308
550
  /**
@@ -313,10 +555,12 @@ export class CustomBundleHandler {
313
555
  tempBundlePath: string
314
556
  ): Promise<void> {
315
557
  const base64Output = new Base64TsOutput(this.cwd);
316
-
317
- // Add the bundle itself
318
- const bundleContent = await plugins.fs.file(tempBundlePath).read();
319
- base64Output.addFile('bundle.js', bundleContent);
558
+ const tempDir = plugins.path.dirname(tempBundlePath);
559
+ for (const artifactPath of await this.listBundleArtifacts(tempDir)) {
560
+ const relativePath = plugins.path.relative(tempDir, artifactPath);
561
+ const artifactContent = await plugins.fs.file(artifactPath).read();
562
+ base64Output.addFile(relativePath, artifactContent);
563
+ }
320
564
 
321
565
  // Add included files
322
566
  if (bundleConfig.includeFiles && bundleConfig.includeFiles.length > 0) {
@@ -330,7 +574,10 @@ export class CustomBundleHandler {
330
574
  }
331
575
 
332
576
  // Write the TypeScript output
333
- await base64Output.writeToFile(bundleConfig.to, bundleConfig.maxLineLength);
577
+ const outputPath = plugins.smartpath.transform.toAbsolute(bundleConfig.to, this.cwd) as string;
578
+ await withArtifactPublicationLocks(outputPath, async () => {
579
+ await base64Output.writeToFile(bundleConfig.to, bundleConfig.maxLineLength);
580
+ });
334
581
  }
335
582
 
336
583
  /**
@@ -340,35 +587,42 @@ export class CustomBundleHandler {
340
587
  bundleConfig: interfaces.IBundleConfig,
341
588
  tempBundlePath: string
342
589
  ): Promise<void> {
343
- // Move bundle to final destination
344
590
  const toPath = plugins.smartpath.transform.toAbsolute(bundleConfig.to, this.cwd) as string;
345
591
  const toDir = plugins.path.dirname(toPath);
346
- await plugins.fs.directory(toDir).create();
347
-
348
- const bundleContent = await plugins.fs.file(tempBundlePath).read();
349
- await plugins.fs.file(toPath).write(bundleContent);
350
- console.log(`Bundle written to: ${bundleConfig.to}`);
351
-
352
- if (bundleConfig.sourcemap === false) {
353
- await plugins.fsPromises.rm(`${toPath}.map`, { force: true });
354
- }
355
-
356
- // Process included files (copy them)
592
+ const tempDir = plugins.path.dirname(tempBundlePath);
593
+ const logicalOutputName = plugins.path.basename(toPath);
594
+ const additionalArtifacts: IAdditionalArtifact[] = [];
357
595
  if (bundleConfig.includeFiles && bundleConfig.includeFiles.length > 0) {
358
- const htmlHandler = new HtmlHandler();
359
- const outputDir = plugins.path.dirname(toPath);
360
-
361
596
  for (const entry of bundleConfig.includeFiles) {
362
597
  const pattern = typeof entry === 'string' ? entry : entry.from;
363
- await this.copyIncludedFiles(pattern, outputDir);
598
+ additionalArtifacts.push(...await this.collectIncludedFiles(pattern, toDir));
364
599
  }
365
600
  }
601
+ await publishGeneratedArtifacts({
602
+ additionalArtifacts,
603
+ sourceDirectory: tempDir,
604
+ sourceMainPath: tempBundlePath,
605
+ targetPath: toPath,
606
+ lockTargetPath: plugins.path.join(toDir, '.tsbundle-output-directory'),
607
+ logicalOutputName,
608
+ chunkNamespace: getChunkNamespace(logicalOutputName),
609
+ sourceMapsEnabled: bundleConfig.sourcemap !== false,
610
+ });
611
+ console.log(`Bundle written to: ${bundleConfig.to}`);
612
+ for (const artifact of additionalArtifacts) {
613
+ const relativePath = plugins.path.relative(this.cwd, artifact.sourcePath);
614
+ console.log(`Copied: ${relativePath} -> ${artifact.targetPath}`);
615
+ }
366
616
  }
367
617
 
368
618
  /**
369
- * Copy files matching a pattern to the output directory
619
+ * Resolve files matching a pattern for transactional publication.
370
620
  */
371
- private async copyIncludedFiles(pattern: string, outputDir: string): Promise<void> {
621
+ private async collectIncludedFiles(
622
+ pattern: string,
623
+ outputDir: string,
624
+ ): Promise<IAdditionalArtifact[]> {
625
+ const artifacts: IAdditionalArtifact[] = [];
372
626
  const absolutePattern = plugins.smartpath.transform.toAbsolute(pattern, this.cwd) as string;
373
627
  const patternDir = plugins.path.dirname(absolutePattern);
374
628
  const patternBase = plugins.path.basename(absolutePattern);
@@ -379,7 +633,7 @@ export class CustomBundleHandler {
379
633
  const dirExists = await plugins.fs.directory(dirPath).exists();
380
634
  if (!dirExists) {
381
635
  console.log(`Directory does not exist: ${dirPath}`);
382
- return;
636
+ return artifacts;
383
637
  }
384
638
 
385
639
  const isRecursive = pattern.includes('**');
@@ -397,24 +651,21 @@ export class CustomBundleHandler {
397
651
  if (!entry.isDirectory && regex.test(entry.name)) {
398
652
  // entry.path is already absolute from smartfs
399
653
  const fullPath = entry.path;
400
- const relativePath = plugins.path.relative(this.cwd, fullPath);
401
654
  const destPath = plugins.path.join(outputDir, plugins.path.basename(entry.path));
402
- await plugins.fs.directory(plugins.path.dirname(destPath)).create();
403
- await plugins.fs.file(fullPath).copy(destPath);
404
- console.log(`Copied: ${relativePath} -> ${destPath}`);
655
+ artifacts.push({ sourcePath: fullPath, targetPath: destPath });
405
656
  }
406
657
  }
407
658
  } else {
408
659
  const fileExists = await plugins.fs.file(absolutePattern).exists();
409
660
  if (!fileExists) {
410
661
  console.log(`File does not exist: ${absolutePattern}`);
411
- return;
662
+ return artifacts;
412
663
  }
413
664
  const fileName = plugins.path.basename(absolutePattern);
414
665
  const destPath = plugins.path.join(outputDir, fileName);
415
- await plugins.fs.file(absolutePattern).copy(destPath);
416
- console.log(`Copied: ${pattern} -> ${destPath}`);
666
+ artifacts.push({ sourcePath: absolutePattern, targetPath: destPath });
417
667
  }
668
+ return artifacts;
418
669
  }
419
670
  }
420
671