diff --git a/.eslintrc b/.eslintrc index c3cceab..0d13c16 100644 --- a/.eslintrc +++ b/.eslintrc @@ -119,7 +119,14 @@ { "autoFix": false, "cspell": { - "words": ["cronstrue", "encryptor", "pino", "unstash", "zeplo"] + "words": [ + "cronstrue", + "encryptor", + "memfs", + "pino", + "unstash", + "zeplo" + ] } } ], @@ -239,6 +246,7 @@ "unicorn/explicit-length-check": "off", "unicorn/import-style": "off", "unicorn/no-array-callback-reference": "off", + "unicorn/no-array-for-each": "off", "unicorn/no-array-method-this-argument": "off", "unicorn/no-array-reduce": "off", "unicorn/no-await-expression-member": "off", @@ -250,7 +258,7 @@ "unicorn/numeric-separators-style": "off", "unicorn/prefer-at": "error", "unicorn/prefer-json-parse-buffer": "error", - "unicorn/prefer-node-protocol": "off", + "unicorn/prefer-node-protocol": "error", "unicorn/prefer-string-replace-all": "error", "unicorn/prefer-top-level-await": "off", "unicorn/prefer-type-error": "off", diff --git a/jest.config.json b/jest.config.json index 5332700..b508ccd 100644 --- a/jest.config.json +++ b/jest.config.json @@ -1,4 +1,8 @@ { + "fakeTimers": { + "enableGlobally": true, + "now": 1696486441293 + }, "passWithNoTests": true, "preset": "ts-jest", "restoreMocks": true, diff --git a/package-lock.json b/package-lock.json index 5ad35cf..93b00ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,10 +56,12 @@ "is-ci": "3.0.1", "jest": "29.7.0", "lint-staged": "15.1.0", + "memfs": "4.6.0", "prettier": "2.8.8", "prettier-plugin-sh": "0.12.8", "semantic-release": "22.0.8", "ts-jest": "29.1.1", + "ts-node": "10.9.2", "tsc-watch": "6.0.4", "typescript": "5.3.2" }, @@ -1386,8 +1388,6 @@ "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", "dev": true, - "optional": true, - "peer": true, "dependencies": { "@jridgewell/trace-mapping": "0.3.9" }, @@ -1400,8 +1400,6 @@ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", "dev": true, - "optional": true, - "peer": true, "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" @@ -3627,33 +3625,25 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4188,8 +4178,6 @@ "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", "dev": true, - "optional": true, - "peer": true, "engines": { "node": ">=0.4.0" } @@ -4346,9 +4334,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "node_modules/argparse": { "version": "2.0.1", @@ -5650,9 +5636,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "node_modules/croner": { "version": "8.0.0", @@ -7572,6 +7556,13 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "peer": true + }, "node_modules/fast-equals": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.0.1.tgz", @@ -8494,6 +8485,15 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/hyperdyperid": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "dev": true, + "engines": { + "node": ">=10.18" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -10236,6 +10236,43 @@ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true }, + "node_modules/json-joy": { + "version": "9.9.1", + "resolved": "https://registry.npmjs.org/json-joy/-/json-joy-9.9.1.tgz", + "integrity": "sha512-/d7th2nbQRBQ/nqTkBe6KjjvDciSwn9UICmndwk3Ed/Bk9AqkTRm4PnLVfXG4DKbT0rEY0nKnwE7NqZlqKE6kg==", + "dev": true, + "dependencies": { + "arg": "^5.0.2", + "hyperdyperid": "^1.2.0" + }, + "bin": { + "jj": "bin/jj.js", + "json-pack": "bin/json-pack.js", + "json-pack-test": "bin/json-pack-test.js", + "json-patch": "bin/json-patch.js", + "json-patch-test": "bin/json-patch-test.js", + "json-pointer": "bin/json-pointer.js", + "json-pointer-test": "bin/json-pointer-test.js", + "json-unpack": "bin/json-unpack.js" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "quill-delta": "^5", + "rxjs": "7", + "tslib": "2" + } + }, + "node_modules/json-joy/node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, "node_modules/json-parse-better-errors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", @@ -10622,6 +10659,13 @@ "integrity": "sha512-kZzYOKspf8XVX5AvmQF94gQW0lejFVgb80G85bU4ZWzoJ6C03PQg3coYAUpSTpQWelrZELd3XWgHzw4Ck5kaIw==", "dev": true }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "dev": true, + "peer": true + }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", @@ -10640,6 +10684,13 @@ "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", "dev": true }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "dev": true, + "peer": true + }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", @@ -11072,6 +11123,26 @@ "url": "https://github.com/sindresorhus/mem?sponsor=1" } }, + "node_modules/memfs": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.6.0.tgz", + "integrity": "sha512-I6mhA1//KEZfKRQT9LujyW6lRbX7RkC24xKododIDO3AGShcaFAMKElv1yFGWX8fD4UaSiwasr3NeQ5TdtHY1A==", + "dev": true, + "dependencies": { + "json-joy": "^9.2.0", + "thingies": "^1.11.1" + }, + "engines": { + "node": ">= 4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, "node_modules/meow": { "version": "12.1.1", "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", @@ -15788,6 +15859,21 @@ "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" }, + "node_modules/quill-delta": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", + "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==", + "dev": true, + "peer": true, + "dependencies": { + "fast-diff": "^1.3.0", + "lodash.clonedeep": "^4.5.0", + "lodash.isequal": "^4.5.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -17633,6 +17719,18 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/thingies": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-1.16.0.tgz", + "integrity": "sha512-J23AVs11hSQxuJxvfQyMIaS9z1QpDxOCvMkL3ZxZl8/jmkgmnNGWrlyNxVz6Jbh0U6DuGmHqq6f7zUROfg/ncg==", + "dev": true, + "engines": { + "node": ">=10.18" + }, + "peerDependencies": { + "tslib": "^2" + } + }, "node_modules/thread-stream": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.4.1.tgz", @@ -17846,8 +17944,6 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, - "optional": true, - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -17891,8 +17987,6 @@ "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", "dev": true, - "optional": true, - "peer": true, "engines": { "node": ">=0.3.1" } @@ -18402,9 +18496,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "node_modules/v8-to-istanbul": { "version": "9.2.0", @@ -18875,8 +18967,6 @@ "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", "dev": true, - "optional": true, - "peer": true, "engines": { "node": ">=6" } diff --git a/package.json b/package.json index b8cb216..1ed6a87 100644 --- a/package.json +++ b/package.json @@ -69,10 +69,12 @@ "is-ci": "3.0.1", "jest": "29.7.0", "lint-staged": "15.1.0", + "memfs": "4.6.0", "prettier": "2.8.8", "prettier-plugin-sh": "0.12.8", "semantic-release": "22.0.8", "ts-jest": "29.1.1", + "ts-node": "10.9.2", "tsc-watch": "6.0.4", "typescript": "5.3.2" }, diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..21c5806 --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,291 @@ +import { Buffer } from "node:buffer"; +import fsNative from "node:fs"; +import { setTimeout } from "node:timers/promises"; + +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from "@jest/globals"; +import type { SpiedFunction } from "jest-mock"; +import { memfs as memfsNative } from "memfs"; + +import { main } from "."; + +const memfs = (...args: Parameters) => { + const { fs, vol } = memfsNative(...args); + + return { + vol, + fs: { + ...fs, + /* eslint-disable promise/prefer-await-to-callbacks -- HACK */ + readFile: ( + path: string, + callback: (err: NodeJS.ErrnoException | null, data: Buffer) => void + ) => { + try { + callback(null, fs.readFileSync(path) as Buffer); + } catch (error) { + callback(error as NodeJS.ErrnoException, Buffer.from([])); + } + }, + watch: (path: any, options: { signal?: AbortSignal }, handler: any) => { + const watcher = fs.watch(path, options as any, handler); + + const closeBefore = watcher.close.bind(watcher); + + // TODO PR For https://github.com/streamich/memfs/blob/eac1ce29b7aa0a18b3b20d7f4821c020526420ee/src/volume.ts#L2573 + watcher.close = () => { + closeBefore(); + watcher.emit("close"); + }; + + // TODO PR For this + options.signal?.addEventListener("abort", () => { + watcher.close(); + }); + + return watcher; + }, + /* eslint-enable promise/prefer-await-to-callbacks */ + } as unknown as typeof fsNative, + }; +}; + +describe("main", () => { + let controller: AbortController; + let proc: ReturnType | undefined; + let fetchSpy: SpiedFunction; + const destination = { + logs: [] as any[], + clear: () => { + destination.logs = []; + }, + write: (msg: string) => { + destination.logs.push(JSON.parse(msg)); + }, + }; + + beforeEach(() => { + controller = new AbortController(); + fetchSpy = jest.spyOn(global, "fetch").mockImplementation( + async (url) => + ({ + headers: {}, + ok: true, + redirected: false, + status: 200, + statusText: "OK", + type: "basic", + url: url.toString(), + text: async () => "", + } as Response) + ); + }); + + afterEach(async () => { + controller.abort(); + + await proc; + + jest.clearAllTimers(); + destination.clear(); + }); + + it("runs forever", async () => { + const { fs } = memfs( + { "./vercel.json": JSON.stringify({}) }, + process.cwd() + ); + + proc = main({ + destination, + fs, + signal: controller.signal, + }); + + await jest.advanceTimersByTimeAsync(0); + + const [winner] = await Promise.all([ + Promise.race([ + setTimeout(500, "timeout", { signal: controller.signal }), + proc, + ]), + jest.advanceTimersByTimeAsync(500), + ]); + + expect(winner).toBe("timeout"); + expect(destination.logs).toContainEqual({ + level: 30, + time: 1696486441293, + msg: "No CRONs Scheduled", + }); + }); + + it("dry ends the process immediately", async () => { + const { fs } = memfs( + { "./vercel.json": JSON.stringify({}) }, + process.cwd() + ); + + proc = main({ + destination, + fs, + signal: controller.signal, + dry: true, + }); + + await jest.advanceTimersByTimeAsync(0); + + const [winner] = await Promise.all([ + Promise.race([ + setTimeout(500, "timeout", { signal: controller.signal }), + proc, + ]), + jest.advanceTimersByTimeAsync(500), + ]); + + expect(winner).not.toBe("timeout"); + expect(destination.logs).toContainEqual({ + level: 30, + time: 1696486441293, + msg: "No CRONs Scheduled", + }); + }); + + it("executes CRON when schedule passes", async () => { + const { fs } = memfs( + { + "./vercel.json": JSON.stringify({ + crons: [{ path: "/some-api", schedule: "* * * * * *" }], + }), + }, + process.cwd() + ); + + proc = main({ + destination, + fs, + signal: controller.signal, + }); + + await jest.advanceTimersByTimeAsync(0); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(destination.logs).toContainEqual({ + level: 30, + msg: "Scheduled /some-api Every second", + time: 1696486441293, + }); + destination.clear(); + + await jest.advanceTimersByTimeAsync(1000); + + expect(fetchSpy).toHaveBeenNthCalledWith( + 1, + "http://localhost:3000/some-api", + expect.objectContaining({ + method: "GET", + redirect: "manual", + headers: {}, + }) + ); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(destination.logs).toContainEqual({ + currentRun: "2023-10-05T06:14:02.000Z", + level: 30, + msg: "Started /some-api Every second", + time: 1696486442000, + }); + expect(destination.logs).toContainEqual({ + currentRun: "2023-10-05T06:14:02.000Z", + level: 30, + msg: "Succeeded /some-api Every second", + status: 200, + text: "", + time: 1696486442000, + }); + destination.clear(); + + await jest.advanceTimersByTimeAsync(1000); + + expect(fetchSpy).toHaveBeenNthCalledWith( + 2, + "http://localhost:3000/some-api", + expect.objectContaining({ + method: "GET", + redirect: "manual", + headers: {}, + }) + ); + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(destination.logs).toContainEqual({ + currentRun: "2023-10-05T06:14:03.000Z", + level: 30, + msg: "Started /some-api Every second", + time: 1696486443000, + }); + expect(destination.logs).toContainEqual({ + currentRun: "2023-10-05T06:14:03.000Z", + level: 30, + msg: "Succeeded /some-api Every second", + status: 200, + text: "", + time: 1696486443000, + }); + }); + + it("misses CRON if config changes", async () => { + const { fs } = memfs( + { + "./vercel.json": JSON.stringify({ + crons: [{ path: "/some-api", schedule: "* * * * * *" }], + }), + }, + process.cwd() + ); + + proc = main({ + destination, + fs, + level: "trace", + signal: controller.signal, + }); + + await jest.advanceTimersByTimeAsync(0); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(destination.logs).toContainEqual({ + level: 30, + msg: "Scheduled /some-api Every second", + time: 1696486441293, + }); + destination.clear(); + + fs.writeFileSync("./vercel.json", JSON.stringify({})); + await jest.advanceTimersByTimeAsync(0); + + expect(destination.logs).toContainEqual({ + config: "./vercel.json", + level: 30, + msg: "Config Changed", + time: 1696486441293, + }); + expect(destination.logs).toContainEqual({ + level: 30, + msg: "No CRONs Scheduled", + time: 1696486441293, + }); + destination.clear(); + + await jest.advanceTimersByTimeAsync(100000); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(fetchSpy).toHaveBeenCalledTimes(0); + expect(destination.logs).toHaveLength(0); + }); +}); diff --git a/src/index.ts b/src/index.ts index 25f5f96..936f5c3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ -import fs from "node:fs/promises"; +import fsNative from "node:fs"; +import { promisify } from "node:util"; import boxen from "boxen"; import chalk from "chalk"; @@ -7,44 +8,31 @@ import cronstrue from "cronstrue"; import pino from "pino"; import z from "zod"; -type MaybePromise = Promise | V; - -const ignoreAbortError = async ( - fn: Promise | (() => MaybePromise) -): Promise => { - try { - return await (fn instanceof Promise ? fn : fn()); - } catch (error) { - if ( - !z - .intersection( - z.instanceof(Error), - z.object({ - cause: z.intersection( - z.instanceof(DOMException), - z.object({ name: z.literal("AbortError") }) - ), - }) - ) - .safeParse(error).success - ) { - throw error; - } - } +// TODO [engine:node@>=20.3.0]: Replace with AbortSignal.any +const anySignal = (signals: Array) => { + const controller = new globalThis.AbortController(); - return undefined; -}; + const onAbort = () => { + controller.abort(); + + signals.forEach((signal) => { + if (signal?.removeEventListener) { + signal.removeEventListener("abort", onAbort); + } + }); + }; -const unshiftIterable = async function* ( - value: T, - iterable: AsyncIterable -) { - yield value; + signals.forEach((signal) => { + if (signal?.addEventListener) { + signal.addEventListener("abort", onAbort); + } + }); - // eslint-disable-next-line no-restricted-syntax, fp/no-loops -- Generator loop - for await (const value of iterable) { - yield value; + if (signals.some((signal) => signal?.aborted)) { + onAbort(); } + + return controller.signal; }; export const zOpts = z @@ -53,6 +41,7 @@ export const zOpts = z config: z.string(), dry: z.boolean(), ignoreTimestamp: z.boolean(), + pretty: z.boolean(), secret: z.nullable(z.string()), url: z.string(), level: z.union([ @@ -68,37 +57,69 @@ export const zOpts = z .partial(); export const defaults = { - color: Boolean(chalk.supportsColor), config: "./vercel.json", level: "info", secret: process.env.CRON_SECRET ?? null, url: "http://localhost:3000", } satisfies z.infer; -export const main = async (opts: z.infer = {}) => { - const { color, config, dry, ignoreTimestamp, level, secret, url } = { +export const main = async ({ + destination, + signal, + fs = fsNative, + ...opts +}: z.infer & { + destination?: pino.DestinationStream; + fs?: typeof fsNative; + signal?: AbortSignal; +}) => { + const { + config, + dry, + ignoreTimestamp, + level, + secret, + url, + color = false, + pretty = false, + } = { ...defaults, ...opts, }; - const logger = pino({ + if (chalk.supportsColor && !color) { + chalk.level = 0; + } + + const loggerOptions = { level, timestamp: !ignoreTimestamp, errorKey: "error", - transport: { - target: "pino-pretty", - options: { - colorize: color, - float: "center", - levelFirst: true, - singleLine: true, - translateTime: "UTC:yyyy-mm-dd'T'HH:MM:ss.l'Z'", - ignore: "pid,hostname", - }, + redact: { + paths: ["pid", "hostname"], + remove: true, }, - }); + ...(!pretty + ? {} + : { + transport: { + target: "pino-pretty", + options: { + colorize: color, + float: "center", + levelFirst: true, + singleLine: true, + translateTime: "UTC:yyyy-mm-dd'T'HH:MM:ss.l'Z'", + }, + }, + }), + }; - if (logger.isLevelEnabled("fatal")) { + const logger = !destination + ? pino(loggerOptions) + : pino(loggerOptions, destination); + + if (logger.isLevelEnabled("info") && pretty) { // eslint-disable-next-line no-console -- boxen! console.log( boxen("▲ Vercel CRON ▲", { @@ -112,7 +133,14 @@ export const main = async (opts: z.infer = {}) => { logger.trace({ opts }, "Parsed Options"); - const watchConfig = async () => { + const readFile = promisify(fs.readFile.bind(fs)); + + const scheduleCrons = async () => { + const controller = new AbortController(); + + const configContent = (await readFile(config)).toString(); + logger.trace({ config: configContent }, "Config"); + const { crons: cronConfigs = [] } = z .object({ crons: z.optional( @@ -124,9 +152,7 @@ export const main = async (opts: z.infer = {}) => { ) ), }) - .parse(JSON.parse((await fs.readFile(config)).toString())); - - const abortController = new AbortController(); + .parse(JSON.parse(configContent)); const crons = cronConfigs.map(({ path, schedule }) => { const pathString = `${chalk.magenta(path)} ${chalk.yellow( @@ -140,25 +166,19 @@ export const main = async (opts: z.infer = {}) => { let res: Response | undefined; try { - res = await ignoreAbortError( - fetch(url + path, { - method: "GET", - redirect: "manual", - signal: abortController.signal, - headers: !secret ? {} : { Authorization: `Bearer ${secret}` }, - }) - ); + res = await fetch(url + path, { + method: "GET", + redirect: "manual", + signal: anySignal([signal, controller.signal]), + headers: !secret ? {} : { Authorization: `Bearer ${secret}` }, + }); } catch (error) { runLogger.error({ error }, `Failed ${pathString}`); return; } - if (!res) { - return; - } - - if (res.status >= 300) { + if (!res.ok) { runLogger.error( { status: res.status, error: new Error(await res.text()) }, `Failed ${pathString}` @@ -171,7 +191,7 @@ export const main = async (opts: z.infer = {}) => { } }); - abortController.signal.addEventListener("abort", cron.stop.bind(cron)); + controller.signal.addEventListener("abort", cron.stop.bind(cron)); logger.info(`Scheduled ${pathString}`); @@ -182,30 +202,39 @@ export const main = async (opts: z.infer = {}) => { logger.info("No CRONs Scheduled"); } - return abortController.abort.bind(abortController); + return controller.abort.bind(controller); }; logger.debug({ config }, "Watching Config"); let abortPrevious: (() => void) | undefined; - // eslint-disable-next-line no-restricted-syntax, fp/no-loops -- Generator loop - for await (const value of unshiftIterable( - { eventType: "rename", filename: config }, - fs.watch(config, { persistent: true }) - )) { + const handler = (async (eventType, filename) => { + logger.trace({ eventType, filename }, "fs.watch"); + if (abortPrevious) { - logger.trace(value, "fs.watch"); logger.info({ config }, "Config Changed"); } abortPrevious?.(); - abortPrevious = await watchConfig(); - - if (dry) { - break; + try { + // eslint-disable-next-line require-atomic-updates -- HACK + abortPrevious = await scheduleCrons(); + } catch (error) { + logger.fatal({ error }, "Failed to Schedule CRONs"); + abortPrevious?.(); } + }) satisfies Parameters[1]; + + handler("rename", config); + + if (dry) { + abortPrevious?.(); + + return; } - abortPrevious?.(); + const watcher = fs.watch(config, { signal }, handler); + + await promisify(watcher.on.bind(watcher))("close"); }; diff --git a/src/vercel-cron.test.ts b/src/vercel-cron.test.ts index 914e9ee..ad76084 100644 --- a/src/vercel-cron.test.ts +++ b/src/vercel-cron.test.ts @@ -1,29 +1,48 @@ import { exec as execCallback } from "node:child_process"; +import { setTimeout } from "node:timers/promises"; import { promisify } from "node:util"; -import { beforeAll, describe, expect, it } from "@jest/globals"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from "@jest/globals"; // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports, unicorn/prefer-module, @typescript-eslint/no-unused-vars -- HACK We're "including" bin by running a process against the built file so jest won't pick it up with `--findRelatedTests`. const helpJestFindRelatedTests = () => require("./vercel-cron"); -const exec = promisify(execCallback); +jest.useRealTimers(); describe("vercel-cron", () => { - beforeAll(async () => { - await exec("npm run build"); + const exec = promisify(execCallback); + let controller: AbortController; + + beforeEach(() => { + controller = new AbortController(); + }); + + afterEach(() => { + controller.abort(); }); - it("--version", async () => { + it("prints version", async () => { const { stderr, stdout } = await exec( - "node ./dist/vercel-cron.js --version" + "ts-node ./src/vercel-cron.ts --version", + { signal: controller.signal } ); expect(stderr).toBe(""); expect(stdout).toBe("0.0.0-development\n"); }); - it("--help", async () => { - const { stderr, stdout } = await exec("node ./dist/vercel-cron.js --help"); + it("prints help", async () => { + const { stderr, stdout } = await exec( + "ts-node ./src/vercel-cron.ts --help", + { signal: controller.signal } + ); expect(stderr).toBe(""); expect(stdout).toBe(`Usage: vercel-cron [options] @@ -35,6 +54,7 @@ Options: -s --secret Cron Secret (default: \`process.env.CRON_SECRET\`) --dry Shows scheduled CRONs and quit --color Show terminal colors (default: \`chalk.supportsColor\`) + --no-pretty No pretty printing, just a JSON stream of logs -l --level Logging Level (choices: "trace", "debug", "info", "warn", "error", "fatal", "silent", default: "info") --trace @@ -48,9 +68,21 @@ Options: `); }); - it("runs without error", async () => { + it("runs forever", async () => { + const winner = await Promise.race([ + setTimeout(500, "timeout", { signal: controller.signal }), + exec("ts-node ./src/vercel-cron.ts", { + signal: controller.signal, + }), + ]); + + expect(winner).toBe("timeout"); + }); + + it("prints banner", async () => { const { stderr, stdout } = await exec( - "node ./dist/vercel-cron.js --ignoreTimestamp --dry --no-color" + "ts-node ./src/vercel-cron.ts --ignoreTimestamp --dry --no-color", + { signal: controller.signal } ); expect(stderr).toBe(""); diff --git a/src/vercel-cron.ts b/src/vercel-cron.ts index a3df25f..0aada23 100644 --- a/src/vercel-cron.ts +++ b/src/vercel-cron.ts @@ -1,52 +1,59 @@ #!/usr/bin/env node +import chalk from "chalk"; import { Command, Option } from "commander"; import { defaults, main, zOpts } from "."; import pkg from "../package.json"; -main( - zOpts.parse( - new Command() - .name(pkg.name) - .version(pkg.version) - .option("-u --url ", "Base URL", defaults.url) - .option("-p --config ", "Vercel Config", defaults.config) - .addOption( - new Option("-s --secret ", "Cron Secret").default( - defaults.secret, - "`process.env.CRON_SECRET`" +// Stryker disable all + +(async () => + main( + zOpts.parse( + new Command() + .name(pkg.name) + .version(pkg.version) + .option("-u --url ", "Base URL", defaults.url) + .option("-p --config ", "Vercel Config", defaults.config) + .addOption( + new Option("-s --secret ", "Cron Secret").default( + defaults.secret, + "`process.env.CRON_SECRET`" + ) + ) + .option("--dry", "Shows scheduled CRONs and quit") + .addOption( + new Option("--color", "Show terminal colors").default( + Boolean(chalk.supportsColor), + "`chalk.supportsColor`" + ) + ) + .addOption( + new Option("--no-color").hideHelp().implies({ color: false }) ) - ) - .option("--dry", "Shows scheduled CRONs and quit") - .addOption( - new Option("--color", "Show terminal colors").default( - defaults.color, - "`chalk.supportsColor`" + .option("--no-pretty", "No pretty printing, just a JSON stream of logs") + .addOption( + new Option("-l --level ", "Logging Level") + .default(defaults.level) + .choices([ + "trace", + "debug", + "info", + "warn", + "error", + "fatal", + "silent", + ]) ) - ) - .addOption(new Option("--no-color").hideHelp().implies({ color: false })) - .addOption( - new Option("-l --level ", "Logging Level") - .default(defaults.level) - .choices([ - "trace", - "debug", - "info", - "warn", - "error", - "fatal", - "silent", - ]) - ) - .addOption(new Option("--trace").implies({ level: "trace" })) - .addOption(new Option("--debug").implies({ level: "debug" })) - .addOption(new Option("--info").implies({ level: "info" })) - .addOption(new Option("--warn").implies({ level: "warn" })) - .addOption(new Option("--error").implies({ level: "error" })) - .addOption(new Option("--fatal").implies({ level: "fatal" })) - .addOption(new Option("--silent").implies({ level: "silent" })) - .addOption(new Option("--ignoreTimestamp").hideHelp()) - .parse() - .opts() - ) -); + .addOption(new Option("--trace").implies({ level: "trace" })) + .addOption(new Option("--debug").implies({ level: "debug" })) + .addOption(new Option("--info").implies({ level: "info" })) + .addOption(new Option("--warn").implies({ level: "warn" })) + .addOption(new Option("--error").implies({ level: "error" })) + .addOption(new Option("--fatal").implies({ level: "fatal" })) + .addOption(new Option("--silent").implies({ level: "silent" })) + .addOption(new Option("--ignoreTimestamp").hideHelp()) + .parse() + .opts() + ) + ))();