UNPKG

5.9 kBJavaScriptView Raw
1import { spawn as cpSpawn } from "child_process";
2import { existsSync, readdirSync, readFileSync } from "fs";
3import { pathJoin, spawn } from "@lbu/stdlib";
4import chokidar from "chokidar";
5import treeKill from "tree-kill";
6
7/**
8 * Load scripts directory and package.json scripts
9 * @returns {ScriptCollection}
10 */
11export function collectScripts() {
12 const result = {};
13
14 const userDir = pathJoin(process.cwd(), "scripts");
15 if (existsSync(userDir)) {
16 for (const item of readdirSync(userDir)) {
17 if (!item.endsWith(".js")) {
18 continue;
19 }
20
21 const name = item.split(".")[0];
22
23 result[name] = {
24 type: "user",
25 name,
26 path: pathJoin(userDir, item),
27 };
28 }
29 }
30
31 const pkgJsonPath = pathJoin(process.cwd(), "package.json");
32 if (existsSync(pkgJsonPath)) {
33 const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
34 for (const name of Object.keys(pkgJson.scripts || {})) {
35 result[name] = {
36 type: "package",
37 name,
38 script: pkgJson.scripts[name],
39 };
40 }
41 }
42
43 return result;
44}
45
46/**
47 * @param {*} [options]
48 * @returns {CliWatchOptions}
49 */
50export function watchOptionsWithDefaults(options) {
51 /** @type {string[]} } */
52 const extensions = options?.extensions ?? ["js", "json", "mjs", "cjs"];
53 /** @type {string[]} } */
54 const ignoredPatterns = options?.ignoredPatterns ?? ["__fixtures__"];
55 /** @type {boolean} */
56 const disable = options?.disable ?? false;
57
58 if (!Array.isArray(extensions)) {
59 throw new TypeError(
60 `Expected cliWatchOptions.extensions to be an array. Found ${extensions}`,
61 );
62 }
63
64 if (!Array.isArray(ignoredPatterns)) {
65 throw new TypeError(
66 `Expected cliWatchOptions.ignoredPatterns to be an array. Found ${ignoredPatterns}`,
67 );
68 }
69
70 for (let i = 0; i < extensions.length; ++i) {
71 // Remove '.' from extension if specified
72 if (extensions[i].startsWith(".")) {
73 extensions[i] = extensions[i].substring(1);
74 }
75 }
76
77 return {
78 disable,
79 extensions,
80 ignoredPatterns,
81 };
82}
83
84/**
85 * Compiles an chokidar ignore array for the specified options
86 * @param {CliWatchOptions} options
87 * @return {function(string): boolean}
88 */
89export function watchOptionsToIgnoredArray(options) {
90 // Compiled patterns contains extension filter and ignores dotfiles and node_modules
91 const patterns = [
92 RegExp(`\\.(?!${options.extensions.join("|")})[a-z]{1,8}$`),
93 /(^|[/\\])\../,
94 /node_modules/,
95 ];
96
97 for (const pattern of options.ignoredPatterns) {
98 if (pattern instanceof RegExp) {
99 patterns.push(pattern);
100 } else if (typeof pattern === "string") {
101 patterns.push(RegExp(pattern));
102 } else {
103 throw new TypeError(
104 `cliWatchOptions.ignoredPatterns accepts only string and RegExp. Found ${pattern}`,
105 );
106 }
107 }
108
109 const cwd = process.cwd();
110
111 return (path) => {
112 if (path.startsWith(cwd)) {
113 path = path.substring(cwd.length);
114 }
115
116 for (const pattern of patterns) {
117 if (pattern.test(path)) {
118 return true;
119 }
120 }
121
122 return false;
123 };
124}
125
126/**
127 * @param logger
128 * @param verbose
129 * @param watch
130 * @param command
131 * @param commandArgs
132 * @param {CliWatchOptions} watchOptions
133 */
134export async function executeCommand(
135 logger,
136 verbose,
137 watch,
138 command,
139 commandArgs,
140 watchOptions,
141) {
142 if (verbose) {
143 logger.info({
144 msg: "Executing command",
145 verbose,
146 watch,
147 command,
148 commandArgs,
149 });
150 }
151
152 if (!watch) {
153 // Easy mode
154 return spawn(command, commandArgs);
155 }
156
157 // May supply empty watchOptions so all defaults again
158 const ignored = watchOptionsToIgnoredArray(
159 watchOptionsWithDefaults(watchOptions),
160 );
161
162 let timeout = undefined;
163 let instance = undefined;
164 let instanceKilled = false;
165
166 const watcher = chokidar.watch(".", {
167 persistent: true,
168 ignorePermissionErrors: true,
169 ignored,
170 cwd: process.cwd(),
171 });
172
173 watcher.on("change", (path) => {
174 if (verbose) {
175 logger.info(`Restarted because of ${path}`);
176 }
177
178 restart();
179 });
180
181 watcher.on("ready", () => {
182 if (verbose) {
183 logger.info({
184 watched: watcher.getWatched(),
185 });
186 }
187
188 start();
189 prepareStdin(restart);
190 });
191
192 function start() {
193 instance = cpSpawn(command, commandArgs, {
194 stdio: "inherit",
195 });
196 instanceKilled = false;
197
198 instance.on("close", (code) => {
199 if (!instanceKilled || verbose) {
200 logger.info(`Process exited with code ${code ?? 0}`);
201 }
202 instance = undefined;
203 });
204 }
205
206 function stop() {
207 if (instance) {
208 // Needs tree-kill since `instance.kill` does not kill spawned processes by this instance
209 treeKill(instance.pid, "SIGKILL", (error) => {
210 logger.error({
211 message: "Could not kill process",
212 error,
213 });
214 });
215
216 // We don't way for the process to be killed
217 // This may leak some instances in edge cases
218 instanceKilled = true;
219 instance = undefined;
220 }
221 }
222
223 /**
224 * Restart with debounce
225 * @param {boolean} [skipDebounce]
226 */
227 function restart(skipDebounce) {
228 // Restart may be called multiple times in a row
229 // We may want to add some kind of graceful back off here
230 if (timeout !== undefined) {
231 clearTimeout(timeout);
232 }
233
234 if (skipDebounce) {
235 stop();
236 start();
237 } else {
238 timeout = setTimeout(() => {
239 stop();
240 start();
241 clearTimeout(timeout);
242 }, 250);
243 }
244 }
245}
246
247/**
248 * Prepare stdin to be used for manual restarting
249 * @param {Function} restart
250 */
251function prepareStdin(restart) {
252 process.stdin.resume();
253 process.stdin.setEncoding("utf-8");
254 process.stdin.on("data", (data) => {
255 const input = data.toString().trim().toLowerCase();
256
257 // Consistency with Nodemon
258 if (input === "rs") {
259 restart(true);
260 }
261 });
262}