@alchemy.run/node-utils 0.0.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.
@@ -0,0 +1,502 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { exitHook } from "./exit-hook.ts";
4
+ import type { MtimePrecision } from "./mtime-precision.ts";
5
+ import * as mtimePrecision from "./mtime-precision.ts";
6
+ import * as retry from "./retry.ts";
7
+
8
+ type AnyFs = Record<string, any>;
9
+
10
+ export interface LockOptions {
11
+ stale?: number;
12
+ update?: number | null;
13
+ realpath?: boolean;
14
+ retries?: number | retry.OperationOptions;
15
+ fs?: AnyFs;
16
+ onCompromised?: (err: Error) => void;
17
+ lockfilePath?: string;
18
+ }
19
+
20
+ interface ResolvedLockOptions {
21
+ stale: number;
22
+ update: number;
23
+ realpath: boolean;
24
+ retries: retry.OperationOptions;
25
+ fs: AnyFs;
26
+ onCompromised: (err: Error) => void;
27
+ lockfilePath?: string;
28
+ }
29
+
30
+ export interface UnlockOptions {
31
+ realpath?: boolean;
32
+ fs?: AnyFs;
33
+ lockfilePath?: string;
34
+ }
35
+
36
+ export interface CheckOptions {
37
+ stale?: number;
38
+ realpath?: boolean;
39
+ fs?: AnyFs;
40
+ lockfilePath?: string;
41
+ }
42
+
43
+ interface InternalLock {
44
+ lockfilePath: string;
45
+ mtime: Date;
46
+ mtimePrecision: MtimePrecision;
47
+ options: ResolvedLockOptions;
48
+ lastUpdate: number;
49
+ updateDelay?: number | null;
50
+ updateTimeout?: ReturnType<typeof setTimeout> | null;
51
+ released?: boolean;
52
+ }
53
+
54
+ type ReleaseCallback = (err?: NodeJS.ErrnoException | null) => void;
55
+ export type ReleaseFn = (releasedCallback?: ReleaseCallback) => void;
56
+
57
+ type LockCallback = (
58
+ err: NodeJS.ErrnoException | null,
59
+ release?: ReleaseFn,
60
+ ) => void;
61
+ type UnlockCallback = (err?: NodeJS.ErrnoException | null) => void;
62
+ type CheckCallback = (
63
+ err: NodeJS.ErrnoException | null,
64
+ locked?: boolean,
65
+ ) => void;
66
+ type AcquireCallback = (
67
+ err: NodeJS.ErrnoException | null,
68
+ mtime?: Date,
69
+ mtimePrecision?: MtimePrecision,
70
+ ) => void;
71
+
72
+ const locks: Record<string, InternalLock> = {};
73
+
74
+ function getLockFile(file: string, options: { lockfilePath?: string }): string {
75
+ return options.lockfilePath || `${file}.lock`;
76
+ }
77
+
78
+ function resolveCanonicalPath(
79
+ file: string,
80
+ options: { realpath?: boolean; fs: AnyFs },
81
+ callback: (err: NodeJS.ErrnoException | null, file: string) => void,
82
+ ): void {
83
+ if (!options.realpath) {
84
+ return callback(null, path.resolve(file));
85
+ }
86
+
87
+ // Use realpath to resolve symlinks
88
+ // It also resolves relative paths
89
+ options.fs.realpath(file, callback);
90
+ }
91
+
92
+ function acquireLock(
93
+ file: string,
94
+ options: ResolvedLockOptions,
95
+ callback: AcquireCallback,
96
+ ): void {
97
+ const lockfilePath = getLockFile(file, options);
98
+
99
+ // Use mkdir to create the lockfile (atomic operation)
100
+ options.fs.mkdir(lockfilePath, (err: NodeJS.ErrnoException | null) => {
101
+ if (!err) {
102
+ // At this point, we acquired the lock!
103
+ // Probe the mtime precision
104
+ return mtimePrecision.probe(
105
+ lockfilePath,
106
+ options.fs as any,
107
+ (err, mtime, precision) => {
108
+ /* istanbul ignore if */
109
+ if (err) {
110
+ options.fs.rmdir(lockfilePath, () => {});
111
+
112
+ return callback(err);
113
+ }
114
+
115
+ callback(null, mtime, precision);
116
+ },
117
+ );
118
+ }
119
+
120
+ // If error is not EEXIST then some other error occurred while locking
121
+ if (err.code !== "EEXIST") {
122
+ return callback(err);
123
+ }
124
+
125
+ // Otherwise, check if lock is stale by analyzing the file mtime
126
+ if (options.stale <= 0) {
127
+ return callback(
128
+ Object.assign(new Error("Lock file is already being held"), {
129
+ code: "ELOCKED",
130
+ file,
131
+ }),
132
+ );
133
+ }
134
+
135
+ options.fs.stat(
136
+ lockfilePath,
137
+ (err: NodeJS.ErrnoException | null, stat: import("node:fs").Stats) => {
138
+ if (err) {
139
+ // Retry if the lockfile has been removed (meanwhile)
140
+ // Skip stale check to avoid recursiveness
141
+ if (err.code === "ENOENT") {
142
+ return acquireLock(file, { ...options, stale: 0 }, callback);
143
+ }
144
+
145
+ return callback(err);
146
+ }
147
+
148
+ if (!isLockStale(stat, options)) {
149
+ return callback(
150
+ Object.assign(new Error("Lock file is already being held"), {
151
+ code: "ELOCKED",
152
+ file,
153
+ }),
154
+ );
155
+ }
156
+
157
+ // If it's stale, remove it and try again!
158
+ // Skip stale check to avoid recursiveness
159
+ removeLock(file, options, (err) => {
160
+ if (err) {
161
+ return callback(err);
162
+ }
163
+
164
+ acquireLock(file, { ...options, stale: 0 }, callback);
165
+ });
166
+ },
167
+ );
168
+ });
169
+ }
170
+
171
+ function isLockStale(
172
+ stat: import("node:fs").Stats,
173
+ options: { stale: number },
174
+ ): boolean {
175
+ return stat.mtime.getTime() < Date.now() - options.stale;
176
+ }
177
+
178
+ function removeLock(
179
+ file: string,
180
+ options: { fs: AnyFs; lockfilePath?: string },
181
+ callback: (err?: NodeJS.ErrnoException | null) => void,
182
+ ): void {
183
+ options.fs.rmdir(
184
+ getLockFile(file, options),
185
+ (err: NodeJS.ErrnoException | null) => {
186
+ if (err && err.code !== "ENOENT") {
187
+ return callback(err);
188
+ }
189
+
190
+ callback();
191
+ },
192
+ );
193
+ }
194
+
195
+ function updateLock(file: string, options: ResolvedLockOptions): void {
196
+ const lock = locks[file];
197
+
198
+ /* istanbul ignore if */
199
+ if (lock.updateTimeout) {
200
+ return;
201
+ }
202
+
203
+ lock.updateDelay = lock.updateDelay || options.update;
204
+ lock.updateTimeout = setTimeout(() => {
205
+ lock.updateTimeout = null;
206
+
207
+ // Stat the file to check if mtime is still ours
208
+ // If it is, we can still recover from a system sleep or a busy event loop
209
+ options.fs.stat(
210
+ lock.lockfilePath,
211
+ (err: NodeJS.ErrnoException | null, stat: import("node:fs").Stats) => {
212
+ const isOverThreshold = lock.lastUpdate + options.stale < Date.now();
213
+
214
+ // If it failed to update the lockfile, keep trying unless
215
+ // the lockfile was deleted or we are over the threshold
216
+ if (err) {
217
+ if (err.code === "ENOENT" || isOverThreshold) {
218
+ return setLockAsCompromised(
219
+ file,
220
+ lock,
221
+ Object.assign(err, { code: "ECOMPROMISED" }),
222
+ );
223
+ }
224
+
225
+ lock.updateDelay = 1000;
226
+
227
+ return updateLock(file, options);
228
+ }
229
+
230
+ const isMtimeOurs = lock.mtime.getTime() === stat.mtime.getTime();
231
+
232
+ if (!isMtimeOurs) {
233
+ return setLockAsCompromised(
234
+ file,
235
+ lock,
236
+ Object.assign(
237
+ new Error("Unable to update lock within the stale threshold"),
238
+ { code: "ECOMPROMISED" },
239
+ ),
240
+ );
241
+ }
242
+
243
+ const mtime = mtimePrecision.getMtime(lock.mtimePrecision);
244
+
245
+ options.fs.utimes(
246
+ lock.lockfilePath,
247
+ mtime,
248
+ mtime,
249
+ (err: NodeJS.ErrnoException | null) => {
250
+ const isOverThreshold =
251
+ lock.lastUpdate + options.stale < Date.now();
252
+
253
+ if (lock.released) {
254
+ return;
255
+ }
256
+
257
+ // If it failed to update the lockfile, keep trying unless
258
+ // the lockfile was deleted or we are over the threshold
259
+ if (err) {
260
+ if (err.code === "ENOENT" || isOverThreshold) {
261
+ return setLockAsCompromised(
262
+ file,
263
+ lock,
264
+ Object.assign(err, { code: "ECOMPROMISED" }),
265
+ );
266
+ }
267
+
268
+ lock.updateDelay = 1000;
269
+
270
+ return updateLock(file, options);
271
+ }
272
+
273
+ // All ok, keep updating..
274
+ lock.mtime = mtime;
275
+ lock.lastUpdate = Date.now();
276
+ lock.updateDelay = null;
277
+ updateLock(file, options);
278
+ },
279
+ );
280
+ },
281
+ );
282
+ }, lock.updateDelay);
283
+
284
+ // Unref the timer so that the nodejs process can exit freely
285
+ // This is safe because all acquired locks will be automatically released
286
+ // on process exit
287
+
288
+ // We first check that `lock.updateTimeout.unref` exists because some users
289
+ // may be using this module outside of NodeJS (e.g., in an electron app),
290
+ // and in those cases `setTimeout` returns an integer.
291
+ /* istanbul ignore else */
292
+ if (
293
+ lock.updateTimeout &&
294
+ (lock.updateTimeout as { unref?: () => void }).unref
295
+ ) {
296
+ (lock.updateTimeout as { unref: () => void }).unref();
297
+ }
298
+ }
299
+
300
+ function setLockAsCompromised(
301
+ file: string,
302
+ lock: InternalLock,
303
+ err: Error,
304
+ ): void {
305
+ lock.released = true;
306
+
307
+ // Cancel lock mtime update
308
+ /* istanbul ignore if */
309
+ if (lock.updateTimeout) {
310
+ clearTimeout(lock.updateTimeout);
311
+ }
312
+
313
+ if (locks[file] === lock) {
314
+ delete locks[file];
315
+ }
316
+
317
+ lock.options.onCompromised(err);
318
+ }
319
+
320
+ // ----------------------------------------------------------
321
+
322
+ export function lock(
323
+ file: string,
324
+ options: LockOptions | undefined,
325
+ callback: LockCallback,
326
+ ): void {
327
+ /* istanbul ignore next */
328
+ const merged = {
329
+ stale: 10000,
330
+ update: null as number | null,
331
+ realpath: true,
332
+ retries: 0 as number | retry.OperationOptions,
333
+ fs: fs as AnyFs,
334
+ onCompromised: ((err: Error) => {
335
+ throw err;
336
+ }) as (err: Error) => void,
337
+ ...options,
338
+ };
339
+
340
+ const retriesValue = merged.retries || 0;
341
+ const resolved: ResolvedLockOptions = {
342
+ stale: Math.max(merged.stale || 0, 2000),
343
+ update: 0,
344
+ realpath: merged.realpath,
345
+ retries:
346
+ typeof retriesValue === "number"
347
+ ? { retries: retriesValue }
348
+ : retriesValue,
349
+ fs: merged.fs,
350
+ onCompromised: merged.onCompromised,
351
+ lockfilePath: merged.lockfilePath,
352
+ };
353
+
354
+ const updateRaw =
355
+ merged.update == null ? resolved.stale / 2 : merged.update || 0;
356
+ resolved.update = Math.max(Math.min(updateRaw, resolved.stale / 2), 1000);
357
+
358
+ // Resolve to a canonical file path
359
+ resolveCanonicalPath(file, resolved, (err, file) => {
360
+ if (err) {
361
+ return callback(err);
362
+ }
363
+
364
+ // Attempt to acquire the lock
365
+ const operation = retry.operation(resolved.retries);
366
+
367
+ operation.attempt(() => {
368
+ acquireLock(file, resolved, (err, mtime, precision) => {
369
+ if (operation.retry(err as Error)) {
370
+ return;
371
+ }
372
+
373
+ if (err) {
374
+ return callback(operation.mainError());
375
+ }
376
+
377
+ // We now own the lock
378
+ const internalLock: InternalLock = (locks[file] = {
379
+ lockfilePath: getLockFile(file, resolved),
380
+ mtime: mtime as Date,
381
+ mtimePrecision: precision as MtimePrecision,
382
+ options: resolved,
383
+ lastUpdate: Date.now(),
384
+ });
385
+
386
+ // We must keep the lock fresh to avoid staleness
387
+ updateLock(file, resolved);
388
+
389
+ callback(null, (releasedCallback) => {
390
+ if (internalLock.released) {
391
+ return (
392
+ releasedCallback &&
393
+ releasedCallback(
394
+ Object.assign(new Error("Lock is already released"), {
395
+ code: "ERELEASED",
396
+ }),
397
+ )
398
+ );
399
+ }
400
+
401
+ // Not necessary to use realpath twice when unlocking
402
+ unlock(
403
+ file,
404
+ { ...resolved, realpath: false },
405
+ releasedCallback || (() => {}),
406
+ );
407
+ });
408
+ });
409
+ });
410
+ });
411
+ }
412
+
413
+ export function unlock(
414
+ file: string,
415
+ options: UnlockOptions | undefined,
416
+ callback: UnlockCallback,
417
+ ): void {
418
+ const resolved = {
419
+ fs: fs as AnyFs,
420
+ realpath: true,
421
+ ...options,
422
+ };
423
+
424
+ // Resolve to a canonical file path
425
+ resolveCanonicalPath(file, resolved, (err, file) => {
426
+ if (err) {
427
+ return callback(err);
428
+ }
429
+
430
+ const lock = locks[file];
431
+
432
+ if (!lock) {
433
+ return callback(
434
+ Object.assign(new Error("Lock is not acquired/owned by you"), {
435
+ code: "ENOTACQUIRED",
436
+ }),
437
+ );
438
+ }
439
+
440
+ if (lock.updateTimeout) {
441
+ clearTimeout(lock.updateTimeout);
442
+ }
443
+ lock.released = true;
444
+ delete locks[file];
445
+
446
+ removeLock(file, resolved, callback);
447
+ });
448
+ }
449
+
450
+ export function check(
451
+ file: string,
452
+ options: CheckOptions | undefined,
453
+ callback: CheckCallback,
454
+ ): void {
455
+ const resolved = {
456
+ stale: 10000,
457
+ realpath: true,
458
+ fs: fs as AnyFs,
459
+ ...options,
460
+ };
461
+
462
+ resolved.stale = Math.max(resolved.stale || 0, 2000);
463
+
464
+ // Resolve to a canonical file path
465
+ resolveCanonicalPath(file, resolved, (err, file) => {
466
+ if (err) {
467
+ return callback(err);
468
+ }
469
+
470
+ // Check if lockfile exists
471
+ resolved.fs.stat(
472
+ getLockFile(file, resolved),
473
+ (err: NodeJS.ErrnoException | null, stat: import("node:fs").Stats) => {
474
+ if (err) {
475
+ // If does not exist, file is not locked. Otherwise, callback with error
476
+ return err.code === "ENOENT" ? callback(null, false) : callback(err);
477
+ }
478
+
479
+ // Otherwise, check if lock is stale by analyzing the file mtime
480
+ return callback(null, !isLockStale(stat, resolved));
481
+ },
482
+ );
483
+ });
484
+ }
485
+
486
+ export function getLocks(): Record<string, InternalLock> {
487
+ return locks;
488
+ }
489
+
490
+ // Remove acquired locks on exit
491
+ /* istanbul ignore next */
492
+ exitHook(() => {
493
+ for (const file in locks) {
494
+ const options = locks[file].options;
495
+
496
+ try {
497
+ options.fs.rmdirSync(getLockFile(file, options));
498
+ } catch (e) {
499
+ /* Empty */
500
+ }
501
+ }
502
+ });
@@ -0,0 +1,83 @@
1
+ import type { Stats } from "node:fs";
2
+
3
+ export type MtimePrecision = "s" | "ms";
4
+
5
+ export interface MtimePrecisionFs {
6
+ stat(
7
+ path: string,
8
+ callback: (err: NodeJS.ErrnoException | null, stats: Stats) => void,
9
+ ): void;
10
+ utimes(
11
+ path: string,
12
+ atime: Date,
13
+ mtime: Date,
14
+ callback: (err: NodeJS.ErrnoException | null) => void,
15
+ ): void;
16
+ }
17
+
18
+ type ProbeCallback = (
19
+ err: NodeJS.ErrnoException | null,
20
+ mtime?: Date,
21
+ precision?: MtimePrecision,
22
+ ) => void;
23
+
24
+ const cacheSymbol = Symbol();
25
+
26
+ interface CachedFs extends MtimePrecisionFs {
27
+ [cacheSymbol]?: MtimePrecision;
28
+ }
29
+
30
+ export function probe(
31
+ file: string,
32
+ fs: MtimePrecisionFs,
33
+ callback: ProbeCallback,
34
+ ): void {
35
+ const cachedFs = fs as CachedFs;
36
+ const cachedPrecision = cachedFs[cacheSymbol];
37
+
38
+ if (cachedPrecision) {
39
+ return fs.stat(file, (err, stat) => {
40
+ /* istanbul ignore if */
41
+ if (err) {
42
+ return callback(err);
43
+ }
44
+
45
+ callback(null, stat.mtime, cachedPrecision);
46
+ });
47
+ }
48
+
49
+ // Set mtime by ceiling Date.now() to seconds + 5ms so that it's "not on the second"
50
+ const mtime = new Date(Math.ceil(Date.now() / 1000) * 1000 + 5);
51
+
52
+ fs.utimes(file, mtime, mtime, (err) => {
53
+ /* istanbul ignore if */
54
+ if (err) {
55
+ return callback(err);
56
+ }
57
+
58
+ fs.stat(file, (err, stat) => {
59
+ /* istanbul ignore if */
60
+ if (err) {
61
+ return callback(err);
62
+ }
63
+
64
+ const precision: MtimePrecision =
65
+ stat.mtime.getTime() % 1000 === 0 ? "s" : "ms";
66
+
67
+ // Cache the precision in a non-enumerable way
68
+ Object.defineProperty(fs, cacheSymbol, { value: precision });
69
+
70
+ callback(null, stat.mtime, precision);
71
+ });
72
+ });
73
+ }
74
+
75
+ export function getMtime(precision: MtimePrecision): Date {
76
+ let now = Date.now();
77
+
78
+ if (precision === "s") {
79
+ now = Math.ceil(now / 1000) * 1000;
80
+ }
81
+
82
+ return new Date(now);
83
+ }