• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

homer0 / packages / 4422859008

pending completion
4422859008

push

github

homer0
fix(eslint-plugin): use project as an array in the preset

720 of 720 branches covered (100.0%)

Branch coverage included in aggregate %.

2027 of 2027 relevant lines covered (100.0%)

45.23 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

100.0
/packages/public/fs-cache/src/index.ts
1
import * as path from 'path';
1✔
2
import * as fs from 'fs/promises';
1✔
3
import { deferred, type DeferredPromise } from '@homer0/deferred';
1✔
4
import { pathUtils, type PathUtils } from '@homer0/path-utils';
1✔
5
import { providerCreator, injectHelper } from '@homer0/jimple';
1✔
6
import type {
7
  FsCacheOptions,
8
  FsCacheEntryOptions,
9
  FsCacheCustomEntryOptions,
10
  FsCacheMemoryEntry,
11
  FsCacheCleanFsOptions,
12
  FsCacheCleanMemoryOptions,
13
  FsCacheShouldRemoveFileInfo,
14
  FsCacheCleanOptions,
15
} from './types';
16
/**
17
 * The dictionary of dependencies that need to be injected in {@link FsCache}.
18
 */
19
export type FsCacheInjectOptions = {
20
  /**
21
   * The service that generates paths relative to the project root.
22
   */
23
  pathUtils: PathUtils;
24
};
25
/**
26
 * The options to construct the {@link FsCache} service.
27
 */
28
export type FsCacheConstructorOptions = Partial<FsCacheOptions> & {
29
  inject?: Partial<FsCacheInjectOptions>;
30
};
31

32
type FsCacheCleanFsData = {
33
  filename: string;
34
  filepath: string;
35
  expired: boolean;
36
  exists: boolean;
37
  mtime: number;
38
};
39

40
/* eslint-disable no-magic-numbers */
41
const FIVE_MINUTES = 5 * 60 * 1000;
1✔
42
const ONE_WEEK = 7 * 24 * 60 * 60 * 1000;
1✔
43
/* eslint-enable no-magic-numbers */
44

45
/**
46
 * The inject helper to resolve the dependencies.
47
 */
48
const deps = injectHelper<FsCacheInjectOptions>();
1✔
49
/**
50
 * A small and friendly service to cache data on the file system.
51
 */
