From 6bea50bfa1e509105f0041633279c3f7f3d3d797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=8E?= Date: Tue, 24 Oct 2023 23:17:02 +0800 Subject: [PATCH] feat(runner): more flexible watch mode (#17) --- README.md | 4 +- package-lock.json | 51 +++++++-- package.json | 3 + src/Runner.ts | 194 +++++++++++++++++++++++---------- src/TestFinder.ts | 195 ++++++++++++++++++++++++++++++++++ src/WS/WSClient.ts | 2 +- src/cli.ts | 3 +- src/util/TypedEventEmitter.ts | 40 +++++++ src/util/patchDisposable.ts | 8 ++ 9 files changed, 430 insertions(+), 70 deletions(-) create mode 100644 src/TestFinder.ts create mode 100644 src/util/TypedEventEmitter.ts create mode 100644 src/util/patchDisposable.ts diff --git a/README.md b/README.md index db49c62..fcb899e 100644 --- a/README.md +++ b/README.md @@ -337,7 +337,9 @@ wrightplay -w wrightplay --watch ``` -Watch the setup and test files for changes and automatically rerun the tests. Defaults to `false`. +Monitor test file changes and trigger automatic test reruns. Defaults to `false`. + +Please be aware that on certain platforms, particularly in the context of large-scale projects, this feature might silently fail or raise some errors. ### browser diff --git a/package-lock.json b/package-lock.json index 2f3cdc4..ac9af8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "commander": "^11.1.0", "esbuild": "~0.19.5", "get-port": "^7.0.0", + "glob-parent": "^6.0.2", "globby": "^13.2.2", "jiti": "^1.20.0", "lilconfig": "^2.1.0", @@ -26,7 +27,9 @@ "wrightplay": "build/cli.js" }, "devDependencies": { + "@types/glob-parent": "^5.1.2", "@types/mocha": "^10.0.3", + "@types/node": "^20.8.8", "@types/tape": "^5.6.3", "@types/ws": "^8.5.8", "@typescript-eslint/eslint-plugin": "^6.8.0", @@ -790,6 +793,12 @@ "integrity": "sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==", "dev": true }, + "node_modules/@types/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-hS9aA7+fMXnKSOftFd1CSPJ9n4zrmYoQptBxr7NHYYUV+I9WXcU891jsihXWOv00tkiIcF0DviR378ZCzy1EQw==", + "dev": true + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", @@ -833,10 +842,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "17.0.35", - "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.35.tgz", - "integrity": "sha512-vu1SrqBjbbZ3J6vwY17jBs8Sr/BKA+/a/WtjRG+whKg1iuLFOosq872EXS0eXWILdO36DHQQeku/ZcL6hz2fpg==", - "dev": true + "version": "20.8.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.8.tgz", + "integrity": "sha512-YRsdVxq6OaLfmR9Hy816IMp33xOBjfyOgUd77ehqg96CFywxAPbDbXvAsuN2KVg2HOT8Eh6uAfU+l4WffwPVrQ==", + "dev": true, + "dependencies": { + "undici-types": "~5.25.1" + } }, "node_modules/@types/semver": { "version": "7.5.4", @@ -2614,7 +2626,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, "dependencies": { "is-glob": "^4.0.3" }, @@ -4878,6 +4889,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici-types": { + "version": "5.25.3", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.25.3.tgz", + "integrity": "sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA==", + "dev": true + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -5601,6 +5618,12 @@ "integrity": "sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==", "dev": true }, + "@types/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-hS9aA7+fMXnKSOftFd1CSPJ9n4zrmYoQptBxr7NHYYUV+I9WXcU891jsihXWOv00tkiIcF0DviR378ZCzy1EQw==", + "dev": true + }, "@types/istanbul-lib-coverage": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", @@ -5644,10 +5667,13 @@ "dev": true }, "@types/node": { - "version": "17.0.35", - "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.35.tgz", - "integrity": "sha512-vu1SrqBjbbZ3J6vwY17jBs8Sr/BKA+/a/WtjRG+whKg1iuLFOosq872EXS0eXWILdO36DHQQeku/ZcL6hz2fpg==", - "dev": true + "version": "20.8.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.8.tgz", + "integrity": "sha512-YRsdVxq6OaLfmR9Hy816IMp33xOBjfyOgUd77ehqg96CFywxAPbDbXvAsuN2KVg2HOT8Eh6uAfU+l4WffwPVrQ==", + "dev": true, + "requires": { + "undici-types": "~5.25.1" + } }, "@types/semver": { "version": "7.5.4", @@ -6963,7 +6989,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, "requires": { "is-glob": "^4.0.3" } @@ -8561,6 +8586,12 @@ "which-boxed-primitive": "^1.0.2" } }, + "undici-types": { + "version": "5.25.3", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.25.3.tgz", + "integrity": "sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA==", + "dev": true + }, "uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index 62a15a3..fdcfde8 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "commander": "^11.1.0", "esbuild": "~0.19.5", "get-port": "^7.0.0", + "glob-parent": "^6.0.2", "globby": "^13.2.2", "jiti": "^1.20.0", "lilconfig": "^2.1.0", @@ -78,7 +79,9 @@ "ws": "^8.14.2" }, "devDependencies": { + "@types/glob-parent": "^5.1.2", "@types/mocha": "^10.0.3", + "@types/node": "^20.8.8", "@types/tape": "^5.6.3", "@types/ws": "^8.5.8", "@typescript-eslint/eslint-plugin": "^6.8.0", diff --git a/src/Runner.ts b/src/Runner.ts index 279748a..867a3b8 100644 --- a/src/Runner.ts +++ b/src/Runner.ts @@ -1,18 +1,19 @@ import http from 'node:http'; import path from 'node:path'; import { randomUUID } from 'node:crypto'; -import { EventEmitter } from 'node:events'; import { fileURLToPath } from 'node:url'; import type { AddressInfo } from 'node:net'; import type { SourceMapPayload } from 'node:module'; import playwright from 'playwright'; -import { globby } from 'globby'; import { lookup as mimeLookup } from 'mrmime'; import getPort, { portNumbers } from 'get-port'; import esbuild from 'esbuild'; import sirv from 'sirv'; +import './util/patchDisposable.js'; +import EventEmitter from './util/TypedEventEmitter.js'; +import TestFinder from './TestFinder.js'; import BrowserLogger from './BrowserLogger.js'; import CoverageReporter from './CoverageReporter.js'; import WSServer from './WS/WSServer.js'; @@ -40,12 +41,12 @@ export interface RunnerOptions { /** * Additional entry points to build. The output name must be explicitly specified. * You can use this option to build workers. - * @see [Entry points | esbuild - API]{@link https://esbuild.github.io/api/#entry-points} + * @see [Entry points | esbuild - API](https://esbuild.github.io/api/#entry-points) */ entryPoints?: Record; /** - * Watch the setup and test files for changes and rerun the tests automatically. + * Monitor test file changes and trigger automatic test reruns. * Defaults to `false`. */ watch?: boolean; @@ -86,7 +87,7 @@ export type BrowserServer = playwright.BrowserServer; */ export const staticDir = fileURLToPath(new URL('../static', import.meta.url)); -export default class Runner { +export default class Runner implements Disposable { readonly cwd: string; /** @@ -95,18 +96,18 @@ export default class Runner { readonly setupFile: string | undefined; /** - * Promise of an array of absolute test file paths. + * Test file finder and watcher. */ - readonly testFiles: Promise; + readonly testFinder: TestFinder; /** * Additional entry points to build. - * @see [Entry points | esbuild - API]{@link https://esbuild.github.io/api/#entry-points} + * @see [Entry points | esbuild - API](https://esbuild.github.io/api/#entry-points) */ readonly entryPoints: Record; /** - * Watch the setup and test files for changes and automatically rerun the tests. + * Monitor test file changes and trigger automatic test reruns. */ readonly watch: boolean; @@ -158,11 +159,10 @@ export default class Runner { this.cwd = path.resolve(cwd); - // Globby match test files. - this.testFiles = globby(tests, { + this.testFinder = new TestFinder({ + patterns: tests, cwd: this.cwd, - absolute: true, - gitignore: true, + watch: this.watch, }); // Resolve coverage folder. Defaults to NODE_V8_COVERAGE @@ -180,10 +180,10 @@ export default class Runner { } /** - * Pathname to file content map. - * For instance, '/stdin.js' -> 'console.log(1)'. + * Pathname to built file hash & content map. + * For instance, '/stdin.js' -> { text: 'console.log(1)' hash: 'xxx' }. */ - readonly fileContents: Map = new Map(); + readonly fileContents: Map = new Map(); /** * Pathname to source map payload map. @@ -193,19 +193,25 @@ export default class Runner { private updateBuiltFiles(files: esbuild.OutputFile[]) { const { cwd, fileContents, sourceMapPayloads } = this; - files.forEach(({ path: absPath, text }) => { + return files.reduce((changed, { path: absPath, hash, text }) => { const pathname = `/${path.relative(cwd, absPath)}`; - const changed = fileContents.get(pathname) !== text; - fileContents.set(pathname, text); + + // Skip unchanged files. + const same = fileContents.get(pathname)?.hash === hash; + if (same) return changed; + + fileContents.set(pathname, { text, hash }); // Cache source maps for stack trace and coverage. - if (changed && pathname.endsWith('.map')) { + if (pathname.endsWith('.map')) { sourceMapPayloads.set( pathname.slice(0, -4), JSON.parse(text) as SourceMapPayload, ); } - }); + + return true; + }, false); } private readonly cwdRequestListener: http.RequestListener; @@ -216,55 +222,125 @@ export default class Runner { const { cwd, setupFile, - testFiles, + testFinder, entryPoints, watch, fileContents, staticRequestListener, } = this; - const importFiles = await testFiles; - if (setupFile) importFiles.unshift(setupFile.replace(/\\/g, '\\\\')); - if (importFiles.length === 0) { - throw new Error('No test file found'); - } - let building = true; - const buildEventEmitter = new EventEmitter(); + const buildEventEmitter = new EventEmitter<{ + ready: []; + changed: [buildCount: number]; + }>(); const buildContext = await esbuild.context({ - stdin: { - contents: `${importFiles.map((file) => `import '${file}'`).join('\n')} -window.dispatchEvent(new CustomEvent('__wrightplay_${this.uuid}_init__'))`, - resolveDir: cwd, + entryPoints: { + ...entryPoints, + // The stdin API doesn't support onLoad callbacks, + // so we use the entry point workaround. + // https://github.com/evanw/esbuild/issues/720 + stdin: '', }, - entryPoints, + metafile: watch, bundle: true, format: 'esm', sourcemap: 'linked', outdir: './', absWorkingDir: cwd, define: { WRIGHTPLAY_CLIENT_UUID: `'${this.uuid}'` }, - plugins: [{ - name: 'built files updater', - setup: (pluginBuild) => { - let count = 0; - pluginBuild.onStart(() => { - building = true; - }); - pluginBuild.onEnd((result) => { - this.updateBuiltFiles(result.outputFiles ?? []); - count += 1; - building = false; - buildEventEmitter.emit('end', count); - }); + plugins: [ + { + name: 'import files loader', + setup: (pluginBuild) => { + pluginBuild.onResolve({ filter: /^$/ }, () => ({ path: 'stdin', namespace: 'stdin' })); + pluginBuild.onLoad({ filter: /^/, namespace: 'stdin' }, async () => { + const importFiles = await testFinder.getFiles(); + if (setupFile) importFiles.unshift(setupFile.replace(/\\/g, '\\\\')); + if (importFiles.length === 0) { + // eslint-disable-next-line no-console + console.warn('No test file found'); + } + const importStatements = importFiles.map((file) => `import '${file}'`).join('\n'); + const initFunc = (uuid: string) => window.dispatchEvent(new CustomEvent(`__wrightplay_${uuid}_init__`)); + return { + contents: `${importStatements}\n(${initFunc.toString()})('${this.uuid}')`, + resolveDir: cwd, + }; + }); + }, }, - }], + { + name: 'built files updater', + setup: (pluginBuild) => { + let buildCount = 0; + let lastBuildFailed = false; + pluginBuild.onStart(() => { + building = true; + }); + pluginBuild.onEnd((result) => { + building = false; + buildCount += 1; + const files = result.outputFiles!; + const changed = this.updateBuiltFiles(files); + buildEventEmitter.emit('ready'); // signals the http server to respond + + if (!watch) return; + + // Watch the errored files if any. + // This may not help the cases where the error may be resolved + // in another dir (TestFinder watches the dir instead of the file), + // but still better than nothing. + const watchFiles: string[] = []; + result.errors.forEach((error) => { + if (!error.location) return; + watchFiles.push(error.location.file); + }); + + if (watchFiles.length > 0) { + lastBuildFailed = true; + testFinder.setRelevantFiles(watchFiles); + return; + } + + // Return if the built content remains unchanged and no recovery is needed. + // Since built content remains the same during errors, we should identify a + // successful rerun that can replace previous esbuild error messages with + // the latest test results, even if the content has been run before. + if (!changed && !lastBuildFailed) return; + lastBuildFailed = false; + + // Watch the imported files. + const { inputs } = result.metafile!; + Object.values(inputs).forEach((input) => { + input.imports.forEach((im) => { + if (im.external || im.path.startsWith('(disabled):')) return; + watchFiles.push(im.path.replace(/[?#].+$/, '')); + }); + }); + + testFinder.setRelevantFiles(watchFiles); + + // Emit the updated event so as to trigger a rerun + buildEventEmitter.emit('changed', buildCount); + }); + }, + }, + ], write: false, }); if (watch) { - await buildContext.watch(); + testFinder.on('change', () => { + buildContext.rebuild() + // Do nothing as esbuild prints the errors itself + .catch(() => {}); + }); + + testFinder.updateFiles(); } else { + // Non-watch mode automatically triggers `updateFiles` on construction, + // so we don't need to manually call it here. await buildContext.rebuild(); } @@ -283,11 +359,11 @@ window.dispatchEvent(new CustomEvent('__wrightplay_${this.uuid}_init__'))`, response.writeHead(200, { 'Content-Type': `${mimeType}; charset=utf-8`, }); - response.end(builtContent); + response.end(builtContent.text); }; if (building) { - buildEventEmitter.once('end', handleRequest); + buildEventEmitter.once('ready', handleRequest); } else { handleRequest(); } @@ -296,15 +372,15 @@ window.dispatchEvent(new CustomEvent('__wrightplay_${this.uuid}_init__'))`, const server = http.createServer(esbuildListener) as FileServer; server.on('close', () => { buildContext.dispose() - // eslint-disable-next-line no-console - .catch(console.error); + // Do nothing as esbuild prints the errors itself + .catch(() => {}); }); - // Forward rebuild end event. - buildEventEmitter.on('end', (count: number) => { + // Forward file change event for the reruns. + buildEventEmitter.on('changed', (count) => { // Bypass the first build. if (count === 1) return; - server.emit('wrightplay:rebuilt'); + server.emit('wrightplay:changed'); }); // This is helpful if one day esbuild Incremental API supports @@ -427,7 +503,7 @@ window.dispatchEvent(new CustomEvent('__wrightplay_${this.uuid}_init__'))`, await page.goto('/'); // Rerun the tests on file changes. - fileServer.on('wrightplay:rebuilt', () => { + fileServer.on('wrightplay:changed', () => { page.reload().catch(() => { // eslint-disable-next-line no-console console.error('Failed to rerun the tests after file changes'); @@ -486,4 +562,8 @@ window.dispatchEvent(new CustomEvent('__wrightplay_${this.uuid}_init__'))`, return exitCodePromise; } + + [Symbol.dispose]() { + this.testFinder[Symbol.dispose](); + } } diff --git a/src/TestFinder.ts b/src/TestFinder.ts new file mode 100644 index 0000000..4dad723 --- /dev/null +++ b/src/TestFinder.ts @@ -0,0 +1,195 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { globby } from 'globby'; +import globParent from 'glob-parent'; + +import './util/patchDisposable.js'; +import EventEmitter from './util/TypedEventEmitter.js'; + +export interface TestFinderOptions { + patterns: string | readonly string[]; + cwd: string; + watch?: boolean; +} + +export interface TestFinderEventMap { + change: []; +} + +export default class TestFinder extends EventEmitter implements Disposable { + private readonly patterns: string | readonly string[]; + + private readonly cwd: string; + + private readonly watch: boolean; + + private readonly patternDirs: string[] = []; + + private readonly patternWatcherMap = new Map(); + + constructor({ + patterns, + cwd, + watch = false, + }: TestFinderOptions) { + super(); + this.patterns = patterns.concat(); // clone + this.cwd = cwd; + this.watch = watch; + + if (watch) { + this.filesPromise = Promise.resolve([]); + } else { + this.filesPromise = this.searchFiles(); + return; + } + + // Parse pattern dirs to watch recursively + let patternDirs: string[] = []; + const patternList = (typeof patterns === 'string' ? [patterns] : patterns); + patternList.forEach((pattern) => { + // Skip negated patterns + if (pattern.startsWith('!')) return; + + // Resolve its parent absolute dir path + const candidateDir = path.resolve(cwd, globParent(pattern)); + + // Skip if the pattern dir is already covered + if (patternDirs.some((dir) => candidateDir.startsWith(dir))) return; + + // Remove dirs that will be covered by the candidate dir + patternDirs = patternDirs.filter((dir) => !dir.startsWith(candidateDir)); + + // Accept the candidate dir + patternDirs.push(candidateDir); + }); + + this.patternDirs = patternDirs; + + // Create pattern watchers + this.patternWatcherMap = new Map( + Array.from(patternDirs, (dir) => { + const watcher = fs.watch(dir, { + persistent: false, + recursive: true, + }); + + watcher.on('change', this.onChange); + + return [dir, watcher]; + }), + ); + } + + private filesPromise: Promise; + + private searchFiles() { + return globby(this.patterns, { + cwd: this.cwd, + absolute: true, + gitignore: true, + }); + } + + /** + * Update the test file list. + * - In non-watch mode, + * - is automatically called once when constructed. + * - should be manually called to update the files. + * - In watch mode, + * - should be manually called once before getting the files. + * - is automatically called on file changes. + */ + updateFiles() { + this.filesPromise = this.searchFiles().then((files) => { + this.emit('change'); + return files; + }); + } + + /** + * Returns a promise that resolves to the list by the last `updateFiles` call. + */ + getFiles() { + return this.filesPromise; + } + + private relevantWatcherMap = new Map(); + + /** + * Set additional files to watch. + * Each call will replace the previous provided files. + */ + setRelevantFiles(files: string[]) { + if (!this.watch) { + throw new Error('Cannot set relevant files when not watching'); + } + + const lastWatcherMap = this.relevantWatcherMap; + const watcherMap = new Map(); + files.forEach((file) => { + // Use the parent dir to reduce the number of watchers. + const dir = path.dirname(path.resolve(this.cwd, file)); + + let watcher = lastWatcherMap.get(dir); + + // reuse the existing watcher if possible + if (watcher) { + watcherMap.set(dir, watcher); + lastWatcherMap.delete(dir); + return; + } + + // return if already watched by another relevant file + if (watcherMap.has(dir)) { + return; + } + + // return if already watched by a pattern + if (this.patternDirs.some((patternDir) => dir.startsWith(patternDir))) { + return; + } + + // create a new watcher if not watched by any other + watcher = fs.watch(dir, { + persistent: false, + recursive: false, // Yap, not recursive. + }); + + watcher.on('change', this.onChange); + + watcherMap.set(dir, watcher); + }); + + lastWatcherMap.forEach((watcher) => { + watcher.close(); + }); + + this.relevantWatcherMap = watcherMap; + } + + private atomicTimeoutId: NodeJS.Timeout | null = null; + + private onChange = () => { + if (this.atomicTimeoutId !== null) { + return; + } + + this.atomicTimeoutId = setTimeout(() => { + this.atomicTimeoutId = null; + this.updateFiles(); + }, 100); + }; + + [Symbol.dispose]() { + if (!this.watch) return; + + this.patternWatcherMap.forEach((watcher) => { + watcher.close(); + }); + + this.relevantWatcherMap.forEach((watcher) => { + watcher.close(); + }); + } +} diff --git a/src/WS/WSClient.ts b/src/WS/WSClient.ts index 99bf1c3..49b3abb 100644 --- a/src/WS/WSClient.ts +++ b/src/WS/WSClient.ts @@ -80,7 +80,7 @@ export default class WSClient { return this.statusPromise; } - bypassFetch(...fetchArgs: Parameters) { + bypassFetch(...fetchArgs: Parameters) { const request = new Request(...fetchArgs); request.headers.set(`bypass-${this.uuid}`, 'true'); return fetch(request); diff --git a/src/cli.ts b/src/cli.ts index 378db9c..4996425 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -79,7 +79,8 @@ program const runnerOptionsList = await parseRunnerOptionsFromCLI(testAndEntries, options); await runnerOptionsList.reduce(async (last, runnerOptions) => { await last; - const exitCode = await new Runner(runnerOptions).runTests(); + using runner = new Runner(runnerOptions); + const exitCode = await runner.runTests(); process.exitCode ||= exitCode; }, Promise.resolve()); }) diff --git a/src/util/TypedEventEmitter.ts b/src/util/TypedEventEmitter.ts new file mode 100644 index 0000000..906909e --- /dev/null +++ b/src/util/TypedEventEmitter.ts @@ -0,0 +1,40 @@ +/* eslint-disable max-len */ +import { EventEmitter as BaseEventEmitter } from 'node:events'; + +// The values of the event map are the arguments passed to the event. +export type EventMap> = Record, unknown[]>; + +// Extract valid event names from an event map. +export type EventOf> = Extract; + +// The base EventEmitter only allows string | symbol as event names. +export type EventName = string | symbol; + +export type ArbitraryEventMap = Record; + +export interface EventEmitter = ArbitraryEventMap> extends BaseEventEmitter { + addListener>(eventName: K, listener: (...args: T[K]) => void): this; + on>(eventName: K, listener: (...args: T[K]) => void): this; + once>(eventName: K, listener: (...args: T[K]) => void): this; + removeListener>(eventName: K, listener: (...args: T[K]) => void): this; + off>(eventName: K, listener: (...args: T[K]) => void): this; + removeAllListeners>(event?: K): this; + listeners>(eventName: K): ((...args: T[K]) => void)[]; + rawListeners>(eventName: K): ((...args: T[K]) => void)[]; + emit>(eventName: K, ...args: T[K]): boolean; + listenerCount>(eventName: K): number; + prependListener>(eventName: K, listener: (...args: T[K]) => void): this; + prependOnceListener>(eventName: K, listener: (...args: T[K]) => void): this; + eventNames(): Array>; +} + +export interface EventEmitterConstructor { + new = ArbitraryEventMap>(...args: ConstructorParameters): EventEmitter; + readonly prototype: EventEmitter; +} + +// @ts-expect-error not assignable as we add type restrictions +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const EventEmitter: EventEmitterConstructor = BaseEventEmitter; + +export default EventEmitter; diff --git a/src/util/patchDisposable.ts b/src/util/patchDisposable.ts new file mode 100644 index 0000000..f0e9868 --- /dev/null +++ b/src/util/patchDisposable.ts @@ -0,0 +1,8 @@ +/** + * Simple polyfill that covers the `using` and `async using` use cases. + */ + +// @ts-expect-error polyfill +Symbol.dispose ??= Symbol('Symbol.dispose'); +// @ts-expect-error polyfill +Symbol.asyncDispose ??= Symbol('Symbol.asyncDispose');