@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
@@ -0,0 +1,1185 @@
1
+ import * as plugins from './plugins.js';
2
+
3
+ const artifactOwner = '@git.zone/tsbundle';
4
+ const chunkManifestKind = 'chunk-namespace';
5
+ const chunkManifestSchemaVersion = 1;
6
+ const buildStageKind = 'build-stage';
7
+ const buildStageSchemaVersion = 1;
8
+ const lockKind = 'artifact-publication-lock';
9
+ const lockSchemaVersion = 1;
10
+ const lockWaitTimeoutMs = 60_000;
11
+ const lockLeaseTimeoutMs = 60 * 60 * 1000;
12
+ const ownershipHeartbeatIntervalMs = 30_000;
13
+ const staleStageThresholdMs = 24 * 60 * 60 * 1000;
14
+ const buildStageHeartbeats = new Map<string, () => void>();
15
+
16
+ export const chunkManifestFile = '.tsbundle-artifacts.json';
17
+
18
+ interface IChunkManifest {
19
+ owner: string;
20
+ kind: string;
21
+ logicalOutputName: string;
22
+ namespace: string;
23
+ schemaVersion: 1;
24
+ }
25
+
26
+ export interface IProcessOwnership {
27
+ leaseManaged?: boolean;
28
+ processId: number;
29
+ processIdentity?: string;
30
+ }
31
+
32
+ interface IBuildStageMarker extends IProcessOwnership {
33
+ owner: string;
34
+ kind: string;
35
+ createdAt: string;
36
+ invocationId: string;
37
+ schemaVersion: 1;
38
+ }
39
+
40
+ interface IArtifactLockMarker extends IProcessOwnership {
41
+ owner: string;
42
+ kind: string;
43
+ createdAt: string;
44
+ ownerToken: string;
45
+ targetPath: string;
46
+ schemaVersion: 1;
47
+ }
48
+
49
+ interface ILockOwner {
50
+ lockPath: string;
51
+ marker: IArtifactLockMarker;
52
+ ownerPath: string;
53
+ }
54
+
55
+ interface IReaperOwnership {
56
+ createdAt: number;
57
+ processId: number;
58
+ processIdentityToken: string;
59
+ }
60
+
61
+ interface IPreparedArtifact {
62
+ content: Buffer;
63
+ mode: number;
64
+ relativePath?: string;
65
+ targetPath: string;
66
+ }
67
+
68
+ interface IFileSnapshot {
69
+ content?: Buffer;
70
+ existed: boolean;
71
+ mode?: number;
72
+ path: string;
73
+ }
74
+
75
+ export interface IAdditionalArtifact {
76
+ sourcePath: string;
77
+ targetPath: string;
78
+ }
79
+
80
+ interface IPublishGeneratedArtifactsOptions {
81
+ additionalArtifacts?: IAdditionalArtifact[];
82
+ chunkNamespace: string;
83
+ lockTargetPath?: string;
84
+ logicalOutputName: string;
85
+ sourceDirectory: string;
86
+ sourceMainPath: string;
87
+ sourceMapsEnabled: boolean;
88
+ targetPath: string;
89
+ }
90
+
91
+ const hasErrorCode = (errorArg: unknown): errorArg is NodeJS.ErrnoException => (
92
+ typeof errorArg === 'object' && errorArg !== null && 'code' in errorArg
93
+ );
94
+
95
+ const createToken = (): string => plugins.crypto.randomBytes(16).toString('hex');
96
+
97
+ const isProcessAlive = (processIdArg: number): boolean => {
98
+ try {
99
+ process.kill(processIdArg, 0);
100
+ return true;
101
+ } catch (error: unknown) {
102
+ return hasErrorCode(error) && error.code === 'EPERM';
103
+ }
104
+ };
105
+
106
+ export const getProcessIdentity = (processIdArg: number): string | undefined => {
107
+ if (process.platform !== 'linux') {
108
+ return undefined;
109
+ }
110
+ try {
111
+ const processStat = plugins.fsSync.readFileSync(`/proc/${processIdArg}/stat`, 'utf8');
112
+ const commandEnd = processStat.lastIndexOf(')');
113
+ if (commandEnd === -1) {
114
+ return undefined;
115
+ }
116
+ const fieldsAfterCommand = processStat.slice(commandEnd + 2).trim().split(/\s+/);
117
+ const processStartTicks = fieldsAfterCommand[19];
118
+ return processStartTicks ? `linux-proc-start:${processStartTicks}` : undefined;
119
+ } catch {
120
+ return undefined;
121
+ }
122
+ };
123
+
124
+ const encodeProcessIdentity = (processIdentityArg: string): string => (
125
+ plugins.crypto.createHash('sha256').update(processIdentityArg).digest('base64url').slice(0, 16)
126
+ );
127
+
128
+ const getProcessIdentityToken = (processIdArg: number): string => {
129
+ const processIdentity = getProcessIdentity(processIdArg);
130
+ return processIdentity
131
+ ? encodeProcessIdentity(processIdentity)
132
+ : 'lease';
133
+ };
134
+
135
+ export const createProcessOwnership = (): Required<IProcessOwnership> => ({
136
+ leaseManaged: true,
137
+ processId: process.pid,
138
+ processIdentity: getProcessIdentity(process.pid) || '',
139
+ });
140
+
141
+ export const isProcessOwnershipActive = (
142
+ ownershipArg: IProcessOwnership,
143
+ markerPathArg: string,
144
+ leaseTimeoutMsArg: number,
145
+ ): boolean => {
146
+ if (!isProcessAlive(ownershipArg.processId)) {
147
+ return false;
148
+ }
149
+ if (ownershipArg.processIdentity) {
150
+ const currentIdentity = getProcessIdentity(ownershipArg.processId);
151
+ return currentIdentity === undefined || currentIdentity === ownershipArg.processIdentity;
152
+ }
153
+ if (!ownershipArg.leaseManaged) {
154
+ return true;
155
+ }
156
+ try {
157
+ return Date.now() - plugins.fsSync.statSync(markerPathArg).mtimeMs < leaseTimeoutMsArg;
158
+ } catch {
159
+ return false;
160
+ }
161
+ };
162
+
163
+ export const startOwnershipHeartbeat = (markerPathArg: string): (() => void) => {
164
+ const heartbeat = setInterval(() => {
165
+ try {
166
+ const now = new Date();
167
+ plugins.fsSync.utimesSync(markerPathArg, now, now);
168
+ } catch (error: unknown) {
169
+ if (!hasErrorCode(error) || error.code !== 'ENOENT') {
170
+ console.warn(`tsbundle: unable to update ownership lease ${markerPathArg}: ${String(error)}`);
171
+ }
172
+ }
173
+ }, ownershipHeartbeatIntervalMs);
174
+ heartbeat.unref();
175
+ return () => clearInterval(heartbeat);
176
+ };
177
+
178
+ const assertPathNotSymbolicLink = (pathArg: string, descriptionArg: string): void => {
179
+ if (!plugins.fsSync.existsSync(pathArg)) {
180
+ return;
181
+ }
182
+ const stat = plugins.fsSync.lstatSync(pathArg);
183
+ if (stat.isSymbolicLink()) {
184
+ throw new Error(`Refusing to use symbolic-link ${descriptionArg}: ${pathArg}`);
185
+ }
186
+ };
187
+
188
+ const assertRegularFile = (pathArg: string, descriptionArg: string): void => {
189
+ const stat = plugins.fsSync.lstatSync(pathArg);
190
+ if (stat.isSymbolicLink() || !stat.isFile()) {
191
+ throw new Error(`Expected a regular ${descriptionArg}: ${pathArg}`);
192
+ }
193
+ };
194
+
195
+ const sameFile = (firstPathArg: string, secondPathArg: string): boolean => {
196
+ const firstStat = plugins.fsSync.statSync(firstPathArg);
197
+ const secondStat = plugins.fsSync.statSync(secondPathArg);
198
+ return firstStat.dev === secondStat.dev && firstStat.ino === secondStat.ino;
199
+ };
200
+
201
+ const getLockPath = (targetPathArg: string): string => `${targetPathArg}.tsbundle-lock`;
202
+
203
+ const getLockOwnerPath = (lockPathArg: string, ownerTokenArg: string): string => (
204
+ `${lockPathArg}.owner-${ownerTokenArg}`
205
+ );
206
+
207
+ const parseLockMarker = (rawMarkerArg: string, targetPathArg: string): IArtifactLockMarker => {
208
+ const marker = JSON.parse(rawMarkerArg) as Partial<IArtifactLockMarker>;
209
+ if (
210
+ marker.owner !== artifactOwner
211
+ || marker.kind !== lockKind
212
+ || typeof marker.processId !== 'number'
213
+ || !Number.isSafeInteger(marker.processId)
214
+ || marker.processId <= 0
215
+ || (marker.processIdentity !== undefined && typeof marker.processIdentity !== 'string')
216
+ || (marker.leaseManaged !== undefined && typeof marker.leaseManaged !== 'boolean')
217
+ || typeof marker.createdAt !== 'string'
218
+ || typeof marker.ownerToken !== 'string'
219
+ || !/^[a-f0-9]{32}$/.test(marker.ownerToken)
220
+ || typeof marker.targetPath !== 'string'
221
+ || plugins.path.resolve(marker.targetPath) !== plugins.path.resolve(targetPathArg)
222
+ || marker.schemaVersion !== lockSchemaVersion
223
+ ) {
224
+ throw new Error(`Refusing to use invalid artifact publication lock for ${targetPathArg}`);
225
+ }
226
+ return marker as IArtifactLockMarker;
227
+ };
228
+
229
+ const readLockMarker = (lockPathArg: string, targetPathArg: string): IArtifactLockMarker | undefined => {
230
+ try {
231
+ const stat = plugins.fsSync.lstatSync(lockPathArg);
232
+ if (stat.isSymbolicLink() || !stat.isFile()) {
233
+ throw new Error(`Refusing to use unsafe artifact publication lock: ${lockPathArg}`);
234
+ }
235
+ return parseLockMarker(plugins.fsSync.readFileSync(lockPathArg, 'utf8'), targetPathArg);
236
+ } catch (error: unknown) {
237
+ if (hasErrorCode(error) && error.code === 'ENOENT') {
238
+ return undefined;
239
+ }
240
+ throw error;
241
+ }
242
+ };
243
+
244
+ const createLockOwner = (targetPathArg: string): ILockOwner => {
245
+ const lockPath = getLockPath(targetPathArg);
246
+ const marker: IArtifactLockMarker = {
247
+ owner: artifactOwner,
248
+ kind: lockKind,
249
+ ...createProcessOwnership(),
250
+ createdAt: new Date().toISOString(),
251
+ ownerToken: createToken(),
252
+ targetPath: plugins.path.resolve(targetPathArg),
253
+ schemaVersion: lockSchemaVersion,
254
+ };
255
+ const ownerPath = getLockOwnerPath(lockPath, marker.ownerToken);
256
+ try {
257
+ plugins.fsSync.writeFileSync(ownerPath, `${JSON.stringify(marker, null, 2)}\n`, {
258
+ flag: 'wx',
259
+ mode: 0o600,
260
+ });
261
+ return { lockPath, marker, ownerPath };
262
+ } catch (error: unknown) {
263
+ plugins.fsSync.rmSync(ownerPath, { force: true });
264
+ throw error;
265
+ }
266
+ };
267
+
268
+ const cleanupOrphanLockOwners = (targetPathArg: string): void => {
269
+ const lockPath = getLockPath(targetPathArg);
270
+ const directory = plugins.path.dirname(lockPath);
271
+ const lockBasename = plugins.path.basename(lockPath);
272
+ const escapedLockBasename = lockBasename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
273
+ const ownerPattern = new RegExp(
274
+ `^${escapedLockBasename}\\.owner-([a-f0-9]{32})(?:\\.reaping-(\\d+)-([A-Za-z0-9_-]{1,32})-(\\d+)-[a-f0-9]{32})?$`,
275
+ );
276
+ for (const entry of plugins.fsSync.readdirSync(directory, { withFileTypes: true })) {
277
+ const match = ownerPattern.exec(entry.name);
278
+ if (!match || !entry.isFile()) {
279
+ continue;
280
+ }
281
+ const ownerPath = plugins.path.join(directory, entry.name);
282
+ const marker = parseLockMarker(plugins.fsSync.readFileSync(ownerPath, 'utf8'), targetPathArg);
283
+ if (isProcessOwnershipActive(marker, ownerPath, lockLeaseTimeoutMs)) {
284
+ continue;
285
+ }
286
+ const baseOwnerPath = getLockOwnerPath(lockPath, match[1]);
287
+ const reaperOwnership = match[2]
288
+ ? parseReaperOwnership(baseOwnerPath, ownerPath)
289
+ : undefined;
290
+ if (match[2] && !reaperOwnership) {
291
+ throw new Error(`Refusing to use invalid artifact lock reaping path: ${ownerPath}`);
292
+ }
293
+ if (reaperOwnership && isReaperOwnershipActive(reaperOwnership)) {
294
+ continue;
295
+ }
296
+ if (plugins.fsSync.existsSync(lockPath) && sameFile(lockPath, ownerPath)) {
297
+ continue;
298
+ }
299
+ plugins.fsSync.rmSync(ownerPath, { force: true });
300
+ }
301
+ };
302
+
303
+ const getReapingPath = (ownerPathArg: string): string => (
304
+ `${ownerPathArg}.reaping-${process.pid}-${getProcessIdentityToken(process.pid)}-${Date.now()}-${createToken()}`
305
+ );
306
+
307
+ const parseReaperOwnership = (
308
+ ownerPathArg: string,
309
+ reapingPathArg: string,
310
+ ): IReaperOwnership | undefined => {
311
+ const suffix = plugins.path.basename(reapingPathArg).slice(plugins.path.basename(ownerPathArg).length);
312
+ const match = /^\.reaping-(\d+)-([A-Za-z0-9_-]{1,32})-(\d+)-[a-f0-9]{32}$/.exec(suffix);
313
+ if (!match) {
314
+ return undefined;
315
+ }
316
+ const processId = Number(match[1]);
317
+ const createdAt = Number(match[3]);
318
+ if (
319
+ !Number.isSafeInteger(processId)
320
+ || processId <= 0
321
+ || !Number.isSafeInteger(createdAt)
322
+ || createdAt <= 0
323
+ ) {
324
+ return undefined;
325
+ }
326
+ return {
327
+ processId,
328
+ processIdentityToken: match[2],
329
+ createdAt,
330
+ };
331
+ };
332
+
333
+ const isReaperOwnershipActive = (ownershipArg: IReaperOwnership): boolean => {
334
+ if (!isProcessAlive(ownershipArg.processId)) {
335
+ return false;
336
+ }
337
+ if (ownershipArg.processIdentityToken !== 'lease') {
338
+ const currentIdentity = getProcessIdentity(ownershipArg.processId);
339
+ if (currentIdentity) {
340
+ return encodeProcessIdentity(currentIdentity) === ownershipArg.processIdentityToken;
341
+ }
342
+ }
343
+ return Date.now() - ownershipArg.createdAt < lockLeaseTimeoutMs;
344
+ };
345
+
346
+ const claimReapingOwner = (
347
+ lockPathArg: string,
348
+ ownerPathArg: string,
349
+ ): string | undefined => {
350
+ const reapingPath = getReapingPath(ownerPathArg);
351
+ try {
352
+ plugins.fsSync.renameSync(ownerPathArg, reapingPath);
353
+ return reapingPath;
354
+ } catch (error: unknown) {
355
+ if (!hasErrorCode(error) || error.code !== 'ENOENT') {
356
+ throw error;
357
+ }
358
+ }
359
+
360
+ const directory = plugins.path.dirname(ownerPathArg);
361
+ const prefix = `${plugins.path.basename(ownerPathArg)}.reaping-`;
362
+ let entries: string[];
363
+ try {
364
+ entries = plugins.fsSync.readdirSync(directory).filter((entry) => entry.startsWith(prefix));
365
+ } catch (error: unknown) {
366
+ if (hasErrorCode(error) && error.code === 'ENOENT') {
367
+ return undefined;
368
+ }
369
+ throw error;
370
+ }
371
+ for (const entry of entries) {
372
+ const existingReapingPath = plugins.path.join(directory, entry);
373
+ const reaperOwnership = parseReaperOwnership(ownerPathArg, existingReapingPath);
374
+ if (!reaperOwnership) {
375
+ throw new Error(`Refusing to use invalid artifact lock reaping path: ${existingReapingPath}`);
376
+ }
377
+ if (isReaperOwnershipActive(reaperOwnership)) {
378
+ return undefined;
379
+ }
380
+ try {
381
+ plugins.fsSync.renameSync(existingReapingPath, reapingPath);
382
+ return reapingPath;
383
+ } catch (error: unknown) {
384
+ if (hasErrorCode(error) && error.code === 'ENOENT') {
385
+ continue;
386
+ }
387
+ throw error;
388
+ }
389
+ }
390
+
391
+ // The stale lock may already have been removed between inspection and claim.
392
+ if (!plugins.fsSync.existsSync(lockPathArg)) {
393
+ return undefined;
394
+ }
395
+ return undefined;
396
+ };
397
+
398
+ const tryReapStaleLock = (targetPathArg: string): boolean => {
399
+ const lockPath = getLockPath(targetPathArg);
400
+ const marker = readLockMarker(lockPath, targetPathArg);
401
+ if (!marker || isProcessOwnershipActive(marker, lockPath, lockLeaseTimeoutMs)) {
402
+ return false;
403
+ }
404
+ const ownerPath = getLockOwnerPath(lockPath, marker.ownerToken);
405
+ const reapingPath = claimReapingOwner(lockPath, ownerPath);
406
+ if (!reapingPath) {
407
+ return false;
408
+ }
409
+
410
+ try {
411
+ if (plugins.fsSync.existsSync(lockPath) && sameFile(lockPath, reapingPath)) {
412
+ plugins.fsSync.unlinkSync(lockPath);
413
+ }
414
+ } finally {
415
+ plugins.fsSync.rmSync(reapingPath, { force: true });
416
+ }
417
+ return true;
418
+ };
419
+
420
+ const releaseLockOwner = (ownerArg: ILockOwner): void => {
421
+ let canRemoveOwner = false;
422
+ try {
423
+ if (!plugins.fsSync.existsSync(ownerArg.lockPath)) {
424
+ canRemoveOwner = true;
425
+ } else if (
426
+ plugins.fsSync.existsSync(ownerArg.ownerPath)
427
+ && sameFile(ownerArg.lockPath, ownerArg.ownerPath)
428
+ ) {
429
+ plugins.fsSync.unlinkSync(ownerArg.lockPath);
430
+ canRemoveOwner = true;
431
+ } else {
432
+ canRemoveOwner = true;
433
+ }
434
+ } catch (error: unknown) {
435
+ console.warn(`tsbundle: unable to release artifact publication lock ${ownerArg.lockPath}: ${String(error)}`);
436
+ try {
437
+ const abandonedMarker: IArtifactLockMarker = {
438
+ ...ownerArg.marker,
439
+ leaseManaged: false,
440
+ processId: 2_147_483_647,
441
+ processIdentity: '',
442
+ };
443
+ plugins.fsSync.writeFileSync(
444
+ ownerArg.ownerPath,
445
+ `${JSON.stringify(abandonedMarker, null, 2)}\n`,
446
+ { flag: 'w', mode: 0o600 },
447
+ );
448
+ } catch (markerError: unknown) {
449
+ console.warn(`tsbundle: unable to mark artifact lock abandoned ${ownerArg.ownerPath}: ${String(markerError)}`);
450
+ }
451
+ }
452
+ if (canRemoveOwner) {
453
+ try {
454
+ plugins.fsSync.rmSync(ownerArg.ownerPath, { force: true });
455
+ } catch (error: unknown) {
456
+ console.warn(`tsbundle: unable to remove artifact lock owner ${ownerArg.ownerPath}: ${String(error)}`);
457
+ }
458
+ }
459
+ };
460
+
461
+ export const withArtifactPublicationLock = async <T>(
462
+ targetPathArg: string,
463
+ callbackArg: () => Promise<T>,
464
+ ): Promise<T> => {
465
+ const resolvedTargetPath = plugins.path.resolve(targetPathArg);
466
+ plugins.fsSync.mkdirSync(plugins.path.dirname(resolvedTargetPath), { recursive: true });
467
+ cleanupOrphanLockOwners(resolvedTargetPath);
468
+ const owner = createLockOwner(resolvedTargetPath);
469
+ const deadline = Date.now() + lockWaitTimeoutMs;
470
+ let acquired = false;
471
+ let stopHeartbeat: (() => void) | undefined;
472
+ try {
473
+ while (!acquired) {
474
+ try {
475
+ plugins.fsSync.linkSync(owner.ownerPath, owner.lockPath);
476
+ acquired = true;
477
+ stopHeartbeat = startOwnershipHeartbeat(owner.ownerPath);
478
+ } catch (error: unknown) {
479
+ if (!hasErrorCode(error) || error.code !== 'EEXIST') {
480
+ const detail = hasErrorCode(error) ? ` (${error.code})` : '';
481
+ throw new Error(`Unable to acquire hard-link artifact publication lock${detail}: ${owner.lockPath}`);
482
+ }
483
+ tryReapStaleLock(resolvedTargetPath);
484
+ if (Date.now() >= deadline) {
485
+ throw new Error(`Timed out waiting for artifact publication lock: ${owner.lockPath}`);
486
+ }
487
+ await plugins.delay(25);
488
+ }
489
+ }
490
+ return await callbackArg();
491
+ } finally {
492
+ stopHeartbeat?.();
493
+ if (acquired) {
494
+ releaseLockOwner(owner);
495
+ } else {
496
+ plugins.fsSync.rmSync(owner.ownerPath, { force: true });
497
+ }
498
+ }
499
+ };
500
+
501
+ const getArtifactPublicationLockTargets = (
502
+ targetPathArg: string,
503
+ additionalTargetPathsArg: string[] = [],
504
+ ): string[] => {
505
+ const targetPath = plugins.path.resolve(targetPathArg);
506
+ return [...new Set([
507
+ targetPath,
508
+ plugins.path.join(plugins.path.dirname(targetPath), '.tsbundle-output-directory'),
509
+ ...additionalTargetPathsArg.map((additionalPath) => plugins.path.resolve(additionalPath)),
510
+ ])].sort((first, second) => first.localeCompare(second));
511
+ };
512
+
513
+ export const withArtifactPublicationLocks = async <T>(
514
+ targetPathArg: string,
515
+ callbackArg: () => Promise<T>,
516
+ additionalTargetPathsArg: string[] = [],
517
+ ): Promise<T> => {
518
+ const lockTargetPaths = getArtifactPublicationLockTargets(
519
+ targetPathArg,
520
+ additionalTargetPathsArg,
521
+ );
522
+ const acquireLock = async (indexArg: number): Promise<T> => {
523
+ if (indexArg === lockTargetPaths.length) {
524
+ return await callbackArg();
525
+ }
526
+ return await withArtifactPublicationLock(
527
+ lockTargetPaths[indexArg],
528
+ async () => await acquireLock(indexArg + 1),
529
+ );
530
+ };
531
+ return await acquireLock(0);
532
+ };
533
+
534
+ export const writeFileAtomically = (
535
+ targetPathArg: string,
536
+ contentArg: string | Buffer,
537
+ modeArg?: number,
538
+ ): void => {
539
+ const targetPath = plugins.path.resolve(targetPathArg);
540
+ plugins.fsSync.mkdirSync(plugins.path.dirname(targetPath), { recursive: true });
541
+ assertPathNotSymbolicLink(targetPath, 'artifact target');
542
+ if (plugins.fsSync.existsSync(targetPath) && !plugins.fsSync.lstatSync(targetPath).isFile()) {
543
+ throw new Error(`Refusing to replace non-file artifact target: ${targetPath}`);
544
+ }
545
+ const temporaryPath = plugins.path.join(
546
+ plugins.path.dirname(targetPath),
547
+ `.${plugins.path.basename(targetPath)}.${process.pid}.${createToken()}.tsbundle-tmp`,
548
+ );
549
+ try {
550
+ plugins.fsSync.writeFileSync(temporaryPath, contentArg, {
551
+ flag: 'wx',
552
+ mode: modeArg,
553
+ });
554
+ plugins.fsSync.renameSync(temporaryPath, targetPath);
555
+ } finally {
556
+ plugins.fsSync.rmSync(temporaryPath, { force: true });
557
+ }
558
+ };
559
+
560
+ export const getChunkNamespace = (logicalOutputNameArg: string): string => (
561
+ plugins.crypto
562
+ .createHash('sha256')
563
+ .update(logicalOutputNameArg)
564
+ .digest('base64url')
565
+ .slice(0, 24)
566
+ );
567
+
568
+ const getChunkNamespaceDirectory = (directoryArg: string, namespaceArg: string): string => (
569
+ plugins.path.join(directoryArg, 'chunks', namespaceArg)
570
+ );
571
+
572
+ const getChunkManifestPath = (namespaceDirectoryArg: string): string => (
573
+ plugins.path.join(namespaceDirectoryArg, chunkManifestFile)
574
+ );
575
+
576
+ const parseChunkManifest = (rawManifestArg: string): IChunkManifest | undefined => {
577
+ try {
578
+ const manifest = JSON.parse(rawManifestArg) as Partial<IChunkManifest>;
579
+ if (
580
+ manifest.owner === artifactOwner
581
+ && manifest.kind === chunkManifestKind
582
+ && typeof manifest.logicalOutputName === 'string'
583
+ && typeof manifest.namespace === 'string'
584
+ && manifest.schemaVersion === chunkManifestSchemaVersion
585
+ ) {
586
+ return manifest as IChunkManifest;
587
+ }
588
+ } catch {
589
+ return undefined;
590
+ }
591
+ return undefined;
592
+ };
593
+
594
+ const readChunkManifest = (namespaceDirectoryArg: string): IChunkManifest | undefined => {
595
+ const manifestPath = getChunkManifestPath(namespaceDirectoryArg);
596
+ try {
597
+ assertRegularFile(manifestPath, 'chunk ownership manifest');
598
+ return parseChunkManifest(plugins.fsSync.readFileSync(manifestPath, 'utf8'));
599
+ } catch (error: unknown) {
600
+ if (hasErrorCode(error) && error.code === 'ENOENT') {
601
+ return undefined;
602
+ }
603
+ throw error;
604
+ }
605
+ };
606
+
607
+ const assertOwnedChunkNamespace = (
608
+ namespaceDirectoryArg: string,
609
+ logicalOutputNameArg: string,
610
+ namespaceArg: string,
611
+ ): void => {
612
+ const manifest = readChunkManifest(namespaceDirectoryArg);
613
+ if (
614
+ !manifest
615
+ || manifest.logicalOutputName !== logicalOutputNameArg
616
+ || manifest.namespace !== namespaceArg
617
+ ) {
618
+ throw new Error(`Refusing to modify unowned chunk namespace: ${namespaceDirectoryArg}`);
619
+ }
620
+ };
621
+
622
+ const assertSafeArtifactTree = (directoryArg: string): void => {
623
+ if (!plugins.fsSync.existsSync(directoryArg)) {
624
+ return;
625
+ }
626
+ const stat = plugins.fsSync.lstatSync(directoryArg);
627
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
628
+ throw new Error(`Refusing to use unsafe generated artifact directory: ${directoryArg}`);
629
+ }
630
+ for (const entry of plugins.fsSync.readdirSync(directoryArg, { withFileTypes: true })) {
631
+ const entryPath = plugins.path.join(directoryArg, entry.name);
632
+ const entryStat = plugins.fsSync.lstatSync(entryPath);
633
+ if (entryStat.isSymbolicLink()) {
634
+ throw new Error(`Refusing to use symbolic-link generated artifact: ${entryPath}`);
635
+ }
636
+ if (entryStat.isDirectory()) {
637
+ assertSafeArtifactTree(entryPath);
638
+ } else if (!entryStat.isFile()) {
639
+ throw new Error(`Refusing to use non-file generated artifact: ${entryPath}`);
640
+ }
641
+ }
642
+ };
643
+
644
+ const listArtifactFiles = (directoryArg: string): string[] => {
645
+ if (!plugins.fsSync.existsSync(directoryArg)) {
646
+ return [];
647
+ }
648
+ assertSafeArtifactTree(directoryArg);
649
+ const files: string[] = [];
650
+ const collect = (currentDirectoryArg: string): void => {
651
+ for (const entry of plugins.fsSync.readdirSync(currentDirectoryArg, { withFileTypes: true })) {
652
+ const entryPath = plugins.path.join(currentDirectoryArg, entry.name);
653
+ if (entry.isDirectory()) {
654
+ collect(entryPath);
655
+ } else if (entry.isFile() && entry.name !== chunkManifestFile) {
656
+ files.push(entryPath);
657
+ }
658
+ }
659
+ };
660
+ collect(directoryArg);
661
+ return files.sort((first, second) => first.localeCompare(second));
662
+ };
663
+
664
+ const rebaseSourceMap = (
665
+ sourceMapContentArg: Buffer,
666
+ sourceDirectoryArg: string,
667
+ targetDirectoryArg: string,
668
+ targetFileNameArg?: string,
669
+ ): Buffer => {
670
+ const sourceMap = JSON.parse(sourceMapContentArg.toString('utf8')) as {
671
+ file?: string;
672
+ sourceRoot?: string;
673
+ sources?: string[];
674
+ };
675
+ if (Array.isArray(sourceMap.sources)) {
676
+ const sourceRoot = sourceMap.sourceRoot || '';
677
+ sourceMap.sources = sourceMap.sources.map((source) => {
678
+ if (/^(?:[a-z]+:|\/)/i.test(source)) {
679
+ return source;
680
+ }
681
+ const absoluteSource = plugins.path.resolve(sourceDirectoryArg, sourceRoot, source);
682
+ return plugins.path.relative(targetDirectoryArg, absoluteSource).split(plugins.path.sep).join('/');
683
+ });
684
+ if (sourceMap.sourceRoot) {
685
+ sourceMap.sourceRoot = '';
686
+ }
687
+ }
688
+ if (targetFileNameArg) {
689
+ sourceMap.file = targetFileNameArg;
690
+ }
691
+ return Buffer.from(JSON.stringify(sourceMap));
692
+ };
693
+
694
+ const prepareChunkArtifacts = (
695
+ sourceNamespaceDirectoryArg: string,
696
+ targetNamespaceDirectoryArg: string,
697
+ ): IPreparedArtifact[] => listArtifactFiles(sourceNamespaceDirectoryArg).map((sourcePath) => {
698
+ const relativePath = plugins.path.relative(sourceNamespaceDirectoryArg, sourcePath);
699
+ const targetPath = plugins.path.join(targetNamespaceDirectoryArg, relativePath);
700
+ const sourceStat = plugins.fsSync.statSync(sourcePath);
701
+ const sourceContent = plugins.fsSync.readFileSync(sourcePath);
702
+ return {
703
+ content: sourcePath.endsWith('.map')
704
+ ? rebaseSourceMap(sourceContent, sourceNamespaceDirectoryArg, targetNamespaceDirectoryArg)
705
+ : sourceContent,
706
+ mode: sourceStat.mode,
707
+ relativePath,
708
+ targetPath,
709
+ };
710
+ });
711
+
712
+ const snapshotFile = (pathArg: string): IFileSnapshot => {
713
+ if (!plugins.fsSync.existsSync(pathArg)) {
714
+ return { existed: false, path: pathArg };
715
+ }
716
+ assertRegularFile(pathArg, 'destination artifact');
717
+ const stat = plugins.fsSync.statSync(pathArg);
718
+ return {
719
+ content: plugins.fsSync.readFileSync(pathArg),
720
+ existed: true,
721
+ mode: stat.mode,
722
+ path: pathArg,
723
+ };
724
+ };
725
+
726
+ const restoreSnapshot = (snapshotArg: IFileSnapshot): void => {
727
+ if (snapshotArg.existed) {
728
+ writeFileAtomically(snapshotArg.path, snapshotArg.content!, snapshotArg.mode);
729
+ } else {
730
+ assertPathNotSymbolicLink(snapshotArg.path, 'rollback target');
731
+ plugins.fsSync.rmSync(snapshotArg.path, { force: true });
732
+ }
733
+ };
734
+
735
+ const removeEmptyDirectories = (startDirectoryArg: string, stopDirectoryArg: string): void => {
736
+ let currentDirectory = plugins.path.resolve(startDirectoryArg);
737
+ const stopDirectory = plugins.path.resolve(stopDirectoryArg);
738
+ while (currentDirectory !== stopDirectory && currentDirectory.startsWith(`${stopDirectory}${plugins.path.sep}`)) {
739
+ try {
740
+ plugins.fsSync.rmdirSync(currentDirectory);
741
+ } catch (error: unknown) {
742
+ if (hasErrorCode(error) && ['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code || '')) {
743
+ return;
744
+ }
745
+ throw error;
746
+ }
747
+ currentDirectory = plugins.path.dirname(currentDirectory);
748
+ }
749
+ };
750
+
751
+ const prepareAdditionalArtifacts = (
752
+ artifactsArg: IAdditionalArtifact[],
753
+ targetPathArg: string,
754
+ targetMapPathArg: string,
755
+ targetChunksDirectoryArg: string,
756
+ lockTargetPathsArg: string[],
757
+ ): IPreparedArtifact[] => {
758
+ const outputDirectory = plugins.path.dirname(targetPathArg);
759
+ const seenTargets = new Set<string>();
760
+ return artifactsArg.map((artifact) => {
761
+ const sourcePath = plugins.path.resolve(artifact.sourcePath);
762
+ const targetPath = plugins.path.resolve(artifact.targetPath);
763
+ assertRegularFile(sourcePath, 'included source file');
764
+ if (plugins.path.dirname(targetPath) !== outputDirectory) {
765
+ throw new Error(`Included artifact must target the bundle output directory: ${targetPath}`);
766
+ }
767
+ if (
768
+ targetPath === targetPathArg
769
+ || targetPath === targetMapPathArg
770
+ || lockTargetPathsArg.some((lockTargetPath) => (
771
+ targetPath === plugins.path.resolve(lockTargetPath)
772
+ || targetPath === plugins.path.resolve(getLockPath(lockTargetPath))
773
+ ))
774
+ || targetPath === targetChunksDirectoryArg
775
+ || targetPath.startsWith(`${targetChunksDirectoryArg}${plugins.path.sep}`)
776
+ || plugins.path.basename(targetPath).includes('.tsbundle-')
777
+ ) {
778
+ throw new Error(`Included artifact collides with a reserved bundle path: ${targetPath}`);
779
+ }
780
+ if (seenTargets.has(targetPath)) {
781
+ throw new Error(`Multiple included artifacts target the same output path: ${targetPath}`);
782
+ }
783
+ seenTargets.add(targetPath);
784
+ const sourceStat = plugins.fsSync.statSync(sourcePath);
785
+ return {
786
+ content: plugins.fsSync.readFileSync(sourcePath),
787
+ mode: sourceStat.mode,
788
+ targetPath,
789
+ };
790
+ });
791
+ };
792
+
793
+ const ensureChunkNamespace = (
794
+ namespaceDirectoryArg: string,
795
+ logicalOutputNameArg: string,
796
+ namespaceArg: string,
797
+ ): boolean => {
798
+ if (plugins.fsSync.existsSync(namespaceDirectoryArg)) {
799
+ assertSafeArtifactTree(namespaceDirectoryArg);
800
+ assertOwnedChunkNamespace(namespaceDirectoryArg, logicalOutputNameArg, namespaceArg);
801
+ return false;
802
+ }
803
+ plugins.fsSync.mkdirSync(namespaceDirectoryArg, { recursive: true });
804
+ try {
805
+ const manifest: IChunkManifest = {
806
+ owner: artifactOwner,
807
+ kind: chunkManifestKind,
808
+ logicalOutputName: logicalOutputNameArg,
809
+ namespace: namespaceArg,
810
+ schemaVersion: chunkManifestSchemaVersion,
811
+ };
812
+ writeFileAtomically(getChunkManifestPath(namespaceDirectoryArg), `${JSON.stringify(manifest, null, 2)}\n`);
813
+ } catch (error: unknown) {
814
+ plugins.fsSync.rmSync(namespaceDirectoryArg, { recursive: true, force: true });
815
+ throw error;
816
+ }
817
+ return true;
818
+ };
819
+
820
+ const reconcileChunkNamespace = (
821
+ namespaceDirectoryArg: string,
822
+ chunksDirectoryArg: string,
823
+ desiredRelativePathsArg: Set<string>,
824
+ logicalOutputNameArg: string,
825
+ namespaceArg: string,
826
+ ): void => {
827
+ if (!plugins.fsSync.existsSync(namespaceDirectoryArg)) {
828
+ return;
829
+ }
830
+ assertSafeArtifactTree(namespaceDirectoryArg);
831
+ assertOwnedChunkNamespace(namespaceDirectoryArg, logicalOutputNameArg, namespaceArg);
832
+ if (desiredRelativePathsArg.size === 0) {
833
+ plugins.fsSync.rmSync(namespaceDirectoryArg, { recursive: true, force: true });
834
+ } else {
835
+ for (const artifactPath of listArtifactFiles(namespaceDirectoryArg)) {
836
+ const relativePath = plugins.path.relative(namespaceDirectoryArg, artifactPath);
837
+ if (!desiredRelativePathsArg.has(relativePath)) {
838
+ plugins.fsSync.rmSync(artifactPath, { force: true });
839
+ removeEmptyDirectories(plugins.path.dirname(artifactPath), namespaceDirectoryArg);
840
+ }
841
+ }
842
+ }
843
+ try {
844
+ plugins.fsSync.rmdirSync(chunksDirectoryArg);
845
+ } catch (error: unknown) {
846
+ if (!hasErrorCode(error) || !['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code || '')) {
847
+ throw error;
848
+ }
849
+ }
850
+ };
851
+
852
+ export const publishGeneratedArtifacts = async (
853
+ optionsArg: IPublishGeneratedArtifactsOptions,
854
+ ): Promise<void> => {
855
+ const sourceDirectory = plugins.path.resolve(optionsArg.sourceDirectory);
856
+ const sourceMainPath = plugins.path.resolve(optionsArg.sourceMainPath);
857
+ const targetPath = plugins.path.resolve(optionsArg.targetPath);
858
+ const targetDirectory = plugins.path.dirname(targetPath);
859
+ const targetMapPath = `${targetPath}.map`;
860
+ const additionalLockTargets = optionsArg.lockTargetPath
861
+ ? [optionsArg.lockTargetPath]
862
+ : [];
863
+ const lockTargetPaths = getArtifactPublicationLockTargets(targetPath, additionalLockTargets);
864
+ const sourceNamespaceDirectory = getChunkNamespaceDirectory(sourceDirectory, optionsArg.chunkNamespace);
865
+ const targetChunksDirectory = plugins.path.join(targetDirectory, 'chunks');
866
+ const targetNamespaceDirectory = getChunkNamespaceDirectory(targetDirectory, optionsArg.chunkNamespace);
867
+
868
+ const publishUnderLocks = async (): Promise<void> => {
869
+ assertRegularFile(sourceMainPath, 'generated main bundle');
870
+ assertPathNotSymbolicLink(targetPath, 'bundle target');
871
+ assertPathNotSymbolicLink(targetMapPath, 'bundle source map target');
872
+ assertPathNotSymbolicLink(targetChunksDirectory, 'chunk directory');
873
+ if (plugins.fsSync.existsSync(targetPath)) {
874
+ assertRegularFile(targetPath, 'bundle target');
875
+ }
876
+ if (plugins.fsSync.existsSync(targetNamespaceDirectory)) {
877
+ assertSafeArtifactTree(targetNamespaceDirectory);
878
+ assertOwnedChunkNamespace(
879
+ targetNamespaceDirectory,
880
+ optionsArg.logicalOutputName,
881
+ optionsArg.chunkNamespace,
882
+ );
883
+ }
884
+
885
+ const sourceMainStat = plugins.fsSync.statSync(sourceMainPath);
886
+ let mainContent = plugins.fsSync.readFileSync(sourceMainPath, 'utf8');
887
+ const sourceMainName = plugins.path.basename(sourceMainPath);
888
+ const targetMainName = plugins.path.basename(targetPath);
889
+ const sourceMapPattern = new RegExp(
890
+ `//# sourceMappingURL=${sourceMainName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.map`,
891
+ 'g',
892
+ );
893
+ mainContent = mainContent.replace(sourceMapPattern, `//# sourceMappingURL=${targetMainName}.map`);
894
+
895
+ const sourceMapPath = `${sourceMainPath}.map`;
896
+ let preparedMainMap: Buffer | undefined;
897
+ let sourceMapMode: number | undefined;
898
+ if (optionsArg.sourceMapsEnabled) {
899
+ assertRegularFile(sourceMapPath, 'generated source map');
900
+ preparedMainMap = rebaseSourceMap(
901
+ plugins.fsSync.readFileSync(sourceMapPath),
902
+ sourceDirectory,
903
+ targetDirectory,
904
+ targetMainName,
905
+ );
906
+ sourceMapMode = plugins.fsSync.statSync(sourceMapPath).mode;
907
+ }
908
+
909
+ const preparedChunks = prepareChunkArtifacts(sourceNamespaceDirectory, targetNamespaceDirectory);
910
+ const desiredChunkPaths = new Set(preparedChunks.map((artifact) => artifact.relativePath!));
911
+ for (const artifact of preparedChunks) {
912
+ if (plugins.fsSync.existsSync(artifact.targetPath)) {
913
+ assertRegularFile(artifact.targetPath, 'generated chunk target');
914
+ if (!plugins.fsSync.readFileSync(artifact.targetPath).equals(artifact.content)) {
915
+ throw new Error(`Generated chunk name collision with different content: ${artifact.targetPath}`);
916
+ }
917
+ }
918
+ }
919
+
920
+ const preparedAdditionalArtifacts = prepareAdditionalArtifacts(
921
+ optionsArg.additionalArtifacts || [],
922
+ targetPath,
923
+ targetMapPath,
924
+ targetChunksDirectory,
925
+ lockTargetPaths,
926
+ );
927
+ const additionalSnapshots = preparedAdditionalArtifacts.map((artifact) => snapshotFile(artifact.targetPath));
928
+ const mapSnapshot = snapshotFile(targetMapPath);
929
+ const newlyCreatedChunkPaths: string[] = [];
930
+ let namespaceCreated = false;
931
+ let mainCommitted = false;
932
+
933
+ try {
934
+ if (preparedChunks.length > 0) {
935
+ namespaceCreated = ensureChunkNamespace(
936
+ targetNamespaceDirectory,
937
+ optionsArg.logicalOutputName,
938
+ optionsArg.chunkNamespace,
939
+ );
940
+ for (const artifact of preparedChunks) {
941
+ if (plugins.fsSync.existsSync(artifact.targetPath)) {
942
+ continue;
943
+ }
944
+ writeFileAtomically(artifact.targetPath, artifact.content, artifact.mode);
945
+ newlyCreatedChunkPaths.push(artifact.targetPath);
946
+ }
947
+ }
948
+
949
+ for (const artifact of preparedAdditionalArtifacts) {
950
+ writeFileAtomically(artifact.targetPath, artifact.content, artifact.mode);
951
+ }
952
+ if (preparedMainMap) {
953
+ writeFileAtomically(targetMapPath, preparedMainMap, sourceMapMode);
954
+ }
955
+ writeFileAtomically(targetPath, mainContent, sourceMainStat.mode);
956
+ mainCommitted = true;
957
+ } catch (error: unknown) {
958
+ const rollbackErrors: unknown[] = [];
959
+ for (const snapshot of [...additionalSnapshots].reverse()) {
960
+ try {
961
+ restoreSnapshot(snapshot);
962
+ } catch (rollbackError: unknown) {
963
+ rollbackErrors.push(rollbackError);
964
+ }
965
+ }
966
+ try {
967
+ restoreSnapshot(mapSnapshot);
968
+ } catch (rollbackError: unknown) {
969
+ rollbackErrors.push(rollbackError);
970
+ }
971
+ try {
972
+ if (namespaceCreated) {
973
+ plugins.fsSync.rmSync(targetNamespaceDirectory, { recursive: true, force: true });
974
+ try {
975
+ plugins.fsSync.rmdirSync(targetChunksDirectory);
976
+ } catch (cleanupError: unknown) {
977
+ if (
978
+ !hasErrorCode(cleanupError)
979
+ || !['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(cleanupError.code || '')
980
+ ) {
981
+ throw cleanupError;
982
+ }
983
+ }
984
+ } else {
985
+ for (const chunkPath of [...newlyCreatedChunkPaths].reverse()) {
986
+ plugins.fsSync.rmSync(chunkPath, { force: true });
987
+ removeEmptyDirectories(plugins.path.dirname(chunkPath), targetNamespaceDirectory);
988
+ }
989
+ }
990
+ } catch (rollbackError: unknown) {
991
+ rollbackErrors.push(rollbackError);
992
+ }
993
+ if (rollbackErrors.length > 0) {
994
+ throw new AggregateError([error, ...rollbackErrors], 'Artifact publication failed and rollback was incomplete.');
995
+ }
996
+ throw error;
997
+ }
998
+
999
+ if (mainCommitted) {
1000
+ try {
1001
+ if (!optionsArg.sourceMapsEnabled) {
1002
+ assertPathNotSymbolicLink(targetMapPath, 'stale source map target');
1003
+ plugins.fsSync.rmSync(targetMapPath, { force: true });
1004
+ }
1005
+ reconcileChunkNamespace(
1006
+ targetNamespaceDirectory,
1007
+ targetChunksDirectory,
1008
+ desiredChunkPaths,
1009
+ optionsArg.logicalOutputName,
1010
+ optionsArg.chunkNamespace,
1011
+ );
1012
+ } catch (error: unknown) {
1013
+ console.warn(`tsbundle: output committed but stale artifact cleanup was incomplete: ${String(error)}`);
1014
+ }
1015
+ }
1016
+ };
1017
+
1018
+ await withArtifactPublicationLocks(targetPath, publishUnderLocks, additionalLockTargets);
1019
+ };
1020
+
1021
+ const assertBuildStageDirectory = (stageDirectoryArg: string, outputDirectoryArg: string): void => {
1022
+ const stageDirectory = plugins.path.resolve(stageDirectoryArg);
1023
+ const outputDirectory = plugins.path.resolve(outputDirectoryArg);
1024
+ if (
1025
+ plugins.path.dirname(stageDirectory) !== outputDirectory
1026
+ || !plugins.path.basename(stageDirectory).startsWith('.tsbundle-stage-')
1027
+ ) {
1028
+ throw new Error(`Refusing to use unexpected build stage: ${stageDirectoryArg}`);
1029
+ }
1030
+ const stat = plugins.fsSync.lstatSync(stageDirectory);
1031
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
1032
+ throw new Error(`Refusing to use unsafe build stage: ${stageDirectoryArg}`);
1033
+ }
1034
+ };
1035
+
1036
+ const getBuildStageMarkerPath = (stageDirectoryArg: string): string => (
1037
+ plugins.path.join(stageDirectoryArg, '.tsbundle-stage.json')
1038
+ );
1039
+
1040
+ const parseBuildStageMarker = (rawMarkerArg: string): IBuildStageMarker | undefined => {
1041
+ try {
1042
+ const marker = JSON.parse(rawMarkerArg) as Partial<IBuildStageMarker>;
1043
+ if (
1044
+ marker.owner === artifactOwner
1045
+ && marker.kind === buildStageKind
1046
+ && typeof marker.processId === 'number'
1047
+ && Number.isSafeInteger(marker.processId)
1048
+ && marker.processId > 0
1049
+ && (marker.processIdentity === undefined || typeof marker.processIdentity === 'string')
1050
+ && (marker.leaseManaged === undefined || typeof marker.leaseManaged === 'boolean')
1051
+ && typeof marker.createdAt === 'string'
1052
+ && typeof marker.invocationId === 'string'
1053
+ && /^[A-Za-z0-9._-]{1,128}$/.test(marker.invocationId)
1054
+ && marker.schemaVersion === buildStageSchemaVersion
1055
+ ) {
1056
+ return marker as IBuildStageMarker;
1057
+ }
1058
+ } catch {
1059
+ return undefined;
1060
+ }
1061
+ return undefined;
1062
+ };
1063
+
1064
+ const readBuildStageMarker = (stageDirectoryArg: string): IBuildStageMarker | undefined => {
1065
+ try {
1066
+ const markerPath = getBuildStageMarkerPath(stageDirectoryArg);
1067
+ assertRegularFile(markerPath, 'build stage marker');
1068
+ return parseBuildStageMarker(plugins.fsSync.readFileSync(markerPath, 'utf8'));
1069
+ } catch (error: unknown) {
1070
+ if (hasErrorCode(error) && error.code === 'ENOENT') {
1071
+ return undefined;
1072
+ }
1073
+ throw error;
1074
+ }
1075
+ };
1076
+
1077
+ const buildStageMarkersMatch = (
1078
+ firstMarkerArg: IBuildStageMarker,
1079
+ secondMarkerArg: IBuildStageMarker,
1080
+ ): boolean => (
1081
+ firstMarkerArg.owner === secondMarkerArg.owner
1082
+ && firstMarkerArg.kind === secondMarkerArg.kind
1083
+ && firstMarkerArg.processId === secondMarkerArg.processId
1084
+ && firstMarkerArg.processIdentity === secondMarkerArg.processIdentity
1085
+ && firstMarkerArg.leaseManaged === secondMarkerArg.leaseManaged
1086
+ && firstMarkerArg.createdAt === secondMarkerArg.createdAt
1087
+ && firstMarkerArg.invocationId === secondMarkerArg.invocationId
1088
+ && firstMarkerArg.schemaVersion === secondMarkerArg.schemaVersion
1089
+ );
1090
+
1091
+ export const cleanupStaleBuildStages = (outputDirectoryArg: string): void => {
1092
+ const outputDirectory = plugins.path.resolve(outputDirectoryArg);
1093
+ if (!plugins.fsSync.existsSync(outputDirectory)) {
1094
+ return;
1095
+ }
1096
+ for (const entry of plugins.fsSync.readdirSync(outputDirectory, { withFileTypes: true })) {
1097
+ if (!entry.isDirectory() || !entry.name.startsWith('.tsbundle-stage-')) {
1098
+ continue;
1099
+ }
1100
+ const stageDirectory = plugins.path.join(outputDirectory, entry.name);
1101
+ let marker: IBuildStageMarker | undefined;
1102
+ try {
1103
+ assertBuildStageDirectory(stageDirectory, outputDirectory);
1104
+ marker = readBuildStageMarker(stageDirectory);
1105
+ } catch (error: unknown) {
1106
+ if (hasErrorCode(error) && error.code === 'ENOENT') {
1107
+ continue;
1108
+ }
1109
+ throw error;
1110
+ }
1111
+ if (!marker) {
1112
+ continue;
1113
+ }
1114
+ const createdAt = Date.parse(marker.createdAt);
1115
+ if (
1116
+ !Number.isFinite(createdAt)
1117
+ || Date.now() - createdAt < staleStageThresholdMs
1118
+ || isProcessOwnershipActive(
1119
+ marker,
1120
+ getBuildStageMarkerPath(stageDirectory),
1121
+ staleStageThresholdMs,
1122
+ )
1123
+ ) {
1124
+ continue;
1125
+ }
1126
+ const currentMarker = readBuildStageMarker(stageDirectory);
1127
+ if (currentMarker && buildStageMarkersMatch(marker, currentMarker)) {
1128
+ plugins.fsSync.rmSync(stageDirectory, { recursive: true, force: true });
1129
+ }
1130
+ }
1131
+ };
1132
+
1133
+ export const createBuildStage = (
1134
+ outputDirectoryArg: string,
1135
+ ): { marker: IBuildStageMarker; stageDirectory: string } => {
1136
+ const outputDirectory = plugins.path.resolve(outputDirectoryArg);
1137
+ plugins.fsSync.mkdirSync(outputDirectory, { recursive: true });
1138
+ assertPathNotSymbolicLink(outputDirectory, 'build output directory');
1139
+ cleanupStaleBuildStages(outputDirectory);
1140
+ const stageDirectory = plugins.fsSync.mkdtempSync(plugins.path.join(outputDirectory, '.tsbundle-stage-'));
1141
+ try {
1142
+ assertBuildStageDirectory(stageDirectory, outputDirectory);
1143
+ const marker: IBuildStageMarker = {
1144
+ owner: artifactOwner,
1145
+ kind: buildStageKind,
1146
+ ...createProcessOwnership(),
1147
+ createdAt: new Date().toISOString(),
1148
+ invocationId: `${process.pid}-${Date.now()}-${createToken()}`,
1149
+ schemaVersion: buildStageSchemaVersion,
1150
+ };
1151
+ plugins.fsSync.writeFileSync(
1152
+ getBuildStageMarkerPath(stageDirectory),
1153
+ `${JSON.stringify(marker, null, 2)}\n`,
1154
+ { flag: 'wx', mode: 0o600 },
1155
+ );
1156
+ buildStageHeartbeats.set(
1157
+ stageDirectory,
1158
+ startOwnershipHeartbeat(getBuildStageMarkerPath(stageDirectory)),
1159
+ );
1160
+ return { marker, stageDirectory };
1161
+ } catch (error: unknown) {
1162
+ plugins.fsSync.rmSync(stageDirectory, { recursive: true, force: true });
1163
+ throw error;
1164
+ }
1165
+ };
1166
+
1167
+ export const cleanupBuildStage = (
1168
+ stageDirectoryArg: string,
1169
+ outputDirectoryArg: string,
1170
+ expectedMarkerArg: IBuildStageMarker,
1171
+ ): void => {
1172
+ buildStageHeartbeats.get(stageDirectoryArg)?.();
1173
+ buildStageHeartbeats.delete(stageDirectoryArg);
1174
+ try {
1175
+ assertBuildStageDirectory(stageDirectoryArg, outputDirectoryArg);
1176
+ const currentMarker = readBuildStageMarker(stageDirectoryArg);
1177
+ if (currentMarker && buildStageMarkersMatch(currentMarker, expectedMarkerArg)) {
1178
+ plugins.fsSync.rmSync(stageDirectoryArg, { recursive: true, force: true });
1179
+ }
1180
+ } catch (error: unknown) {
1181
+ if (!hasErrorCode(error) || error.code !== 'ENOENT') {
1182
+ throw error;
1183
+ }
1184
+ }
1185
+ };