52
export class FsCache {
1✔
53
  /**
54
   * The service customization options.
55
   */
56
  protected options: FsCacheOptions;
39✔
57
  /**
58
   * A dictionary with timeout functions for the entries that need to be expired.
59
   */
60
  protected deletionTasks: Record<string, NodeJS.Timeout> = {};
39✔
61
  /**
62
   * A dictionary of deferred promises for the entries, in case the same entry is
63
   * requested multiple times.
64
   */
65
  protected promises: Record<string, DeferredPromise<string>> = {};
39✔
66
  /**
67
   * The "in memory cache" the service uses.
68
   */
69
  protected memory: Record<string, FsCacheMemoryEntry> = {};
39✔
70
  /**
71
   * The service that generates paths relative to the project root.
72
   */
73
  protected pathUtils: PathUtils;
39✔
74
  constructor({ inject = {}, ...options }: FsCacheConstructorOptions = {}) {
18✔
75
    this.pathUtils = deps.get(inject, 'pathUtils', () => pathUtils());
39✔
76
    this.options = {
39✔
77
      path: '.cache',
78
      defaultTTL: FIVE_MINUTES,
79
      maxTTL: ONE_WEEK,
80
      keepInMemory: true,
81
      extension: 'tmp',
82
      ...options,
83
    };
84

85
    this.validateOptions();
39✔
86
  }
87
  /**
88
   * Gets the service options.
89
   */
90
  getOptions(): FsCacheOptions {
91
    return { ...this.options };
2✔
92
  }
93
  /**
94
   * Generates a cache entry: given the unique `key` and the `init` function, the service
95
   * will try to recover a cached value in order to return, but if it doesn't exist or is
96
   * expired, it will call the `init` function and cache it result.
97
   *
98
   * @param options  The options to generate the entry.
99
   * @returns The value, cached or not.
100
   * @throws If the TTL is less than or equal to zero.
101
   * @throws If the TTL is greater than the service max TTL.
102
   * @example
103
   *
104
   * <caption>Basic</caption>
105
   *
106
   *   const cachedResponse = await cache.use({
107
   *     key: 'my-key',
108
   *     init: async () => {
109
   *       const res = await fetch('https://example.com');
110
   *       const data = await res.json();
111
   *       return JSON.stringify(data);
112
   *     },
113
   *   });
114
   *
115
   * @example
116
   *
117
   * <caption>Custom TTL</caption>
118
   *
119
   *   const cachedResponse = await cache.use({
120
   *     key: 'my-key',
121
   *     ttl: 60 * 60 * 24, // 1 day
122
   *     init: async () => {
123
   *       const res = await fetch('https://example.com');
124
   *       const data = await res.json();
125
   *       return JSON.stringify(data);
126
   *     },
127
   *   });
128
   *
129
   */
130
  async use(options: FsCacheEntryOptions<string>): Promise<string> {
131
    const {
132
      key,
133
      init,
134
      ttl = this.options.defaultTTL,
85✔
135
      keepInMemory = this.options.keepInMemory,
87✔
136
      extension = this.options.extension,
83✔
137
      skip = false,
82✔
138
    } = options;
87✔
139

140
    if (ttl <= 0) {
87✔
141
      throw new Error('The TTL cannot be less than or equal to zero.');
1✔
142
    }
143

144
    if (ttl > this.options.maxTTL) {
86✔
145
      throw new Error('The TTL cannot be greater than the service max TTL.');
1✔
146
    }
147

148
    if (this.promises[key]) {
85✔
149
      const def = this.promises[key]!;
3✔
150
      return def.promise;
3✔
151
    }
152

153
    const def = deferred<string>();
82✔
154
    this.promises[key] = def;
82✔
155

156
    if (skip) {
82✔
157
      try {
3✔
158
        const result = await init();
3✔
159
        def.resolve(result);
2✔
160
        delete this.promises[key];
2✔
161
        return result;
2✔
162
      } catch (error) {
163
        setTimeout(() => {
1✔
164
          def.reject(error);
1✔
165
          delete this.promises[key];
1✔
166
        }, 0);
167

168
        return def.promise;
1✔
169
      }
170
    }
171

172
    if (keepInMemory && this.memory[key]) {
79✔
173
      const memoryEntry = this.memory[key]!;
17✔
174
      if (Date.now() - memoryEntry.time < ttl) {
17✔
175
        const result = memoryEntry.value;
16✔
176
        def.resolve(result);
16✔
177
        delete this.promises[key];
16✔
178
        return result;
16✔
179
      }
180

181
      delete this.memory[key];
1✔
182
    }
183

184
    try {
63✔
185
      await this.ensureCacheDir();
63✔
186
      if (this.deletionTasks[key]) {
62✔
187
        clearTimeout(this.deletionTasks[key]);
3✔
188
        delete this.deletionTasks[key];
3✔
189
      }
190

191
      const [filepath] = this.getFilepathInfo(key, extension);
62✔
192
      const exists = await this.pathExists(filepath);
62✔
193
      if (exists) {
62✔
194
        const stats = await fs.stat(filepath);
10✔
195
        if (Date.now() - stats.mtimeMs < ttl) {
10✔
196
          const result = await fs.readFile(filepath, 'utf8');
9✔
197
          def.resolve(result);
9✔
198
          delete this.promises[key];
9✔
199
          return result;
9✔
200
        }
201
      }
202

203
      const value = await init();
53✔
204
      await fs.writeFile(filepath, String(value));
53✔
205
      if (keepInMemory) {
53✔
206
        this.memory[key] = {
47✔
207
          time: Date.now(),
208
          value,
209
        };
210
      }
211
      this.deletionTasks[key] = setTimeout(async () => {
53✔
212
        await fs.unlink(filepath);
29✔
213
        delete this.deletionTasks[key];
29✔
214
        delete this.memory[key];
29✔
215
      }, ttl);
216

217
      def.resolve(value);
53✔
218
      delete this.promises[key];
53✔
219
      return value;
53✔
220
    } catch (error) {
221
      setTimeout(() => {
1✔
222
        def.reject(error);
1✔
223
        delete this.promises[key];
1✔
224
      }, 0);
225
      return def.promise;
1✔
226
    }
227
  }
228
  /**
229
   * This is a wrapper on top of {@link FsCache.use} that allows for custom
230
   * serialization/deserialization so the value can be something different than a string.
231
   * Yes, this is used behind {@link FsCache.useJSON}.
232
   *
233
   * @param options  The options to generate the entry.
234
   * @template T  The type of the value.
235
   */
236
  async useCustom<T = unknown>({
237
    serialize,
238
    deserialize,
239
    ...options
240
  }: FsCacheCustomEntryOptions<T>): Promise<T> {
241
    const value = await this.use({
3✔
242
      ...options,
243
      init: async () => {
244
        const result = await options.init();
2✔
245
        return serialize(result);
2✔
246
      },
247
    });
248

249
    return deserialize(value);
3✔
250
  }
251
  /**
252
   * Generates a JSON entry: The value will be stored as a JSON string, and parsed when
253
   * read.
254
   *
255
   * @param options  The options to generate the entry.
256
   * @template T  The type of the value.
257
   */
258
  async useJSON<T = unknown>(options: FsCacheEntryOptions<T>): Promise<T> {
259
    return this.useCustom({
3✔
260
      ...options,
261
      serialize: JSON.stringify,
262
      deserialize: JSON.parse,
263
    });
264
  }
265
  /**
266
   * Removes an entry from the service memory.
267
   *
268
   * @param key      The key of the entry.
269
   * @param options  Custom options in case the file for the removed entry should be
270
   *                 removed too.
271
   */
272
  async removeFromMemory(
273
    key: string,
274
    options: FsCacheCleanMemoryOptions = {},
1✔
275
  ): Promise<void> {
276
    const { includeFs = true, ...fsOptions } = options;
21✔
277
    if (this.deletionTasks[key]) {
21✔
278
      clearTimeout(this.deletionTasks[key]);
21✔
279
      delete this.deletionTasks[key];
21✔
280
    }
281

282
    delete this.memory[key];
21✔
283
    if (includeFs) {
21✔
284
      await this.removeFromFs(key, {
5✔
285
        ...fsOptions,
286
        includeMemory: false,
287
      });
288
    }
289
  }
290
  /**
291
   * Removes an entry from the fs.
292
   *
293
   * @param key      The key of the entry.
294
   * @param options  Custom options to validate the file before removing it.
295
   */
296
  async removeFromFs(key: string, options: FsCacheCleanFsOptions = {}): Promise<void> {
1✔
297
    const { extension = this.options.extension, ttl = this.options.defaultTTL } = options;
19✔
298
    const [filepath, filename] = this.getFilepathInfo(key, extension);
19✔
299
    const exists = await this.pathExists(filepath);
19✔
300
    const data: FsCacheCleanFsData = {
19✔
301
      filename,
302
      filepath,
303
      exists,
304
      expired: true,
305
      mtime: 0,
306
    };
307
    if (data.exists) {
19✔
308
      const stats = await fs.stat(filepath);
19✔
309
      data.expired = Date.now() - stats.mtimeMs > ttl;
19✔
310
      data.mtime = stats.mtimeMs;
19✔
311
    }
312

313
    return this.removeEntryFromFs(key, options, data);
19✔
314
  }
315
  /**
316
   * Removes an entry from the service memory, and the fs.
317
   *
318
   * @param key      The key of the entry.
319
   * @param options  Custom options to validate the file before removing it.
320
   */
321
  remove(key: string, options: FsCacheCleanOptions = {}): Promise<void> {
1✔
322
    return this.removeFromFs(key, {
2✔
323
      ...options,
324
      includeMemory: true,
325
    });
326
  }
327
  /**
328
   * Removes all entries from the service memory.
329
   *
330
   * @param options  Custom options in case the files for the removed entries should be
331
   *                 removed too.
332
   */
333
  async cleanMemory(options: FsCacheCleanMemoryOptions = {}): Promise<void> {
1✔
334
    await Promise.all(
2✔
335
      Object.keys(this.memory).map(async (key) => this.removeFromMemory(key, options)),
4✔
336
    );
337
  }
338
  /**
339
   * Removes all entries from the fs.
340
   *
341
   * @param options  Custom options to validate the files before removing them.
342
   */
343
  async cleanFs(options: FsCacheCleanFsOptions = {}): Promise<void> {
1✔
344
    const { extension = this.options.extension } = options;
4✔
345
    const dirPath = this.pathUtils.join(this.options.path);
4✔
346
    const dirContents = await fs.readdir(dirPath);
4✔
347
    const files = this.filterFiles(dirContents, extension);
4✔
348
    await Promise.all(
4✔
349
      files.map(async (file) => {
350
        const key = this.getKeyFromFilepath(file, extension);
8✔
351
        return this.removeFromFs(key, options);
8✔
352
      }),
353
    );
354
  }
355
  /**
356
   * Removes all entries from the service memory, and the fs.
357
   *
358
   * @param options  Custom options to validate the files before removing them.
359
   */
360
  clean(options: Omit<FsCacheCleanFsOptions, 'includeMemory'> = {}): Promise<void> {
1✔
361
    return this.cleanFs({
2✔
362
      ...options,
363
      includeMemory: true,
364
    });
365
  }
366
  /**
367
   * Removes all expired entries from the service memory.
368
   *
369
   * @param options  Custom options in case the files for the removed entries should be
370
   *                 removed too.
371
   */
372
  async purgeMemory(options: FsCacheCleanMemoryOptions = {}): Promise<void> {
2✔
373
    const { ttl = this.options.defaultTTL } = options;
2✔
374
    const now = Date.now();
2✔
375
    const expiredKeys = Object.keys(this.memory).filter((key) => {
2✔
376
      const entry = this.memory[key]!;
4✔
377
      return now - entry.time > ttl;
4✔
378
    });
379

380
    if (!expiredKeys.length) return;
2✔
381
    await Promise.all(expiredKeys.map((key) => this.removeFromMemory(key, options)));
2✔
382
  }
383
  /**
384
   * Removes all expired entries from the fs.
385
   *
386
   * @param options  Custom options to validate the files before removing them.
387
   */
388
  async purgeFs(options: FsCacheCleanFsOptions = {}): Promise<void> {
2✔
389
    const { extension = this.options.extension, ttl = this.options.defaultTTL } = options;
3✔
390
    const dirPath = this.pathUtils.join(this.options.path);
3✔
391
    const dirContents = await fs.readdir(dirPath);
3✔
392
    const files = this.filterFiles(dirContents, extension);
3✔
393
    const info = await Promise.all(
3✔
394
      files.map(async (file) => {
395
        const filepath = path.join(dirPath, file);
6✔
396
        const stats = await fs.stat(filepath);
6✔
397
        const expired = Date.now() - stats.mtimeMs > ttl;
6✔
398
        return {
6✔
399
          filename: file,
400
          filepath,
401
          expired,
402
          mtime: stats.mtimeMs,
403
        };
404
      }),
405
    );
406

407
    const expiredFiles = info.filter(({ expired }) => expired);
6✔
408
    if (!expiredFiles.length) return;
3✔
409
    await Promise.all(
2✔
410
      expiredFiles.map(async (fileInfo) => {
411
        const key = this.getKeyFromFilepath(fileInfo.filename, extension);
4✔
412
        return this.removeEntryFromFs(key, options, {
4✔
413
          ...fileInfo,
414
          exists: true,
415
        });
416
      }),
417
    );
418
  }
419
  /**
420
   * Removes all expired entries from the fs and the service memory.
421
   *
422
   * @param options  Custom options to validate the files before removing them.
423
   */
424
  async purge(options: Omit<FsCacheCleanFsOptions, 'includeMemory'> = {}): Promise<void> {
1✔
425
    return this.purgeFs({
1✔
426
      ...options,
427
      includeMemory: true,
428
    });
429
  }
430
  /**
431
   * Validates the options sent to the constructor.
432
   *
433
   * @throws If the default TTL is less than or equal to zero.
434
   * @throws If the default TTL is greater than the max TTL.
435
   */
436
  protected validateOptions() {
437
    if (this.options.defaultTTL <= 0) {
39✔
438
      throw new Error('The TTL cannot be less than or equal to zero.');
1✔
439
    }
440

441
    if (this.options.defaultTTL > this.options.maxTTL) {
38✔
442
      throw new Error('The default TTL cannot be greater than the max TTL.');
1✔
443
    }
444
  }
445
  /**
446
   * Small utility to validate if a path exists in the file system.
447
   *
448
   * @param filepath  The filepath to validate.
449
   */
450
  protected async pathExists(filepath: string): Promise<boolean> {
451
    let exists = false;
144✔
452
    try {
144✔
453
      await fs.access(filepath);
144✔
454
      exists = true;
89✔
455
    } catch (error) {
456
      if (
55✔
457
        !(error instanceof Error) ||
110✔
458
        (error as NodeJS.ErrnoException).code !== 'ENOENT'
459
      ) {
460
        throw error;
1✔
461
      }
462
    }
463

464
    return exists;
143✔
465
  }
466
  /**
467
   * Ensures that the cache directory exists: if it doesn't, it will create it.
468
   */
469
  protected async ensureCacheDir(): Promise<void> {
470
    const exists = await this.pathExists(this.pathUtils.join(this.options.path));
63✔
471
    if (!exists) {
62✔
472
      await fs.mkdir(this.options.path);
2✔
473
    }
474
  }
475
  /**
476
   * Generates the file (name and path) information for an entry key.
477
   *
478
   * @param key        The key of the entry.
479
   * @param extension  The extension to add.
480
   * @returns The filename for the entry, and its absolute path.
481
   */
482
  protected getFilepathInfo(
483
    key: string,
484
    extension: string,
485
  ): [filepath: string, filename: string] {
486
    const filename = `${key}.${extension}`;
81✔
487
    const filepath = this.pathUtils.join(this.options.path, filename);
81✔
488
    return [filepath, filename];
81✔
489
  }
490
  /**
491
   * Extracts an entry key from a filepath.
492
   *
493
   * @param filepath   The filepath to an entry.
494
   * @param extension  The extension to remove.
495
   */
496
  protected getKeyFromFilepath(filepath: string, extension: string): string {
497
    const filename = path.basename(filepath);
12✔
498
    return filename.slice(0, -(extension.length + 1));
12✔
499
  }
500
  /**
501
   * Filters a list of files by validating it against the extension set in the
502
   * constructor.
503
   *
504
   * @param files      The files to filter.
505
   * @param extension  The extension the files should have.
506
   */
507
  protected filterFiles(files: string[], extension: string): string[] {
508
    return files.filter((file) => file.endsWith(`.${extension}`));
14✔
509
  }
510
  /**
511
   * This is actually the method behind {@link FsCache.removeFromFs}. The logic is
512
   * separated in two methods because the the removal functionality could be called from
513
   * different places, and we don't want to repeat calls to the file system (to check if
514
   * the file exists and get the stats).
515
   *
516
   * @param key      The key of the entry.
517
   * @param options  Custom options to validate the file before removing it.
518
   */
519
  protected async removeEntryFromFs(
520
    key: string,
521
    options: FsCacheCleanFsOptions,
522
    data: FsCacheCleanFsData,
523
  ): Promise<void> {
524
    const { includeMemory = true, shouldRemove = () => true } = options;
23✔
525
    const { exists, expired, filename, filepath, mtime } = data;
23✔
526
    if (exists) {
23✔
527
      const param: FsCacheShouldRemoveFileInfo = {
23✔
528
        key,
529
        filepath,
530
        filename,
531
        mtime,
532
        expired,
533
      };
534

535
      const shouldRemoveResult = shouldRemove(param);
23✔
536
      const should =
537
        typeof shouldRemoveResult === 'boolean'
23✔
538
          ? shouldRemoveResult
539
          : await shouldRemoveResult;
540
      if (should) {
23✔
541
        await fs.unlink(filepath);
22✔
542
      }
543
    }
544

545
    if (includeMemory) {
23✔
546
      await this.removeFromMemory(key, { includeFs: false });
13✔
547
    }
548
  }
549
}
550
/**
551
 * Shorthand for `new FsCache()`.
552
 *
553
 * @param args  The same parameters as the {@link FsCache} constructor.
554
 * @returns A new instance of {@link FsCache}.
555
 */
