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

taskforcesh / bullmq / 10173777761

31 Jul 2024 03:54AM CUT coverage: 89.166%. Remained the same
10173777761

push

github

web-flow
test(clean): fix flaky test (#2680)

1157 of 1392 branches covered (83.12%)

Branch coverage included in aggregate %.

2234 of 2411 relevant lines covered (92.66%)

2934.86 hits per line

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

90.72
/src/commands/script-loader.ts
1
import { createHash } from 'crypto';
2✔
2
import * as path from 'path';
2✔
3
import * as fs from 'fs';
2✔
4
import { RedisClient } from '../interfaces';
5
import { promisify } from 'util';
2✔
6

7
const readFile = promisify(fs.readFile);
2✔
8
const readdir = promisify(fs.readdir);
2✔
9

10
const GlobOptions = { dot: true, silent: false };
2✔
11
const IncludeRegex = /^[-]{2,3}[ \t]*@include[ \t]+(["'])(.+?)\1[; \t\n]*$/m;
2✔
12
const EmptyLineRegex = /^\s*[\r\n]/gm;
2✔
13

14
export interface Command {
15
  name: string;
16
  options: {
17
    numberOfKeys: number;
18
    lua: string;
19
  };
20
}
21

22
/**
23
 * Script metadata
24
 */
25
export interface ScriptMetadata {
26
  /**
27
   * Name of the script
28
   */
29
  name: string;
30

31
  numberOfKeys?: number;
32
  /**
33
   * The path to the script. For includes, this is the normalized path,
34
   * whereas it may not be normalized for the top-level parent
35
   */
36
  path: string;
37
  /**
38
   * The raw script content
39
   */
40
  content: string;
41
  /**
42
   * A hash of the normalized path for easy replacement in the parent
43
   */
44
  token: string;
45
  /**
46
   * Metadata on the scripts that this script includes
47
   */
48
  includes: ScriptMetadata[];
49
}
50

51
export class ScriptLoaderError extends Error {
2✔
52
  /**
53
   * The include stack
54
   */
55
  public readonly includes: string[];
56
  public readonly line: number;
57
  public readonly position: number;
58

59
  constructor(
60
    message: string,
61
    path: string,
62
    stack: string[] = [],
×
63
    line?: number,
64
    position = 0,
3✔
65
  ) {
66
    super(message);
5✔
67
    // Ensure the name of this error is the same as the class name
68
    this.name = this.constructor.name;
5✔
69
    Error.captureStackTrace(this, this.constructor);
5✔
70
    this.includes = stack;
5✔
71
    this.line = line ?? 0;
5✔
72
    this.position = position;
5✔
73
  }
74
}
75

76
const isPossiblyMappedPath = (path: string) =>
2✔
77
  path && ['~', '<'].includes(path[0]);
184✔
78

79
/**
80
 * Lua script loader with include support
81
 */
82
export class ScriptLoader {
2✔
83
  /**
84
   * Map an alias to a path
85
   */
86
  private pathMapper = new Map<string, string>();
30✔
87
  private clientScripts = new WeakMap<RedisClient, Set<string>>();
30✔
88
  /**
89
   * Cache commands by dir
90
   */
91
  private commandCache = new Map<string, Command[]>();
30✔
92
  private rootPath: string;
93

94
  constructor() {
95
    this.rootPath = getPkgJsonDir();
30✔
96
    this.pathMapper.set('~', this.rootPath);
30✔
97
    this.pathMapper.set('rootDir', this.rootPath);
30✔
98
    this.pathMapper.set('base', __dirname);
30✔
99
  }
100

101
  /**
102
   * Add a script path mapping. Allows includes of the form "<includes>/utils.lua" where `includes` is a user
103
   * defined path
104
   * @param name - the name of the mapping. Note: do not include angle brackets
105
   * @param mappedPath - if a relative path is passed, it's relative to the *caller* of this function.
106
   * Mapped paths are also accepted, e.g. "~/server/scripts/lua" or "<base>/includes"
107
   */
108
  addPathMapping(name: string, mappedPath: string): void {
109
    let resolved: string;
110

111
    if (isPossiblyMappedPath(mappedPath)) {
8✔
112
      resolved = this.resolvePath(mappedPath);
3✔
113
    } else {
114
      const caller = getCallerFile();
5✔
115
      const callerPath = path.dirname(caller);
5✔
116
      resolved = path.normalize(path.resolve(callerPath, mappedPath));
5✔
117
    }
118

119
    const last = resolved.length - 1;
8✔
120
    if (resolved[last] === path.sep) {
8!
121
      resolved = resolved.substr(0, last);
×
122
    }
123

124
    this.pathMapper.set(name, resolved);
8✔
125
  }
126

127
  /**
128
   * Resolve the script path considering path mappings
129
   * @param scriptName - the name of the script
130
   * @param stack - the include stack, for nicer errors
131
   */
132
  resolvePath(scriptName: string, stack: string[] = []): string {
11✔
133
    const first = scriptName[0];
13✔
134
    if (first === '~') {
13✔
135
      scriptName = path.join(this.rootPath, scriptName.substr(2));
2✔
136
    } else if (first === '<') {
11!
137
      const p = scriptName.indexOf('>');
11✔
138
      if (p > 0) {
11!
139
        const name = scriptName.substring(1, p);
11✔
140
        const mappedPath = this.pathMapper.get(name);
11✔
141
        if (!mappedPath) {
11✔
142
          throw new ScriptLoaderError(
1✔
143
            `No path mapping found for "${name}"`,
144
            scriptName,
145
            stack,
146
          );
147
        }
148
        scriptName = path.join(mappedPath, scriptName.substring(p + 1));
10✔
149
      }
150
    }
151

152
    return path.normalize(scriptName);
12✔
153
  }
154

155
  /**
156
   * Recursively collect all scripts included in a file
157
   * @param file - the parent file
158
   * @param cache - a cache for file metadata to increase efficiency. Since a file can be included
159
   * multiple times, we make sure to load it only once.
160
   * @param stack - internal stack to prevent circular references
161
   */
162
  private async resolveDependencies(
163
    file: ScriptMetadata,
164
    cache?: Map<string, ScriptMetadata>,
165
    isInclude = false,
69✔
166
    stack: string[] = [],
69✔
167
  ): Promise<void> {
168
    cache = cache ?? new Map<string, ScriptMetadata>();
245✔
169

170
    if (stack.includes(file.path)) {
245✔
171
      throw new ScriptLoaderError(
1✔
172
        `circular reference: "${file.path}"`,
173
        file.path,
174
        stack,
175
      );
176
    }
177
    stack.push(file.path);
244✔
178

179
    function findPos(content: string, match: string) {
180
      const pos = content.indexOf(match);
2✔
181
      const arr = content.slice(0, pos).split('\n');
2✔
182
      return {
2✔
183
        line: arr.length,
184
        column: arr[arr.length - 1].length + match.indexOf('@include') + 1,
185
      };
186
    }
187

188
    function raiseError(msg: string, match: string): void {
189
      const pos = findPos(file.content, match);
2✔
190
      throw new ScriptLoaderError(msg, file.path, stack, pos.line, pos.column);
2✔
191
    }
192

193
    const minimatch = await import('minimatch');
244✔
194

195
    if (!minimatch) {
244!
196
      console.warn('Install minimatch as dev-dependency');
×
197
    }
198

199
    const Minimatch = minimatch.Minimatch || class Empty {};
244!
200

201
    const fg = await import('fast-glob');
244✔
202

203
    if (!fg) {
244!
204
      console.warn('Install fast-glob as dev-dependency');
×
205
    }
206

207
    const nonOp = () => {
244✔
208
      return [''];
×
209
    };
210
    const glob = (fg as any)?.default.glob || nonOp;
244!
211

212
    const hasMagic = (pattern: string | string[]): boolean => {
244✔
213
      if (!Array.isArray(pattern)) {
176!
214
        pattern = [pattern];
176✔
215
      }
216
      for (const p of pattern) {
176✔
217
        if ((new Minimatch(p, GlobOptions) as any).hasMagic()) {
176✔
218
          return true;
2✔
219
        }
220
      }
221
      return false;
174✔
222
    };
223

224
    const hasFilenamePattern = (path: string) => hasMagic(path);
244✔
225

226
    async function getFilenamesByPattern(pattern: string): Promise<string[]> {
227
      return glob(pattern, { dot: true });
2✔
228
    }
229

230
    let res;
231
    let content = file.content;
244✔
232

233
    while ((res = IncludeRegex.exec(content)) !== null) {
244✔
234
      const [match, , reference] = res;
176✔
235

236
      const includeFilename = isPossiblyMappedPath(reference)
176✔
237
        ? // mapped paths imply absolute reference
238
          this.resolvePath(ensureExt(reference), stack)
239
        : // include path is relative to the file being processed
240
          path.resolve(path.dirname(file.path), ensureExt(reference));
241

242
      let includePaths: string[];
243

244
      if (hasFilenamePattern(includeFilename)) {
176✔
245
        const filesMatched = await getFilenamesByPattern(includeFilename);
2✔
246
        includePaths = filesMatched.map((x: string) => path.resolve(x));
4✔
247
      } else {
248
        includePaths = [includeFilename];
174✔
249
      }
250

251
      includePaths = includePaths.filter(
176✔
252
        (file: string) => path.extname(file) === '.lua',
178✔
253
      );
254

255
      if (includePaths.length === 0) {
176!
256
        raiseError(`include not found: "${reference}"`, match);
×
257
      }
258

259
      const tokens: string[] = [];
176✔
260

261
      for (let i = 0; i < includePaths.length; i++) {
176✔
262
        const includePath = includePaths[i];
178✔
263

264
        const hasInclude = file.includes.find(
178✔
265
          (x: ScriptMetadata) => x.path === includePath,
366✔
266
        );
267

268
        if (hasInclude) {
178✔
269
          /**
270
           * We have something like
271
           * --- \@include "a"
272
           * ...
273
           * --- \@include "a"
274
           */
275
          raiseError(
1✔
276
            `file "${reference}" already included in "${file.path}"`,
277
            match,
278
          );
279
        }
280

281
        let includeMetadata = cache.get(includePath);
177✔
282
        let token: string;
283

284
        if (!includeMetadata) {
177✔
285
          const { name, numberOfKeys } = splitFilename(includePath);
73✔
286
          let childContent = '';
73✔
287
          try {
73✔
288
            const buf = await readFile(includePath, { flag: 'r' });
73✔
289
            childContent = buf.toString();
72✔
290
          } catch (err) {
291
            if ((err as any).code === 'ENOENT') {
1!
292
              raiseError(`include not found: "${reference}"`, match);
1✔
293
            } else {
294
              throw err;
×
295
            }
296
          }
297
          // this represents a normalized version of the path to make replacement easy
298
          token = getPathHash(includePath);
72✔
299
          includeMetadata = {
72✔
300
            name,
301
            numberOfKeys,
302
            path: includePath,
303
            content: childContent,
304
            token,
305
            includes: [],
306
          };
307
          cache.set(includePath, includeMetadata);
72✔
308
        } else {
309
          token = includeMetadata.token;
104✔
310
        }
311

312
        tokens.push(token);
176✔
313

314
        file.includes.push(includeMetadata);
176✔
315
        await this.resolveDependencies(includeMetadata, cache, true, stack);
176✔
316
      }
317

318
      // Replace @includes with normalized path hashes
319
      const substitution = tokens.join('\n');
172✔
320
      content = content.replace(match, substitution);
172✔
321
    }
322

323
    file.content = content;
240✔
324

325
    if (isInclude) {
240✔
326
      cache.set(file.path, file);
174✔
327
    } else {
328
      cache.set(file.name, file);
66✔
329
    }
330

331
    stack.pop();
240✔
332
  }
333

334
  /**
335
   * Parse a (top-level) lua script
336
   * @param filename - the full path to the script
337
   * @param content - the content of the script
338
   * @param cache - cache
339
   */
340
  async parseScript(
341
    filename: string,
342
    content: string,
343
    cache?: Map<string, ScriptMetadata>,
344
  ): Promise<ScriptMetadata> {
345
    const { name, numberOfKeys } = splitFilename(filename);
69✔
346
    const meta = cache?.get(name);
69✔
347
    if (meta?.content === content) {
69!
348
      return meta;
×
349
    }
350
    const fileInfo: ScriptMetadata = {
69✔
351
      path: filename,
352
      token: getPathHash(filename),
353
      content,
354
      name,
355
      numberOfKeys,
356
      includes: [],
357
    };
358

359
    await this.resolveDependencies(fileInfo, cache);
69✔
360
    return fileInfo;
66✔
361
  }
362

363
  /**
364
   * Construct the final version of a file by interpolating its includes in dependency order.
365
   * @param file - the file whose content we want to construct
366
   * @param processed - a cache to keep track of which includes have already been processed
367
   */
368
  interpolate(file: ScriptMetadata, processed?: Set<string>): string {
369
    processed = processed || new Set<string>();
453✔
370
    let content = file.content;
453✔
371
    file.includes.forEach((child: ScriptMetadata) => {
453✔
372
      const emitted = processed!.has(child.path);
387✔
373
      const fragment = this.interpolate(child, processed);
387✔
374
      const replacement = emitted ? '' : fragment;
387✔
375

376
      if (!replacement) {
387✔
377
        content = replaceAll(content, child.token, '');
149✔
378
      } else {
379
        // replace the first instance with the dependency
380
        content = content.replace(child.token, replacement);
238✔
381
        // remove the rest
382
        content = replaceAll(content, child.token, '');
238✔
383
      }
384

385
      processed!.add(child.path);
387✔
386
    });
387

388
    return content;
453✔
389
  }
390

391
  async loadCommand(
392
    filename: string,
393
    cache?: Map<string, ScriptMetadata>,
394
  ): Promise<Command> {
395
    filename = path.resolve(filename);
69✔
396

397
    const { name: scriptName } = splitFilename(filename);
69✔
398
    let script = cache?.get(scriptName);
69✔
399
    if (!script) {
69!
400
      const content = (await readFile(filename)).toString();
69✔
401
      script = await this.parseScript(filename, content, cache);
69✔
402
    }
403

404
    const lua = removeEmptyLines(this.interpolate(script));
66✔
405
    const { name, numberOfKeys } = script;
66✔
406

407
    return {
66✔
408
      name,
409
      options: { numberOfKeys: numberOfKeys!, lua },
410
    };
411
  }
412

413
  /**
414
   * Load redis lua scripts.
415
   * The name of the script must have the following format:
416
   *
417
   * cmdName-numKeys.lua
418
   *
419
   * cmdName must be in camel case format.
420
   *
421
   * For example:
422
   * moveToFinish-3.lua
423
   *
424
   */
425
  async loadScripts(
426
    dir?: string,
427
    cache?: Map<string, ScriptMetadata>,
428
  ): Promise<Command[]> {
429
    dir = path.normalize(dir || __dirname);
14!
430

431
    let commands = this.commandCache.get(dir);
14✔
432
    if (commands) {
14✔
433
      return commands;
2✔
434
    }
435

436
    const files = await readdir(dir);
12✔
437

438
    const luaFiles = files.filter(
12✔
439
      (file: string) => path.extname(file) === '.lua',
71✔
440
    );
441

442
    if (luaFiles.length === 0) {
12✔
443
      /**
444
       * To prevent unclarified runtime error "updateDelayset is not a function
445
       * @see https://github.com/OptimalBits/bull/issues/920
446
       */
447
      throw new ScriptLoaderError('No .lua files found!', dir, []);
1✔
448
    }
449

450
    commands = [];
11✔
451
    cache = cache ?? new Map<string, ScriptMetadata>();
11✔
452

453
    for (let i = 0; i < luaFiles.length; i++) {
11✔
454
      const file = path.join(dir, luaFiles[i]);
58✔
455

456
      const command = await this.loadCommand(file, cache);
58✔
457
      commands.push(command);
58✔
458
    }
459

460
    this.commandCache.set(dir, commands);
11✔
461

462
    return commands;
11✔
463
  }
464

465
  /**
466
   * Attach all lua scripts in a given directory to a client instance
467
   * @param client - redis client to attach script to
468
   * @param pathname - the path to the directory containing the scripts
469
   */
470
  async load(
471
    client: RedisClient,
472
    pathname: string,
473
    cache?: Map<string, ScriptMetadata>,
474
  ): Promise<void> {
475
    let paths = this.clientScripts.get(client);
4✔
476
    if (!paths) {
4✔
477
      paths = new Set<string>();
2✔
478
      this.clientScripts.set(client, paths);
2✔
479
    }
480
    if (!paths.has(pathname)) {
4✔
481
      paths.add(pathname);
2✔
482
      const scripts = await this.loadScripts(
2✔
483
        pathname,
484
        cache ?? new Map<string, ScriptMetadata>(),
6!
485
      );
486
      scripts.forEach((command: Command) => {
2✔
487
        // Only define the command if not already defined
488
        if (!(client as any)[command.name]) {
2!
489
          client.defineCommand(command.name, command.options);
2✔
490
        }
491
      });
492
    }
493
  }
494

495
  /**
496
   * Clears the command cache
497
   */
498
  clearCache(): void {
499
    this.commandCache.clear();
1✔
500
  }
501
}
502

503
function ensureExt(filename: string, ext = 'lua'): string {
176✔
504
  const foundExt = path.extname(filename);
176✔
505
  if (foundExt && foundExt !== '.') {
176✔
506
    return filename;
6✔
507
  }
508
  if (ext && ext[0] !== '.') {
170!
509
    ext = `.${ext}`;
170✔
510
  }
511
  return `${filename}${ext}`;
170✔
512
}
513

514
function splitFilename(filePath: string): {
515
  name: string;
516
  numberOfKeys?: number;
517
} {
518
  const longName = path.basename(filePath, '.lua');
211✔
519
  const [name, num] = longName.split('-');
211✔
520
  const numberOfKeys = num ? parseInt(num, 10) : undefined;
211✔
521
  return { name, numberOfKeys };
211✔
522
}
523

524
// Determine the project root
525
// https://stackoverflow.com/a/18721515
526
function getPkgJsonDir(): string {
527
  for (const modPath of module.paths || []) {
30!
528
    try {
90✔
529
      const prospectivePkgJsonDir = path.dirname(modPath);
90✔
530
      fs.accessSync(modPath, fs.constants.F_OK);
90✔
531
      return prospectivePkgJsonDir;
30✔
532
      // eslint-disable-next-line no-empty
533
    } catch (e) {}
534
  }
535
  return '';
×
536
}
537

538
// https://stackoverflow.com/a/66842927
539
// some dark magic here :-)
540
// this version is preferred to the simpler version because of
541
// https://github.com/facebook/jest/issues/5303 -
542
// tldr: dont assume you're the only one with the doing something like this
543
function getCallerFile(): string {
544
  const originalFunc = Error.prepareStackTrace;
5✔
545

546
  let callerFile = '';
5✔
547
  try {
5✔
548
    Error.prepareStackTrace = (_, stack) => stack;
5✔
549

550
    const sites = <NodeJS.CallSite[]>(<unknown>new Error().stack);
5✔
551
    const currentFile = sites.shift()?.getFileName();
5!
552

553
    while (sites.length) {
5✔
554
      callerFile = sites.shift()?.getFileName() ?? '';
10!
555

556
      if (currentFile !== callerFile) {
10✔
557
        break;
5✔
558
      }
559
    }
560
    // eslint-disable-next-line no-empty
561
  } catch (e) {
562
  } finally {
563
    Error.prepareStackTrace = originalFunc;
5✔
564
  }
565

566
  return callerFile;
5✔
567
}
568

569
function sha1(data: string): string {
570
  return createHash('sha1').update(data).digest('hex');
141✔
571
}
572

573
function getPathHash(normalizedPath: string): string {
574
  return `@@${sha1(normalizedPath)}`;
141✔
575
}
576

577
function replaceAll(str: string, find: string, replace: string): string {
578
  return str.replace(new RegExp(find, 'g'), replace);
387✔
579
}
580

581
function removeEmptyLines(str: string): string {
582
  return str.replace(EmptyLineRegex, '');
66✔
583
}
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