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