556
export const fsCache = (...args: ConstructorParameters<typeof FsCache>): FsCache =>
1✔
557
  new FsCache(...args);
1✔
558

559
/**
560
 * The options for the {@link FsCache} Jimple's provider creator.
561
 */
562
export type FsCacheProviderOptions = Omit<FsCacheConstructorOptions, 'inject'> & {
563
  /**
564
   * The name that will be used to register the service.
565
   *
566
   * @default 'fsCache'
567
   */
568
  serviceName?: string;
569
  /**
570
   * A dictionary with the name of the services to inject. If one or more are not
571
   * provided, the service will create new instances.
572
   */
573
  services?: {
574
    [key in keyof FsCacheInjectOptions]?: string;
575
  };
576
};
577
/**
578
 * A provider creator to register {@link FsCache} in a Jimple container.
579
 */
580
export const fsCacheProvider = providerCreator(
1✔
581
  ({ serviceName = 'fsCache', ...rest }: FsCacheProviderOptions = {}) =>
2✔
582
    (container) => {
2✔
583
      container.set(serviceName, () => {
2✔
584
        const { services = {}, ...options } = rest;
2✔
585
        const inject = deps.resolve(['pathUtils'], container, services);
2✔
586
        return new FsCache({ inject, ...options });
2✔
587
      });
588
    },
589
);
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc