@appium/support 7.0.5 → 7.0.6

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 (114) hide show
  1. package/LICENSE +201 -0
  2. package/build/lib/console.d.ts +42 -88
  3. package/build/lib/console.d.ts.map +1 -1
  4. package/build/lib/console.js +20 -80
  5. package/build/lib/console.js.map +1 -1
  6. package/build/lib/doctor.d.ts +6 -18
  7. package/build/lib/doctor.d.ts.map +1 -1
  8. package/build/lib/doctor.js +0 -15
  9. package/build/lib/doctor.js.map +1 -1
  10. package/build/lib/env.d.ts +14 -20
  11. package/build/lib/env.d.ts.map +1 -1
  12. package/build/lib/env.js +13 -50
  13. package/build/lib/env.js.map +1 -1
  14. package/build/lib/fs.d.ts +109 -148
  15. package/build/lib/fs.d.ts.map +1 -1
  16. package/build/lib/fs.js +88 -188
  17. package/build/lib/fs.js.map +1 -1
  18. package/build/lib/image-util.d.ts +7 -6
  19. package/build/lib/image-util.d.ts.map +1 -1
  20. package/build/lib/image-util.js +9 -6
  21. package/build/lib/image-util.js.map +1 -1
  22. package/build/lib/index.d.ts +19 -17
  23. package/build/lib/index.d.ts.map +1 -1
  24. package/build/lib/logger.d.ts +1 -1
  25. package/build/lib/logger.d.ts.map +1 -1
  26. package/build/lib/logger.js +1 -1
  27. package/build/lib/logger.js.map +1 -1
  28. package/build/lib/logging.d.ts +7 -15
  29. package/build/lib/logging.d.ts.map +1 -1
  30. package/build/lib/logging.js +36 -62
  31. package/build/lib/logging.js.map +1 -1
  32. package/build/lib/mjpeg.d.ts +19 -56
  33. package/build/lib/mjpeg.d.ts.map +1 -1
  34. package/build/lib/mjpeg.js +53 -76
  35. package/build/lib/mjpeg.js.map +1 -1
  36. package/build/lib/mkdirp.d.ts +4 -1
  37. package/build/lib/mkdirp.d.ts.map +1 -1
  38. package/build/lib/mkdirp.js +1 -2
  39. package/build/lib/mkdirp.js.map +1 -1
  40. package/build/lib/net.d.ts +52 -90
  41. package/build/lib/net.d.ts.map +1 -1
  42. package/build/lib/net.js +104 -193
  43. package/build/lib/net.js.map +1 -1
  44. package/build/lib/node.d.ts +16 -17
  45. package/build/lib/node.d.ts.map +1 -1
  46. package/build/lib/node.js +106 -111
  47. package/build/lib/node.js.map +1 -1
  48. package/build/lib/npm.d.ts +65 -86
  49. package/build/lib/npm.d.ts.map +1 -1
  50. package/build/lib/npm.js +59 -117
  51. package/build/lib/npm.js.map +1 -1
  52. package/build/lib/plist.d.ts +36 -29
  53. package/build/lib/plist.d.ts.map +1 -1
  54. package/build/lib/plist.js +62 -59
  55. package/build/lib/plist.js.map +1 -1
  56. package/build/lib/process.d.ts +19 -2
  57. package/build/lib/process.d.ts.map +1 -1
  58. package/build/lib/process.js +24 -7
  59. package/build/lib/process.js.map +1 -1
  60. package/build/lib/system.d.ts +41 -6
  61. package/build/lib/system.d.ts.map +1 -1
  62. package/build/lib/system.js +46 -11
  63. package/build/lib/system.js.map +1 -1
  64. package/build/lib/tempdir.d.ts +26 -49
  65. package/build/lib/tempdir.d.ts.map +1 -1
  66. package/build/lib/tempdir.js +41 -73
  67. package/build/lib/tempdir.js.map +1 -1
  68. package/build/lib/timing.d.ts +28 -22
  69. package/build/lib/timing.d.ts.map +1 -1
  70. package/build/lib/timing.js +16 -17
  71. package/build/lib/timing.js.map +1 -1
  72. package/build/lib/util.d.ts +164 -181
  73. package/build/lib/util.d.ts.map +1 -1
  74. package/build/lib/util.js +193 -247
  75. package/build/lib/util.js.map +1 -1
  76. package/build/lib/zip.d.ts +81 -139
  77. package/build/lib/zip.d.ts.map +1 -1
  78. package/build/lib/zip.js +210 -258
  79. package/build/lib/zip.js.map +1 -1
  80. package/lib/console.ts +139 -0
  81. package/lib/{doctor.js → doctor.ts} +6 -20
  82. package/lib/{env.js → env.ts} +31 -59
  83. package/lib/fs.ts +453 -0
  84. package/lib/image-util.ts +40 -0
  85. package/lib/index.ts +1 -0
  86. package/lib/{logger.js → logger.ts} +1 -1
  87. package/lib/logging.ts +157 -0
  88. package/lib/mjpeg.ts +186 -0
  89. package/lib/{mkdirp.js → mkdirp.ts} +2 -2
  90. package/lib/net.ts +305 -0
  91. package/lib/{node.js → node.ts} +134 -133
  92. package/lib/npm.ts +291 -0
  93. package/lib/plist.ts +187 -0
  94. package/lib/process.ts +62 -0
  95. package/lib/system.ts +95 -0
  96. package/lib/tempdir.ts +115 -0
  97. package/lib/{timing.js → timing.ts} +28 -33
  98. package/lib/util.ts +561 -0
  99. package/lib/{zip.js → zip.ts} +341 -296
  100. package/package.json +20 -22
  101. package/tsconfig.json +3 -5
  102. package/index.js +0 -1
  103. package/lib/console.js +0 -173
  104. package/lib/fs.js +0 -496
  105. package/lib/image-util.js +0 -32
  106. package/lib/logging.js +0 -145
  107. package/lib/mjpeg.js +0 -207
  108. package/lib/net.js +0 -336
  109. package/lib/npm.js +0 -310
  110. package/lib/plist.js +0 -182
  111. package/lib/process.js +0 -46
  112. package/lib/system.js +0 -48
  113. package/lib/tempdir.js +0 -131
  114. package/lib/util.js +0 -584
