@junoflow/cli 0.6.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,347 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import { createServer } from 'node:http';
3
+ import { extname, join, posix } from 'node:path';
4
+
5
+ /**
6
+ * The URL segment the instance answers itself under /<id>/
7
+ * (internal/appserve's reservedSegment); ingest refuses an asset under it, so
8
+ * it never shadows a file here either.
9
+ */
10
+ const RESERVED = '_juno';
11
+
12
+ /**
13
+ * The version every response names. The dev server has one build of the app
14
+ * and never a second, so the client's skew check (`X-Juno-App-Version`
15
+ * against the stamp) never fires.
16
+ */
17
+ export const DEV_VERSION = 1;
18
+
19
+ /** The client the instance serves, byte for byte (TestAppClientCLICopy). */
20
+ const CLIENT = readFile(new URL('../../app-client.js', import.meta.url));
21
+
22
+ /**
23
+ * Content types by extension, for public/. The instance uses Go's
24
+ * mime.TypeByExtension; Node has no table of its own, so this is the part of
25
+ * it a page plausibly serves. Anything else is application/octet-stream.
26
+ */
27
+ const CONTENT_TYPES = {
28
+ '.avif': 'image/avif',
29
+ '.css': 'text/css; charset=utf-8',
30
+ '.gif': 'image/gif',
31
+ '.htm': 'text/html; charset=utf-8',
32
+ '.html': 'text/html; charset=utf-8',
33
+ '.ico': 'image/vnd.microsoft.icon',
34
+ '.jpeg': 'image/jpeg',
35
+ '.jpg': 'image/jpeg',
36
+ '.js': 'text/javascript; charset=utf-8',
37
+ '.json': 'application/json',
38
+ '.map': 'application/json',
39
+ '.mjs': 'text/javascript; charset=utf-8',
40
+ '.mp3': 'audio/mpeg',
41
+ '.mp4': 'video/mp4',
42
+ '.pdf': 'application/pdf',
43
+ '.png': 'image/png',
44
+ '.svg': 'image/svg+xml',
45
+ '.txt': 'text/plain; charset=utf-8',
46
+ '.wasm': 'application/wasm',
47
+ '.webm': 'video/webm',
48
+ '.webmanifest': 'application/manifest+json',
49
+ '.webp': 'image/webp',
50
+ '.woff': 'font/woff',
51
+ '.woff2': 'font/woff2',
52
+ '.xml': 'text/xml; charset=utf-8',
53
+ };
54
+
55
+ /**
56
+ * createDevServer is `juno app dev`'s HTTP server: the instance's apps
57
+ * listener (internal/appserve) for one app, answered from the working copy
58
+ * instead of a stored version.
59
+ *
60
+ * | Request | Answer |
61
+ * | ----------------------------- | -------------------------------------------- |
62
+ * | `GET /` | `routes.shell`: the page in a frame |
63
+ * | `/_dev/<rest>` | `routes.dev` |
64
+ * | `GET /<id>` | 301 → `/<id>/` |
65
+ * | `GET /<id>/`, `/<id>/<entry>` | `page/<entry>` as written |
66
+ * | `GET /<id>/<path>.ts` | that page/ module, bundled for the browser |
67
+ * | `GET /<id>/<path>` | `public/<path>` |
68
+ * | `GET /<id>/_juno/app.js` | the stamp line and the client |
69
+ * | `POST /<id>/_juno/call/<h>` | the harness's answer, as the invoker maps it |
70
+ * | `GET /<id>/_juno/stream` | `routes.stream` |
71
+ *
72
+ * `routes` is the extension point: each member is `(req, res, url)` and owns
73
+ * the response. Left out, `shell` frames the page and `dev` and `stream` are
74
+ * 404s. They are copied onto the returned object's `routes`, which is what
75
+ * the server reads per request, so a member set there later takes effect.
76
+ *
77
+ * dev is the harness (createDevHarness), builder the esbuild wrapper
78
+ * (createBuilder); log receives one line per failed call.
79
+ */
80
+ export function createDevServer({
81
+ cwd,
82
+ dev,
83
+ builder,
84
+ routes = {},
85
+ log = () => {},
86
+ }) {
87
+ const srv = {
88
+ routes: Object.assign({ shell: serveShell }, routes),
89
+ server: createServer((req, res) => {
90
+ handle(req, res).catch((err) => {
91
+ log(`${req.method} ${req.url}: ${err.message}`);
92
+ if (!res.headersSent) sendText(res, 500, 'internal error');
93
+ else res.destroy();
94
+ });
95
+ }),
96
+
97
+ /** Listens on 127.0.0.1 only and resolves to the port. */
98
+ listen(port) {
99
+ return new Promise((resolve, reject) => {
100
+ srv.server.once('error', reject);
101
+ srv.server.listen(port, '127.0.0.1', () => {
102
+ srv.server.off('error', reject);
103
+ resolve(srv.server.address().port);
104
+ });
105
+ });
106
+ },
107
+
108
+ /** Stops listening and drops every open connection (a stream included). */
109
+ close() {
110
+ return new Promise((resolve) => {
111
+ srv.server.close(() => resolve());
112
+ srv.server.closeAllConnections();
113
+ });
114
+ },
115
+ };
116
+
117
+ async function handle(req, res) {
118
+ res.setHeader('X-Content-Type-Options', 'nosniff');
119
+ res.setHeader('Cache-Control', 'no-store');
120
+ const url = new URL(req.url, 'http://localhost');
121
+ const id = dev.manifest.id;
122
+
123
+ if (url.pathname === '/') {
124
+ if (!allowGet(req, res)) return;
125
+ return srv.routes.shell(req, res, url);
126
+ }
127
+ if (url.pathname.startsWith('/_dev/')) {
128
+ return route(srv.routes.dev, req, res, url);
129
+ }
130
+ if (url.pathname === `/${id}`) {
131
+ res.writeHead(301, { Location: `/${id}/${url.search}` });
132
+ return res.end();
133
+ }
134
+ if (!url.pathname.startsWith(`/${id}/`)) {
135
+ return sendHTML(res, 404, 'This app is not available');
136
+ }
137
+
138
+ // Only the app's own frame, the shell's, may embed it.
139
+ res.setHeader('Content-Security-Policy', "frame-ancestors 'self'");
140
+ const rest = safePath(url.pathname.slice(id.length + 2));
141
+ if (rest === null) return sendText(res, 404, 'not found');
142
+
143
+ if (rest.startsWith(`${RESERVED}/`)) {
144
+ const after = rest.slice(RESERVED.length + 1);
145
+ if (after === 'app.js') return serveClient(req, res, id);
146
+ if (after === 'stream') return route(srv.routes.stream, req, res, url);
147
+ if (after.startsWith('call/')) {
148
+ return serveCall(req, res, id, after.slice('call/'.length));
149
+ }
150
+ return sendText(res, 404, 'not found');
151
+ }
152
+ if (!allowGet(req, res)) return;
153
+ const entry = dev.manifest.entry || 'index.html';
154
+ if (rest === '' || rest === entry) {
155
+ return serveFile(
156
+ req,
157
+ res,
158
+ join(cwd, 'page', entry),
159
+ CONTENT_TYPES['.html']
160
+ );
161
+ }
162
+ if (rest.endsWith('.ts')) return servePageModule(req, res, rest);
163
+ return serveFile(req, res, join(cwd, 'public', rest), contentType(rest));
164
+ }
165
+
166
+ /** The default `/`: the page in a full-window frame, so it runs at /<id>/. */
167
+ function serveShell(_req, res) {
168
+ const id = escapeHTML(dev.manifest.id);
169
+ sendHTML(
170
+ res,
171
+ 200,
172
+ `<!doctype html>\n<meta charset="utf-8">\n<title>${id} — juno app dev</title>\n` +
173
+ '<style>html,body{margin:0;height:100%}iframe{border:0;width:100%;height:100%}</style>\n' +
174
+ `<iframe src="/${id}/"></iframe>\n`
175
+ );
176
+ }
177
+
178
+ async function serveClient(req, res, id) {
179
+ if (!allowGet(req, res)) return;
180
+ const stamp = JSON.stringify({
181
+ id,
182
+ version: DEV_VERSION,
183
+ base: `/${id}/`,
184
+ });
185
+ const body = Buffer.concat([
186
+ Buffer.from(`window.__JUNO_APP__=${stamp};\n`),
187
+ await CLIENT,
188
+ ]);
189
+ send(req, res, 200, CONTENT_TYPES['.js'], body);
190
+ }
191
+
192
+ /**
193
+ * A page/ module, built on this request. A `<script type="module">` runs
194
+ * whatever arrives with a JavaScript content type, whatever the URL's
195
+ * extension, so the page needs no rewriting (decision 3). A build failure
196
+ * is a 500 carrying esbuild's message.
197
+ */
198
+ async function servePageModule(req, res, rest) {
199
+ const abs = join(cwd, 'page', rest);
200
+ if (!(await isFile(abs))) return sendText(res, 404, 'not found');
201
+ let js;
202
+ try {
203
+ js = await builder.page(abs);
204
+ } catch (err) {
205
+ log(`build page/${rest}: ${err.message}`);
206
+ return sendText(res, 500, err.message);
207
+ }
208
+ send(req, res, 200, CONTENT_TYPES['.js'], Buffer.from(js));
209
+ }
210
+
211
+ async function serveFile(req, res, abs, type) {
212
+ if (!(await isFile(abs))) return sendText(res, 404, 'not found');
213
+ send(req, res, 200, type, await readFile(abs));
214
+ }
215
+
216
+ /**
217
+ * One handler call, in the order internal/apps/invoker.go decides it: the
218
+ * handler (404), then the body (400; an empty one is {}), then the call. A
219
+ * throw, a harness refusal and a build failure are all 500s carrying the
220
+ * message. Not simulated: the timeout, the in-flight cap, the body cap.
221
+ */
222
+ async function serveCall(req, res, id, handler) {
223
+ if (req.method !== 'POST') {
224
+ res.setHeader('Allow', 'POST');
225
+ return sendCall(res, 405, { error: 'handlers are called with POST' });
226
+ }
227
+ const raw = await readBody(req);
228
+ const handlers = dev.manifest.handlers ?? {};
229
+ if (!Object.hasOwn(handlers, handler)) {
230
+ const have = Object.keys(handlers).sort().join(', ');
231
+ return sendCall(res, 404, {
232
+ error: `app ${JSON.stringify(id)} has no handler ${JSON.stringify(handler)} (have: ${have})`,
233
+ });
234
+ }
235
+ let input;
236
+ try {
237
+ input = raw.trim() === '' ? {} : JSON.parse(raw);
238
+ } catch (err) {
239
+ return sendCall(res, 400, {
240
+ error: `body is not valid JSON: ${err.message}`,
241
+ });
242
+ }
243
+ let result;
244
+ try {
245
+ result = await dev.call(handler, input);
246
+ } catch (err) {
247
+ log(`${handler}: ${err.message}`);
248
+ return sendCall(res, 500, { error: err.message });
249
+ }
250
+ sendCall(res, 200, { result });
251
+ }
252
+
253
+ return srv;
254
+ }
255
+
256
+ /** Hands the request to an optional route, or answers 404 without one. */
257
+ function route(fn, req, res, url) {
258
+ if (typeof fn === 'function') return fn(req, res, url);
259
+ return sendText(res, 404, 'not found');
260
+ }
261
+
262
+ /**
263
+ * The path under /<id>/, decoded and normalised, or null for one that
264
+ * escapes it. A trailing slash is kept, so `dir/` names no file.
265
+ */
266
+ function safePath(encoded) {
267
+ let rel;
268
+ try {
269
+ rel = decodeURIComponent(encoded);
270
+ } catch {
271
+ return null;
272
+ }
273
+ if (rel.includes('\0') || rel.includes('\\')) return null;
274
+ if (rel === '') return '';
275
+ const norm = posix.normalize(rel);
276
+ if (norm === '..' || norm.startsWith('../') || norm.startsWith('/')) {
277
+ return null;
278
+ }
279
+ return norm;
280
+ }
281
+
282
+ function contentType(rel) {
283
+ return (
284
+ CONTENT_TYPES[extname(rel).toLowerCase()] ?? 'application/octet-stream'
285
+ );
286
+ }
287
+
288
+ async function isFile(abs) {
289
+ try {
290
+ return (await stat(abs)).isFile();
291
+ } catch {
292
+ return false;
293
+ }
294
+ }
295
+
296
+ function allowGet(req, res) {
297
+ if (req.method === 'GET' || req.method === 'HEAD') return true;
298
+ res.setHeader('Allow', 'GET, HEAD');
299
+ sendText(res, 405, 'method not allowed');
300
+ return false;
301
+ }
302
+
303
+ function readBody(req) {
304
+ return new Promise((resolve, reject) => {
305
+ const chunks = [];
306
+ req.on('data', (c) => chunks.push(c));
307
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
308
+ req.on('error', reject);
309
+ });
310
+ }
311
+
312
+ /** A body for GET, headers only for HEAD. */
313
+ function send(req, res, status, type, body) {
314
+ res.writeHead(status, {
315
+ 'Content-Type': type,
316
+ 'Content-Length': body.length,
317
+ });
318
+ res.end(req.method === 'HEAD' ? undefined : body);
319
+ }
320
+
321
+ function sendCall(res, status, body) {
322
+ res.writeHead(status, {
323
+ 'Content-Type': 'application/json',
324
+ 'X-Juno-App-Version': String(DEV_VERSION),
325
+ });
326
+ res.end(JSON.stringify(body));
327
+ }
328
+
329
+ function sendText(res, status, text) {
330
+ res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
331
+ res.end(text);
332
+ }
333
+
334
+ function sendHTML(res, status, html) {
335
+ res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' });
336
+ res.end(html);
337
+ }
338
+
339
+ function escapeHTML(s) {
340
+ return String(s).replace(
341
+ /[&<>"']/g,
342
+ (c) =>
343
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[
344
+ c
345
+ ]
346
+ );
347
+ }
@@ -0,0 +1,224 @@
1
+ /** How often an open stream sends a `: ping` comment, as the instance does. */
2
+ export const PING_MS = 20_000;
3
+
4
+ /**
5
+ * How a control stands with nothing said about it: the instance's answer for
6
+ * a button and for an idle control (appserve.ControlValue, `null`s sent).
7
+ */
8
+ const IDLE = Object.freeze({ value: null, active: false, expires_at: null });
9
+
10
+ /**
11
+ * createLive is the dev server's `_juno/stream` (plan-local-dev-6 decision
12
+ * 6), in the instance's wire shape (internal/appserve, `.claude/rules/
13
+ * apps.md` "Live values"):
14
+ *
15
+ * | Event | Data | When |
16
+ * | ---------- | -------------------------------- | ---------------------------- |
17
+ * | `snapshot` | `{"values": {states, controls}}` | on connect, and on `reset()` |
18
+ * | `update` | `{"values": {states, controls}}` | `set()`, one per moved map |
19
+ * | `pressed` | `{"binding", "at"}` | `press()` |
20
+ * | (comment) | `: ping` | every `pingMs` |
21
+ *
22
+ * Values are keyed by binding, as the instance keys them: every declared
23
+ * state binding under `states` (the scenario's value, else null) and every
24
+ * control binding under `controls` (idle). The panel's overrides sit on top.
25
+ * A read or read_write binding's override is also seeded into the harness,
26
+ * so a handler reads what the page is shown; a slot or signal binding holds
27
+ * no value a handler can read, so its override reaches the page only.
28
+ *
29
+ * `stale` is never sent: the dev server serves one version, whatever `?v=`
30
+ * the client names. A handler's claim or set moves no value here, because
31
+ * the layer stack is the instance's, as it is in the harness.
32
+ *
33
+ * dev is the harness (createDevHarness). Telling the panel the values moved
34
+ * is the caller's (createPanel).
35
+ */
36
+ export function createLive({ dev, pingMs = PING_MS }) {
37
+ const streams = new Set();
38
+ // The panel's overrides, by binding. Cleared by a scenario switch.
39
+ let overrides = { states: {}, controls: {} };
40
+
41
+ const live = {
42
+ /** The values a snapshot would carry now. */
43
+ values() {
44
+ const states = {};
45
+ for (const name of Object.keys(dev.manifest.states ?? {})) {
46
+ states[name] = Object.hasOwn(overrides.states, name)
47
+ ? overrides.states[name]
48
+ : (dev.scenario?.value?.states?.[name] ?? null);
49
+ }
50
+ const controls = {};
51
+ for (const name of Object.keys(dev.manifest.controls ?? {})) {
52
+ controls[name] = overrides.controls[name] ?? { ...IDLE };
53
+ }
54
+ return { states, controls };
55
+ },
56
+
57
+ /**
58
+ * Moves values, `{states?, controls?}` by binding, and sends one
59
+ * `update` per map that moved, the other map `{}` — as the instance
60
+ * sends a settle generation and a control change separately. A binding
61
+ * the manifest does not declare, or a control value that is not an
62
+ * object, throws before anything moves.
63
+ */
64
+ set({ states = {}, controls = {} } = {}) {
65
+ const declared = {
66
+ states: dev.manifest.states ?? {},
67
+ controls: dev.manifest.controls ?? {},
68
+ };
69
+ for (const [section, map] of Object.entries({ states, controls })) {
70
+ if (map === null || typeof map !== 'object' || Array.isArray(map)) {
71
+ throw new Error(`"${section}" maps bindings to values`);
72
+ }
73
+ for (const name of Object.keys(map)) {
74
+ if (!Object.hasOwn(declared[section], name)) {
75
+ throw new Error(
76
+ `"${name}" is not declared in manifest.json ${section} (have: ${Object.keys(declared[section]).sort().join(', ') || '(none)'})`
77
+ );
78
+ }
79
+ }
80
+ }
81
+ const movedControls = {};
82
+ for (const [name, v] of Object.entries(controls)) {
83
+ movedControls[name] = controlValue(name, v);
84
+ }
85
+ const seeded = {};
86
+ for (const [name, v] of Object.entries(states)) {
87
+ if (holdsValue(declared.states[name])) seeded[name] = v;
88
+ }
89
+ dev.h.states(seeded);
90
+
91
+ Object.assign(overrides.states, states);
92
+ Object.assign(overrides.controls, movedControls);
93
+ if (Object.keys(states).length) {
94
+ broadcast('update', { values: { states, controls: {} } });
95
+ }
96
+ if (Object.keys(movedControls).length) {
97
+ broadcast('update', {
98
+ values: { states: {}, controls: movedControls },
99
+ });
100
+ }
101
+ },
102
+
103
+ /** Sends `pressed` for a declared control binding, at now. */
104
+ press(binding) {
105
+ const controls = dev.manifest.controls ?? {};
106
+ if (typeof binding !== 'string' || !Object.hasOwn(controls, binding)) {
107
+ throw new Error(
108
+ `"${binding}" is not declared in manifest.json controls (have: ${Object.keys(controls).sort().join(', ') || '(none)'})`
109
+ );
110
+ }
111
+ const data = { binding, at: new Date().toISOString() };
112
+ broadcast('pressed', data);
113
+ return data;
114
+ },
115
+
116
+ /**
117
+ * After the harness was rebuilt (a scenario switch, a manifest save):
118
+ * drops the overrides with `clear`, or keeps those whose binding is still
119
+ * declared and seeds them into the fresh harness, then sends every open
120
+ * stream a new snapshot.
121
+ */
122
+ reset({ clear }) {
123
+ if (clear) {
124
+ overrides = { states: {}, controls: {} };
125
+ } else {
126
+ const states = dev.manifest.states ?? {};
127
+ const controls = dev.manifest.controls ?? {};
128
+ overrides = {
129
+ states: pick(overrides.states, states),
130
+ controls: pick(overrides.controls, controls),
131
+ };
132
+ const seeded = {};
133
+ for (const [name, v] of Object.entries(overrides.states)) {
134
+ if (holdsValue(states[name])) seeded[name] = v;
135
+ }
136
+ dev.h.states(seeded);
137
+ }
138
+ broadcast('snapshot', { values: live.values() });
139
+ },
140
+
141
+ /** The `_juno/stream` route. */
142
+ serve(req, res) {
143
+ if (req.method !== 'GET') {
144
+ res.writeHead(405, {
145
+ Allow: 'GET',
146
+ 'Content-Type': 'text/plain; charset=utf-8',
147
+ });
148
+ return res.end('method not allowed');
149
+ }
150
+ const stream = openStream(res, pingMs, () => streams.delete(stream));
151
+ streams.add(stream);
152
+ stream.send('snapshot', { values: live.values() });
153
+ },
154
+
155
+ /** Ends every open stream. */
156
+ close() {
157
+ for (const s of streams) s.end();
158
+ },
159
+ };
160
+
161
+ function broadcast(event, data) {
162
+ for (const s of streams) s.send(event, data);
163
+ }
164
+
165
+ return live;
166
+ }
167
+
168
+ /**
169
+ * openStream starts a server-sent event response with the instance's
170
+ * headers, and a `: ping` comment every pingMs so an idle connection is kept.
171
+ * The timer stops when the response closes, by either side. Returns
172
+ * `{send(event, data), end()}`.
173
+ */
174
+ export function openStream(res, pingMs, onClose = () => {}) {
175
+ res.writeHead(200, {
176
+ 'Content-Type': 'text/event-stream',
177
+ 'Cache-Control': 'no-cache',
178
+ 'X-Accel-Buffering': 'no',
179
+ });
180
+ res.flushHeaders();
181
+ const ping = setInterval(() => res.write(': ping\n\n'), pingMs);
182
+ res.on('close', () => {
183
+ clearInterval(ping);
184
+ onClose();
185
+ });
186
+ return {
187
+ // One line of JSON, which never contains a raw newline.
188
+ send(event, data) {
189
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
190
+ },
191
+ end() {
192
+ clearInterval(ping);
193
+ res.end();
194
+ },
195
+ };
196
+ }
197
+
198
+ /** A read or read_write binding: what h.states seeds and a handler reads. */
199
+ function holdsValue(binding) {
200
+ return !binding?.slot && !binding?.signal;
201
+ }
202
+
203
+ /** A control's value as the instance sends it: the three fields, nulls kept. */
204
+ function controlValue(name, v) {
205
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) {
206
+ throw new Error(
207
+ `controls "${name}" — expected {value, active, expires_at}, got ${JSON.stringify(v)}`
208
+ );
209
+ }
210
+ if (v.expires_at != null && typeof v.expires_at !== 'string') {
211
+ throw new Error(`controls "${name}" — expires_at must be a string or null`);
212
+ }
213
+ return {
214
+ value: v.value ?? null,
215
+ active: v.active === true,
216
+ expires_at: v.expires_at ?? null,
217
+ };
218
+ }
219
+
220
+ function pick(map, declared) {
221
+ return Object.fromEntries(
222
+ Object.entries(map).filter(([name]) => Object.hasOwn(declared, name))
223
+ );
224
+ }
@@ -0,0 +1,47 @@
1
+ import { join } from 'node:path';
2
+
3
+ import { watchPaths } from '../watch.js';
4
+
5
+ /**
6
+ * watchApp is `juno app dev`'s rebuild on save (plan-local-dev-6 decision 9):
7
+ * one debounced watch per part of the working copy, so a save reaches the
8
+ * one handler that knows what to rebuild.
9
+ *
10
+ * | Handler | Watches |
11
+ * | ---------- | ---------------------------------------- |
12
+ * | `manifest` | `manifest.json` |
13
+ * | `src` | `src/`, recursively |
14
+ * | `page` | `page/` and `public/`, recursively |
15
+ * | `fixtures` | `test/fixtures/`, recursively |
16
+ *
17
+ * manifest.json is watched through its directory, filtered by name, because
18
+ * a watch on the file itself is lost the first time an editor saves by
19
+ * renaming over it. A directory absent at start is not watched. Returns
20
+ * `{close()}`.
21
+ */
22
+ export function watchApp(cwd, on, { debounceMs } = {}) {
23
+ const groups = {
24
+ manifest: [
25
+ {
26
+ path: cwd,
27
+ filter: (name) => name === null || name === 'manifest.json',
28
+ },
29
+ ],
30
+ src: [{ path: join(cwd, 'src'), recursive: true }],
31
+ page: [
32
+ { path: join(cwd, 'page'), recursive: true },
33
+ { path: join(cwd, 'public'), recursive: true },
34
+ ],
35
+ fixtures: [{ path: join(cwd, 'test', 'fixtures'), recursive: true }],
36
+ };
37
+ const watchers = [];
38
+ for (const [group, paths] of Object.entries(groups)) {
39
+ if (typeof on[group] !== 'function') continue;
40
+ watchers.push(watchPaths(paths, on[group], { debounceMs }));
41
+ }
42
+ return {
43
+ close() {
44
+ for (const w of watchers) w.close();
45
+ },
46
+ };
47
+ }