package/lib/util.ts ADDED
@@ -0,0 +1,561 @@
1
+ import B from 'bluebird';
2
+ import _ from 'lodash';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import stream from 'node:stream';
6
+ import {promisify} from 'node:util';
7
+ import {asyncmap} from 'asyncbox';
8
+ import {fs} from './fs';
9
+ import * as semver from 'semver';
10
+ import {quote as shellQuote, parse as shellParse} from 'shell-quote';
11
+ export {shellParse};
12
+ import pluralizeLib from 'pluralize';
13
+ import {Base64Encode} from 'base64-stream';
14
+ export {v1 as uuidV1, v3 as uuidV3, v4 as uuidV4, v5 as uuidV5} from 'uuid';
15
+ import * as _lockfile from 'lockfile';
16
+ import type {Element} from '@appium/types';
17
+
18
+ /** W3C WebDriver element identifier key used in element objects. */
19
+ export const W3C_WEB_ELEMENT_IDENTIFIER = 'element-6066-11e4-a52e-4f735466cecf';
20
+
21
+ /** Size of one kibibyte in bytes (1024). */
22
+ export const KiB = 1024;
23
+ /** Size of one mebibyte in bytes (1024 * 1024). */
24
+ export const MiB = KiB * 1024;
25
+ /** Size of one gibibyte in bytes (1024 * 1024 * 1024). */
26
+ export const GiB = MiB * 1024;
27
+
28
+ /** A string which is never `''`. */
29
+ export type NonEmptyString<T extends string = string> = T extends '' ? never : T;
30
+
31
+ /**
32
+ * Type guard: returns true if the value is a non-empty string.
33
+ *
34
+ * @param val - Value to check
35
+ * @returns `true` if `val` is a string with at least one character
36
+ */
37
+ export function hasContent(val: unknown): val is NonEmptyString {
38
+ return _.isString(val) && val !== '';
39
+ }
40
+
41
+ /**
42
+ * Type guard: returns true if the value is not `undefined`, `null`, or `NaN`.
43
+ *
44
+ * @param val - Value to check
45
+ * @returns `true` if `val` is non-null and non-undefined (and not NaN for numbers)
46
+ */
47
+ export function hasValue<T>(val: T): val is NonNullable<T> {
48
+ if (_.isNumber(val)) {
49
+ return !_.isNaN(val);
50
+ }
51
+ return !_.isUndefined(val) && !_.isNull(val);
52
+ }
53
+
54
+ /**
55
+ * Escapes spaces in a string for use in command-line arguments (e.g. ` ` → `\ `).
56
+ *
57
+ * @param str - String that may contain spaces
58
+ * @returns String with spaces escaped by a backslash
59
+ */
60
+ export function escapeSpace(str: string): string {
61
+ return str.split(/ /).join('\\ ');
62
+ }
63
+
64
+ /**
65
+ * Escapes special characters in a string (backslash, slash, quotes, control chars).
66
+ * If `quoteEscape` is provided, that character is also escaped.
67
+ *
68
+ * @param str - String to escape, or non-string value (returned unchanged)
69
+ * @param quoteEscape - Optional character to escape, or `false` to skip
70
+ * @returns Escaped string, or original value if `str` is not a string
71
+ */
72
+ export function escapeSpecialChars(
73
+ str: string | unknown,
74
+ quoteEscape?: string | false
75
+ ): string | unknown {
76
+ if (typeof str !== 'string') {
77
+ return str;
78
+ }
79
+
80
+ const result = str
81
+ .replace(/[\\]/g, '\\\\')
82
+ .replace(/[/]/g, '\\/')
83
+ .replace(/[\b]/g, '\\b')
84
+ .replace(/[\f]/g, '\\f')
85
+ .replace(/[\n]/g, '\\n')
86
+ .replace(/[\r]/g, '\\r')
87
+ .replace(/[\t]/g, '\\t')
88
+ .replace(/["]/g, '\\"')
89
+ .replace(/\\'/g, "\\'");
90
+ if (!quoteEscape) {
91
+ return result;
92
+ }
93
+ const re = new RegExp(quoteEscape, 'g');
94
+ return result.replace(re, `\\${quoteEscape}`);
95
+ }
96
+
97
+ /**
98
+ * Returns the first non-internal IPv4 address of the machine, if any.
99
+ *
100
+ * @returns The local IPv4 address, or `undefined` if none found
101
+ */
102
+ export function localIp(): string | undefined {
103
+ const ifaces = os.networkInterfaces();
104
+ for (const addrs of Object.values(ifaces)) {
105
+ if (!addrs) {
106
+ continue;
107
+ }
108
+ for (const iface of addrs) {
109
+ if (iface.family === 'IPv4' && !iface.internal) {
110
+ return iface.address;
111
+ }
112
+ }
113
+ }
114
+ return undefined;
115
+ }
116
+
117
+ /**
118
+ * Creates a promise that resolves after a delay and can be cancelled via `.cancel()`.
119
+ *
120
+ * @param ms - Delay in milliseconds before the promise resolves
121
+ * @returns A Bluebird promise with a `cancel()` method; cancel rejects with CancellationError
122
+ */
123
+ // TODO: replace with a native implementation in Appium 4
124
+ export function cancellableDelay(ms: number): B<void> & {cancel: () => void} {
125
+ let timer: NodeJS.Timeout;
126
+ let resolve: () => void;
127
+ let reject: (err: Error) => void;
128
+
129
+ const delay = new B<void>((_resolve, _reject) => {
130
+ resolve = _resolve;
131
+ reject = _reject;
132
+ timer = setTimeout(() => resolve(), ms);
133
+ });
134
+
135
+ (delay as B<void> & {cancel: () => void}).cancel = function () {
136
+ clearTimeout(timer);
137
+ reject(new B.CancellationError());
138
+ };
139
+ return delay as B<void> & {cancel: () => void};
140
+ }
141
+
142
+ /**
143
+ * Resolves each root path with the given path segments, returning an array of absolute paths.
144
+ *
145
+ * @param roots - Base directory paths to resolve against
146
+ * @param args - Path segments to join with each root (e.g. 'foo', 'bar' → root/foo/bar)
147
+ * @returns Array of absolute paths, one per root
148
+ */
149
+ export function multiResolve(roots: string[], ...args: string[]): string[] {
150
+ return roots.map((root) => path.resolve(root, ...args));
151
+ }
152
+
153
+ /**
154
+ * Parses a value as JSON if it is a string; otherwise returns the value as-is.
155
+ *
156
+ * @param obj - String (to parse) or other value (returned unchanged)
157
+ * @returns Parsed object or original value
158
+ */
159
+ export function safeJsonParse<T>(obj: unknown): T {
160
+ try {
161
+ return JSON.parse(obj as string) as T;
162
+ } catch {
163
+ return obj as T;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Stringifies an object to JSON, converting Buffers to strings for readable output.
169
+ *
170
+ * @param obj - Object to serialize
171
+ * @param replacer - Optional replacer function (same as JSON.stringify)
172
+ * @param space - Indentation for pretty-printing. Defaults to 2
173
+ * @returns JSON string
174
+ */
175
+ export function jsonStringify(
176
+ obj: unknown,
177
+ replacer: ((key: string, value: unknown) => unknown) | null = null,
178
+ space: number | string = 2
179
+ ): string {
180
+ const replacerFunc = _.isFunction(replacer) ? replacer : (_k: string, v: unknown) => v;
181
+
182
+ const bufferToJSON = Buffer.prototype.toJSON;
183
+ delete (Buffer.prototype as Record<string, unknown>).toJSON;
184
+ try {
185
+ return JSON.stringify(
186
+ obj,
187
+ (key, value) => {
188
+ const updatedValue = Buffer.isBuffer(value) ? value.toString('utf8') : value;
189
+ return replacerFunc(key, updatedValue);
190
+ },
191
+ space
192
+ );
193
+ } finally {
194
+ (Buffer.prototype as Record<string, unknown>).toJSON = bufferToJSON;
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Extracts the element ID from a W3C or JSONWP element object, or returns the string if already an ID.
200
+ *
201
+ * @param el - Element object (with ELEMENT or W3C identifier) or raw element ID string
202
+ * @returns The element ID string
203
+ */
204
+ export function unwrapElement(el: Element | string): string {
205
+ const elObj = el as unknown as Record<string, string>;
206
+ for (const propName of [W3C_WEB_ELEMENT_IDENTIFIER, 'ELEMENT']) {
207
+ if (_.has(elObj, propName)) {
208
+ return elObj[propName];
209
+ }
210
+ }
211
+ return el as string;
212
+ }
213
+
214
+ /**
215
+ * Wraps an element ID string in an element object compatible with both W3C and JSONWP.
216
+ *
217
+ * @param elementId - The element ID to wrap
218
+ * @returns Element object with both ELEMENT and W3C identifier keys
219
+ */
220
+ export function wrapElement(elementId: string): Element {
221
+ return {
222
+ ELEMENT: elementId,
223
+ [W3C_WEB_ELEMENT_IDENTIFIER]: elementId,
224
+ } as Element;
225
+ }
226
+
227
+ /**
228
+ * Returns a copy of the object containing only properties that pass the predicate.
229
+ * If the predicate is missing, removes properties whose values are undefined.
230
+ * If the predicate is a scalar, keeps only properties whose value equals that scalar.
231
+ * If the predicate is a function, calls it for each (value, obj) and keeps properties where it returns true.
232
+ *
233
+ * @param obj - Source object to filter
234
+ * @param predicate - Optional filter: undefined (drop undefined values), scalar (value match), or function
235
+ * @returns New object with only the properties that pass the predicate
236
+ */
237
+ export function filterObject<T extends Record<string, unknown>>(
238
+ obj: T,
239
+ predicate?: ((value: unknown, obj: T) => boolean) | unknown
240
+ ): Partial<T> {
241
+ const newObj = _.clone(obj) as Record<string, unknown>;
242
+ let pred: (v: unknown, o: T) => boolean;
243
+ if (_.isUndefined(predicate)) {
244
+ pred = (v) => !_.isUndefined(v);
245
+ } else if (!_.isFunction(predicate)) {
246
+ const valuePredicate = predicate;
247
+ pred = (v) => v === valuePredicate;
248
+ } else {
249
+ pred = predicate as (v: unknown, o: T) => boolean;
250
+ }
251
+ for (const key of Object.keys(obj)) {
252
+ if (!pred(obj[key], obj)) {
253
+ delete newObj[key];
254
+ }
255
+ }
256
+ return newObj as Partial<T>;
257
+ }
258
+
259
+ /**
260
+ * Converts a byte count to a human-readable size string (e.g. "1.50 MB").
261
+ *
262
+ * @param bytes - Number of bytes (or string coercible to a number)
263
+ * @returns Formatted string like "123 B", "1.50 KB", "2.00 MB", "3.00 GB"
264
+ * @throws {Error} If bytes cannot be converted to a non-negative integer
265
+ */
266
+ export function toReadableSizeString(bytes: number | string): string {
267
+ const intBytes = parseInt(String(bytes), 10);
268
+ if (isNaN(intBytes) || intBytes < 0) {
269
+ throw new Error(`Cannot convert '${bytes}' to a readable size format`);
270
+ }
271
+ if (intBytes >= GiB) {
272
+ return `${(intBytes / (GiB * 1.0)).toFixed(2)} GB`;
273
+ } else if (intBytes >= MiB) {
274
+ return `${(intBytes / (MiB * 1.0)).toFixed(2)} MB`;
275
+ } else if (intBytes >= KiB) {
276
+ return `${(intBytes / (KiB * 1.0)).toFixed(2)} KB`;
277
+ }
278
+ return `${intBytes} B`;
279
+ }
280
+
281
+ /**
282
+ * Checks whether the given path is a subpath of the given root folder.
283
+ *
284
+ * @param originalPath - The absolute file or folder path to test
285
+ * @param root - The absolute root folder path
286
+ * @param forcePosix - If true, interpret paths in POSIX format (e.g. on Windows)
287
+ * @returns `true` if `originalPath` is under `root`
288
+ * @throws {Error} If either path is not absolute
289
+ */
290
+ export function isSubPath(
291
+ originalPath: string,
292
+ root: string,
293
+ forcePosix: boolean | null = null
294
+ ): boolean {
295
+ const pathObj = forcePosix ? path.posix : path;
296
+ for (const p of [originalPath, root]) {
297
+ if (!pathObj.isAbsolute(p)) {
298
+ throw new Error(`'${p}' is expected to be an absolute path`);
299
+ }
300
+ }
301
+ const normalizedRoot = pathObj.normalize(root);
302
+ const normalizedPath = pathObj.normalize(originalPath);
303
+ return normalizedPath.startsWith(normalizedRoot);
304
+ }
305
+
306
+ /**
307
+ * Checks whether the given paths refer to the same file system entity (same inode).
308
+ * All paths must exist.
309
+ *
310
+ * @param path1 - First path
311
+ * @param path2 - Second path
312
+ * @param pathN - Additional paths to compare
313
+ * @returns `true` if all paths resolve to the same file/directory
314
+ */
315
+ export async function isSameDestination(
316
+ path1: string,
317
+ path2: string,
318
+ ...pathN: string[]
319
+ ): Promise<boolean> {
320
+ const allPaths = [path1, path2, ...pathN];
321
+ if (!(await asyncmap(allPaths, async (p) => fs.exists(p))).every(Boolean)) {
322
+ return false;
323
+ }
324
+
325
+ const areAllItemsEqual = (arr: unknown[]) => !!arr.reduce((a, b) => (a === b ? a : NaN));
326
+ if (areAllItemsEqual(allPaths)) {
327
+ return true;
328
+ }
329
+
330
+ const mapCb = async (x: string) => (await fs.stat(x, {bigint: true})).ino;
331
+ return areAllItemsEqual(await asyncmap(allPaths, mapCb));
332
+ }
333
+
334
+ /**
335
+ * Coerces a value to a valid semver string (e.g. "1.0" → "1.0.0").
336
+ *
337
+ * @param ver - Version string or number to coerce
338
+ * @param strict - If true, throws when coercion fails; if false, returns null
339
+ * @returns Valid semver string, or null when strict is false and coercion fails
340
+ * @throws {Error} When strict is true and ver cannot be coerced
341
+ */
342
+ export function coerceVersion(ver: string, strict: true): string;
343
+ export function coerceVersion(ver: string, strict?: false): string | null;
344
+ export function coerceVersion(ver: string, strict = true): string | null {
345
+ let result = semver.valid(`${ver}`);
346
+ if (!result) {
347
+ result = semver.valid(semver.coerce(`${ver}`));
348
+ }
349
+ if (strict && !result) {
350
+ throw new Error(`'${ver}' cannot be coerced to a valid version number`);
351
+ }
352
+ return result;
353
+ }
354
+
355
+ const SUPPORTED_OPERATORS = ['==', '!=', '>', '<', '>=', '<=', '='];
356
+
357
+ /**
358
+ * Compares two version strings using the given operator.
359
+ *
360
+ * @param ver1 - First version string
361
+ * @param operator - One of: ==, !=, >, <, >=, <=, =
362
+ * @param ver2 - Second version string
363
+ * @returns `true` if ver1 operator ver2 holds (e.g. "2.0.0" >= "1.0.0")
364
+ * @throws {Error} If operator is unsupported or either version cannot be coerced
365
+ */
366
+ export function compareVersions(
367
+ ver1: string,
368
+ operator: string,
369
+ ver2: string
370
+ ): boolean {
371
+ if (!SUPPORTED_OPERATORS.includes(operator)) {
372
+ throw new Error(
373
+ `The '${operator}' comparison operator is not supported. ` +
374
+ `Only '${JSON.stringify(SUPPORTED_OPERATORS)}' operators are supported`
375
+ );
376
+ }
377
+
378
+ const semverOperator = ['==', '!='].includes(operator) ? '=' : operator;
379
+ const v1 = coerceVersion(ver1, true);
380
+ const v2 = coerceVersion(ver2, true);
381
+ const result = semver.satisfies(v1, `${semverOperator}${v2}`);
382
+ return operator === '!=' ? !result : result;
383
+ }
384
+
385
+ /**
386
+ * Quotes and escapes command-line arguments so they can be safely passed to a shell.
387
+ *
388
+ * @param args - Single argument or array of arguments to quote
389
+ * @returns Quoted string suitable for shell parsing
390
+ */
391
+ export function quote(args: string | string[]): string {
392
+ return shellQuote(_.castArray(args));
393
+ }
394
+
395
+ /** Options for pluralize(). */
396
+ export interface PluralizeOptions {
397
+ /** If true, prefix the result with the count (e.g. "3 ducks"). */
398
+ inclusive?: boolean;
399
+ }
400
+
401
+ /**
402
+ * Returns the plural or singular form of a word appropriate to the count (e.g. "duck" + 1 → "duck", + 2 → "ducks").
403
+ *
404
+ * @param word - The word to pluralize (or singularize when count is 1)
405
+ * @param count - The count used to choose singular vs plural
406
+ * @param options - Options object or boolean: use `inclusive: true` (or `true`) to prefix with the number (e.g. "3 ducks")
407
+ * @returns The correctly inflected word, optionally prefixed with the count
408
+ */
409
+ export function pluralize(
410
+ word: string,
411
+ count: number,
412
+ options: PluralizeOptions | boolean = {}
413
+ ): string {
414
+ let inclusive = false;
415
+ if (_.isBoolean(options)) {
416
+ inclusive = options;
417
+ } else if (_.isBoolean(options?.inclusive)) {
418
+ inclusive = options.inclusive;
419
+ }
420
+ return pluralizeLib(word, count, inclusive);
421
+ }
422
+
423
+ /** Options for toInMemoryBase64(). */
424
+ export interface EncodingOptions {
425
+ /** Maximum size of the resulting buffer in bytes. Default 1GB. */
426
+ maxSize?: number;
427
+ }
428
+
429
+ /**
430
+ * Reads a file and returns its contents as a base64-encoded buffer.
431
+ *
432
+ * @param srcPath - Full path to the file to encode
433
+ * @param opts - Encoding options (e.g. maxSize to cap buffer size)
434
+ * @returns Buffer containing the base64-encoded file content
435
+ * @throws {Error} If the file does not exist, is a directory, cannot be read, or exceeds maxSize
436
+ */
437
+ export async function toInMemoryBase64(
438
+ srcPath: string,
439
+ opts: EncodingOptions = {}
440
+ ): Promise<Buffer> {
441
+ if (!(await fs.exists(srcPath)) || (await fs.stat(srcPath)).isDirectory()) {
442
+ throw new Error(`No such file: ${srcPath}`);
443
+ }
444
+
445
+ const {maxSize = 1 * GiB} = opts;
446
+ const resultBuffers: Buffer[] = [];
447
+ let resultBuffersSize = 0;
448
+ const resultWriteStream = new stream.Writable({
449
+ write(buffer: Buffer, _encoding: string, next: (err?: Error) => void) {
450
+ resultBuffers.push(buffer);
451
+ resultBuffersSize += buffer.length;
452
+ if (maxSize > 0 && resultBuffersSize > maxSize) {
453
+ resultWriteStream.emit(
454
+ 'error',
455
+ new Error(
456
+ `The size of the resulting buffer must not be greater than ${toReadableSizeString(maxSize)}`
457
+ )
458
+ );
459
+ }
460
+ next();
461
+ },
462
+ });
463
+
464
+ const readerStream = fs.createReadStream(srcPath);
465
+ const base64EncoderStream = new Base64Encode();
466
+ const encoderWritable = base64EncoderStream as NodeJS.WritableStream;
467
+ const encoderReadable = base64EncoderStream as NodeJS.ReadableStream;
468
+ const resultWriteStreamPromise = new Promise<void>((resolve, reject) => {
469
+ resultWriteStream.once('error', (e: Error) => {
470
+ readerStream.unpipe(encoderWritable);
471
+ encoderReadable.unpipe(resultWriteStream);
472
+ readerStream.destroy();
473
+ reject(e);
474
+ });
475
+ resultWriteStream.once('finish', () => resolve());
476
+ });
477
+ const readStreamPromise = new Promise<void>((resolve, reject) => {
478
+ readerStream.once('close', () => resolve());
479
+ readerStream.once('error', (e: Error) =>
480
+ reject(new Error(`Failed to read '${srcPath}': ${e.message}`))
481
+ );
482
+ });
483
+ readerStream.pipe(encoderWritable);
484
+ encoderReadable.pipe(resultWriteStream);
485
+
486
+ await Promise.all([readStreamPromise, resultWriteStreamPromise]);
487
+ return Buffer.concat(resultBuffers);
488
+ }
489
+
490
+ /** Options for getLockFileGuard(). */
491
+ export interface LockFileOptions {
492
+ /** Max time in seconds to wait for the lock. Default 120. */
493
+ timeout?: number;
494
+ /** If true, attempt to unlock and retry once if the first acquisition times out (e.g. stale lock). */
495
+ tryRecovery?: boolean;
496
+ }
497
+
498
+ /** Guard function that runs the given behavior under the lock. */
499
+ type LockFileGuardFn<T> = (behavior: () => Promise<T> | T) => Promise<T>;
500
+
501
+ /** Return type of getLockFileGuard: guard function with a .check() method. */
502
+ type LockFileGuard<T> = LockFileGuardFn<T> & {check: () => Promise<boolean>};
503
+
504
+ /**
505
+ * Creates a guard that serializes access to a critical section using a lock file.
506
+ * The returned function acquires the lock, runs the given behavior, then releases the lock.
507
+ * Also exposes `.check()` to test whether the lock is currently held.
508
+ *
509
+ * @param lockFile - Full path to the lock file
510
+ * @param opts - Options (see {@link LockFileOptions})
511
+ * @returns Async function that accepts a callback to run under the lock, plus a `.check()` method
512
+ */
513
+ export function getLockFileGuard<T>(
514
+ lockFile: string,
515
+ opts: LockFileOptions = {}
516
+ ): LockFileGuard<T> {
517
+ const {timeout = 120, tryRecovery = false} = opts;
518
+
519
+ const lock = promisify(_lockfile.lock) as (
520
+ lockfile: string,
521
+ opts: {wait: number}
522
+ ) => Promise<void>;
523
+ const checkLock = promisify(_lockfile.check) as (lockfile: string) => Promise<boolean>;
524
+ const unlock = promisify(_lockfile.unlock) as (lockfile: string) => Promise<void>;
525
+
526
+ const guard: LockFileGuard<T> = Object.assign(
527
+ async (behavior: () => Promise<T> | T): Promise<T> => {
528
+ let triedRecovery = false;
529
+ let acquired = false;
530
+ while (!acquired) {
531
+ try {
532
+ if (_lockfile.checkSync(lockFile)) {
533
+ await lock(lockFile, {wait: timeout * 1000});
534
+ } else {
535
+ _lockfile.lockSync(lockFile);
536
+ }
537
+ acquired = true;
538
+ } catch (e) {
539
+ const err = e as Error;
540
+ if (_.includes(err.message, 'EEXIST') && tryRecovery && !triedRecovery) {
541
+ _lockfile.unlockSync(lockFile);
542
+ triedRecovery = true;
543
+ } else {
544
+ throw new Error(
545
+ `Could not acquire lock on '${lockFile}' after ${timeout}s. ` +
546
+ `Original error: ${err.message}`
547
+ );
548
+ }
549
+ }
550
+ }
551
+ try {
552
+ return await behavior();
553
+ } finally {
554
+ await unlock(lockFile);
555
+ }
556
+ },
557
+ {check: () => checkLock(lockFile)}
558
+ );
559
+
560
+ return guard;
561
+ }