jq79 0.5.9 → 0.5.10

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.
package/dev/cli.ts CHANGED
@@ -1,11 +1,12 @@
1
- import { devServer } from "./dev"
1
+ import { devServer, type WatchEntry } from "./dev"
2
2
 
3
3
  // the jq79 command. One subcommand so far:
4
4
  //
5
5
  // npx jq79 dev [dir] [--port 4179] [--host localhost]
6
6
  //
7
7
  // This is the entry point the no-bundle path deserves: someone who chose jq79
8
- // to avoid a toolchain shouldn't have to write a script to get a dev server.
8
+ // to avoid a toolchain shouldn't have to write a script to get a dev server -
9
+ // which is also why every devServer option is reachable from here.
9
10
 
10
11
  const USAGE = `jq79 - a mini reactive component library
11
12
 
@@ -15,6 +16,9 @@ usage:
15
16
  options:
16
17
  -p, --port <port> port to listen on (default: 4179)
17
18
  -H, --host <host> host to bind (default: localhost)
19
+ -w, --watch <glob> also watch this path or glob, reloading on a change
20
+ (repeatable; handlers need the js api)
21
+ --header <h> response header, "name: value" (repeatable)
18
22
  -h, --help show this message
19
23
  `
20
24
 
@@ -28,13 +32,36 @@ if (command !== "dev" || rest.includes("-h") || rest.includes("--help")) {
28
32
  process.exit(unknown ? 1 : 0)
29
33
  }
30
34
 
31
- const options: { rootDir?: string; port?: number; host?: string } = {}
35
+ const options: {
36
+ rootDir?: string
37
+ port?: number
38
+ host?: string
39
+ watch?: WatchEntry[]
40
+ headers?: Record<string, string>
41
+ } = {}
32
42
 
33
43
  for (let i = 0; i < rest.length; i++) {
34
44
  const arg = rest[i]
35
45
  if (arg === "-p" || arg === "--port") options.port = Number(rest[++i])
36
46
  else if (arg === "-H" || arg === "--host") options.host = rest[++i]
37
- else if (!arg.startsWith("-")) options.rootDir ??= arg // the directory to serve
47
+ else if (arg === "-w" || arg === "--watch") {
48
+ // no handler from here: an argument can't carry a function, and an entry
49
+ // without one reloads the page, which is all a terminal was ever going to ask for
50
+ const pattern = rest[++i]
51
+ if (!pattern) {
52
+ console.error("--watch takes a path or glob")
53
+ process.exit(1)
54
+ }
55
+ ;(options.watch ??= []).push({ pattern })
56
+ } else if (arg === "--header") {
57
+ // split on the first colon only: a value can hold one (a url, a port)
58
+ const [name, ...value] = (rest[++i] ?? "").split(":")
59
+ if (!name || !value.length) {
60
+ console.error(`--header takes "name: value"`)
61
+ process.exit(1)
62
+ }
63
+ ;(options.headers ??= {})[name.trim()] = value.join(":").trim()
64
+ } else if (!arg.startsWith("-")) options.rootDir ??= arg // the directory to serve
38
65
  else {
39
66
  console.error(`unknown option: ${arg}\n${USAGE}`)
40
67
  process.exit(1)
@@ -46,7 +73,12 @@ if (options.port !== undefined && !Number.isInteger(options.port)) {
46
73
  process.exit(1)
47
74
  }
48
75
 
49
- const server = await devServer(options)
76
+ // a --watch path that isn't there refuses to start; the message names it, and a
77
+ // stack trace over a typo in an argument would only bury it
78
+ const server = await devServer(options).catch((error: unknown) => {
79
+ console.error(error instanceof Error ? error.message : error)
80
+ process.exit(1)
81
+ })
50
82
 
51
83
  console.log(`jq79 dev → ${server.url}`)
52
84
 
package/dev/dev.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http"
2
2
  import { readFile, realpath, stat } from "node:fs/promises"
3
3
  import { watch, type FSWatcher } from "node:fs"
4
- import { basename, extname, join, relative, resolve, sep } from "node:path"
4
+ import { basename, dirname, extname, isAbsolute, join, matchesGlob, relative, resolve, sep } from "node:path"
5
5
 
6
6
  // A dev server for the no-bundle path: serve a directory of .html components
7
7
  // over HTTP, watch it, and hot-reload the components that changed.
@@ -29,6 +29,24 @@ export interface DevServerOptions {
29
29
  port?: number
30
30
  // default: localhost
31
31
  host?: string
32
+ // response headers to send on everything - a static host is configured, and
33
+ // these are that configuration (default: none)
34
+ headers?: Record<string, string>
35
+ // what to watch on top of rootDir, and what to do about it (default: none)
36
+ watch?: WatchEntry[]
37
+ }
38
+
39
+ // the handler gets every file of the burst that woke it, absolute, so a save
40
+ // that touches three of them is one build rather than three
41
+ export type WatchHandler = (files: string[]) => void | Promise<void>
42
+
43
+ export interface WatchEntry {
44
+ // glob(s) resolved against the cwd, like rootDir: "styles/**/*.scss". A bare
45
+ // directory means everything under it
46
+ pattern: string | string[]
47
+ // what a match runs. Without one, a match outside the served root reloads the
48
+ // page - which is the only other thing a file served from nowhere can do
49
+ fn?: WatchHandler
32
50
  }
33
51
 
34
52
  export interface DevServer {
@@ -106,6 +124,13 @@ export const devServer = async (options: DevServerOptions = {}): Promise<DevServ
106
124
  const root = await realpath(resolve(options.rootDir ?? "."))
107
125
  const host = options.host ?? "localhost"
108
126
 
127
+ // lowercased once: header names are case-insensitive and an object is not, so
128
+ // a "Cache-Control" in the options would otherwise go out *beside* the
129
+ // server's own cache-control rather than replacing it
130
+ const headers: Record<string, string> = Object.fromEntries(
131
+ Object.entries(options.headers ?? {}).map(([name, value]) => [name.toLowerCase(), value]),
132
+ )
133
+
109
134
  const clients = new Set<ServerResponse>()
110
135
 
111
136
  const send = (event: string, data: unknown) => {
@@ -115,6 +140,18 @@ export const devServer = async (options: DevServerOptions = {}): Promise<DevServ
115
140
 
116
141
  // --- serving ---------------------------------------------------------------
117
142
 
143
+ // every response carries the configured headers - a page, a component, the
144
+ // client, the event stream, a 404. All but two: content-type and
145
+ // content-length describe the bytes of the response they are on (a wrong
146
+ // length truncates the body or hangs the socket), so those stay the server's
147
+ const head = (res: ServerResponse, status: number, own: Record<string, string | number> = {}) => {
148
+ const merged: Record<string, string | number> = { ...own, ...headers }
149
+ for (const name of ["content-type", "content-length"]) {
150
+ if (own[name] !== undefined) merged[name] = own[name]
151
+ }
152
+ return res.writeHead(status, merged)
153
+ }
154
+
118
155
  const serveStatic = async (req: IncomingMessage, res: ServerResponse, pathname: string) => {
119
156
  // a URL path is not a file path: decode it, then keep the result inside the
120
157
  // root (".." in a request must not walk out of the served directory)
@@ -122,11 +159,11 @@ export const devServer = async (options: DevServerOptions = {}): Promise<DevServ
122
159
  try {
123
160
  file = resolve(join(root, decodeURIComponent(pathname)))
124
161
  } catch {
125
- res.writeHead(400).end("bad request")
162
+ head(res, 400).end("bad request")
126
163
  return
127
164
  }
128
165
  if (file !== root && !file.startsWith(root + sep)) {
129
- res.writeHead(403).end("forbidden")
166
+ head(res, 403).end("forbidden")
130
167
  return
131
168
  }
132
169
 
@@ -136,7 +173,7 @@ export const devServer = async (options: DevServerOptions = {}): Promise<DevServ
136
173
  // static host: /docs rendered as-is would resolve its relative links
137
174
  // against the parent ("img.png" -> /img.png instead of /docs/img.png)
138
175
  if (!pathname.endsWith("/")) {
139
- res.writeHead(301, { location: pathname + "/" }).end()
176
+ head(res, 301, { location: pathname + "/" }).end()
140
177
  return
141
178
  }
142
179
  file = join(file, "index.html")
@@ -150,14 +187,14 @@ export const devServer = async (options: DevServerOptions = {}): Promise<DevServ
150
187
  const html = type.startsWith("text/html") && isDocument(req)
151
188
  const payload = html ? Buffer.from(injectClient(body.toString("utf8"))) : body
152
189
 
153
- res.writeHead(200, {
190
+ head(res, 200, {
154
191
  "content-type": type,
155
192
  "content-length": payload.byteLength,
156
193
  "cache-control": "no-store", // the file on disk is always the truth here
157
194
  })
158
195
  res.end(payload)
159
196
  } catch {
160
- res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("not found")
197
+ head(res, 404, { "content-type": "text/plain; charset=utf-8" }).end("not found")
161
198
  }
162
199
  }
163
200
 
@@ -165,13 +202,13 @@ export const devServer = async (options: DevServerOptions = {}): Promise<DevServ
165
202
  const pathname = (req.url ?? "/").split(/[?#]/)[0]
166
203
 
167
204
  if (pathname === CLIENT_URL) {
168
- res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" })
205
+ head(res, 200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" })
169
206
  res.end(CLIENT)
170
207
  return
171
208
  }
172
209
 
173
210
  if (pathname === EVENTS_URL) {
174
- res.writeHead(200, {
211
+ head(res, 200, {
175
212
  "content-type": "text/event-stream",
176
213
  "cache-control": "no-store",
177
214
  connection: "keep-alive",
@@ -194,8 +231,63 @@ export const devServer = async (options: DevServerOptions = {}): Promise<DevServ
194
231
  // atomic write); collapsing per file keeps that down to one push
195
232
  const pending = new Map<string, NodeJS.Timeout>()
196
233
 
197
- const changed = async (rel: string) => {
198
- const file = join(root, rel)
234
+ // a pattern is written against the cwd (`resolve` reads it the same way), and
235
+ // captured here so a later chdir can't move what the globs mean
236
+ const cwd = process.cwd()
237
+
238
+ type Entry = {
239
+ patterns: string[]
240
+ fn?: WatchHandler
241
+ batch: Set<string>
242
+ timer?: NodeJS.Timeout
243
+ running: boolean
244
+ }
245
+
246
+ const entries: Entry[] = (options.watch ?? []).map(entry => ({
247
+ patterns: [entry.pattern].flat(),
248
+ fn: entry.fn,
249
+ batch: new Set(),
250
+ running: false,
251
+ }))
252
+
253
+ const claims = (entry: Entry, file: string) =>
254
+ entry.patterns.some(pattern =>
255
+ matchesGlob(isAbsolute(pattern) ? posix(file) : posix(relative(cwd, file)), pattern),
256
+ )
257
+
258
+ // a burst is one call: the handler is a build step, and building once per file
259
+ // of a save that touched four is three builds nobody asked for
260
+ const collect = (entry: Entry, file: string) => {
261
+ entry.batch.add(file)
262
+ clearTimeout(entry.timer)
263
+ entry.timer = setTimeout(() => void fire(entry), 30)
264
+ }
265
+
266
+ const fire = async (entry: Entry) => {
267
+ if (entry.running || !entry.batch.size) return
268
+ const files = [...entry.batch]
269
+ entry.batch.clear()
270
+ entry.running = true
271
+ try {
272
+ await entry.fn?.(files)
273
+ } catch (error) {
274
+ // a handler is someone's build script, and a build that fails is a normal
275
+ // morning. Reporting it and staying up beats taking the server with it
276
+ console.error(`jq79 dev: watch handler for ${entry.patterns.join(", ")} failed\n`, error)
277
+ } finally {
278
+ entry.running = false
279
+ if (entry.batch.size) void fire(entry) // saved again while it ran
280
+ }
281
+ }
282
+
283
+ // `dir` is the directory whose watcher reported this - only needed for the
284
+ // macOS quirk below, and only when the file turns out not to exist
285
+ const changed = async (file: string, dir: string) => {
286
+ // where the browser knows the file from, which only exists for a file under
287
+ // the served root: a watched path outside it is served from nowhere, so
288
+ // there is no url for the runtime to match an instance against
289
+ const rel = relative(root, file)
290
+ const served = !!rel && !rel.startsWith("..")
199
291
 
200
292
  let src: string | null = null
201
293
  try {
@@ -203,33 +295,101 @@ export const devServer = async (options: DevServerOptions = {}): Promise<DevServ
203
295
  // the file itself is already on its way - acting on both would reload the
204
296
  // page every time a component is saved
205
297
  if ((await stat(file)).isDirectory()) return
206
- if (rel.endsWith(".html")) src = await readFile(file, "utf8")
298
+ if (served && file.endsWith(".html")) src = await readFile(file, "utf8")
207
299
  } catch {
208
300
  // gone: deleted, or renamed away - and there is nothing to swap in, so the
209
301
  // page has to reload. Unless it was never there: macOS reports a change to
210
- // the watched directory *itself* under its own basename, which resolves to
211
- // a path inside it that does not exist
212
- if (rel === basename(root)) return
302
+ // a watched directory *itself* under its own basename, which resolves to a
303
+ // path inside it that does not exist
304
+ if (file === join(dir, basename(dir))) return
213
305
  }
214
306
 
215
- // the url is the one the component was served from, because that is what the
216
- // runtime resolves its instances' filenames against
217
- const url = "/" + posix(rel)
218
- if (src === null) send("reload", { url })
219
- else send("update", { url, src })
220
- }
307
+ // every entry that names this file gets it, wherever the file lives. What
308
+ // the root does with its own files is not up for negotiation: no pattern can
309
+ // switch hot reload off, so a handler can never cost you the thing you came
310
+ // for by matching more than its author meant it to
311
+ const claimed = entries.filter(entry => claims(entry, file))
312
+ claimed.forEach(entry => entry.fn && collect(entry, file))
313
+
314
+ if (served) {
315
+ // the url is the one the component was served from, because that is what
316
+ // the runtime resolves its instances' filenames against
317
+ const url = "/" + posix(rel)
318
+ if (src === null) send("reload", { url })
319
+ else send("update", { url, src })
320
+ return
321
+ }
221
322
 
222
- const watcher: FSWatcher = watch(root, { recursive: true }, (_event, filename) => {
223
- if (!filename) return
224
- const rel = relative(root, resolve(root, filename.toString()))
225
- if (!rel || rel.startsWith("..") || ignored(rel)) return
323
+ // outside the root a handler *is* the answer: it ran, and whatever it writes
324
+ // into the served directory comes back round as a change of its own, with a
325
+ // url. An entry with no handler is asking for the page, and gets a path
326
+ // relative to the root - the client only logs it, and an absolute one would
327
+ // publish the machine's layout to the page
328
+ if (claimed.some(entry => !entry.fn)) send("reload", { url: posix(rel) })
329
+ }
226
330
 
227
- clearTimeout(pending.get(rel))
228
- pending.set(rel, setTimeout(() => {
229
- pending.delete(rel)
230
- void changed(rel)
331
+ const queue = (file: string, dir: string) => {
332
+ clearTimeout(pending.get(file))
333
+ pending.set(file, setTimeout(() => {
334
+ pending.delete(file)
335
+ void changed(file, dir)
231
336
  }, 30))
232
- })
337
+ }
338
+
339
+ const watchers: FSWatcher[] = []
340
+
341
+ // a directory is watched whole. A single file is watched through the directory
342
+ // it sits in (`only` filtering the rest back out), because a watcher on a file
343
+ // holds its inode, and an editor's atomic save renames a new one over it - the
344
+ // watcher survives as a handle on a file nothing will ever write to again
345
+ const watchTree = (dir: string, only?: string) => {
346
+ watchers.push(watch(dir, { recursive: !only }, (_event, filename) => {
347
+ if (!filename) return
348
+ const file = resolve(dir, filename.toString())
349
+ const rel = relative(dir, file)
350
+ if (!rel || rel.startsWith("..")) return
351
+ if (only ? rel !== only : ignored(rel)) return
352
+ queue(file, dir)
353
+ }))
354
+ }
355
+
356
+ // a glob is a filter and a watcher needs a directory to open, so the watch
357
+ // starts at the literal head of the pattern - everything before the first
358
+ // magic character. "styles/**/*.scss" opens styles/, "**/*.scss" opens the cwd
359
+ const literalHead = (pattern: string) => {
360
+ const parts = pattern.split("/")
361
+ const magic = parts.findIndex(part => /[*?[\]{}!]/.test(part))
362
+ return resolve(magic === -1 ? pattern : parts.slice(0, magic).join("/") || ".")
363
+ }
364
+
365
+ // the patterns are resolved like rootDir, against the cwd, and all checked
366
+ // before anything is watched: a path that isn't there is a typo, and a watcher
367
+ // that silently isn't running is worse than a server that won't start
368
+ const extra = new Map<string, { dir: string; only?: string }>()
369
+ for (const entry of entries) {
370
+ for (const [i, pattern] of entry.patterns.entries()) {
371
+ const head = literalHead(pattern)
372
+ const info = await stat(head).catch(() => null)
373
+ if (!info) throw new Error(`jq79 dev: nothing to watch at ${head}`)
374
+
375
+ // a bare directory means everything under it, which is what it looks like
376
+ // it means - as a pattern it would watch the directory and match nothing
377
+ // in it, and the mistake is invisible until a save doesn't fire
378
+ if (info.isDirectory() && head === resolve(pattern)) {
379
+ entry.patterns[i] = pattern.replace(/\/+$/, "") + "/**"
380
+ }
381
+
382
+ // real paths on both sides, so a symlink pointing out of the root reads as
383
+ // what it is - outside, and not covered by the recursive watch below
384
+ const real = await realpath(head)
385
+ if (real === root || real.startsWith(root + sep)) continue // already watched
386
+ const watched = info.isDirectory() ? { dir: real } : { dir: dirname(real), only: basename(real) }
387
+ extra.set(`${watched.dir}\0${watched.only ?? ""}`, watched) // two patterns can share a head
388
+ }
389
+ }
390
+
391
+ watchTree(root)
392
+ extra.forEach(({ dir, only }) => watchTree(dir, only))
233
393
 
234
394
  // proxies and load balancers cut an idle stream; a comment every 30s is the
235
395
  // conventional way to keep it open. unref'd, so it never holds the process up
@@ -250,8 +410,9 @@ export const devServer = async (options: DevServerOptions = {}): Promise<DevServ
250
410
  close: () =>
251
411
  new Promise(done => {
252
412
  clearInterval(heartbeat)
253
- watcher.close()
413
+ watchers.forEach(watcher => watcher.close())
254
414
  pending.forEach(clearTimeout)
415
+ entries.forEach(entry => clearTimeout(entry.timer))
255
416
  clients.forEach(client => client.end())
256
417
  clients.clear()
257
418
  server.close(() => done())
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { createServer } from "http";
5
5
  import { readFile, realpath, stat } from "fs/promises";
6
6
  import { watch } from "fs";
7
- import { basename, extname, join, relative, resolve, sep } from "path";
7
+ import { basename, dirname, extname, isAbsolute, join, matchesGlob, relative, resolve, sep } from "path";
8
8
  var CONTENT_TYPES = {
9
9
  ".html": "text/html; charset=utf-8",
10
10
  ".js": "text/javascript; charset=utf-8",
@@ -54,6 +54,9 @@ var injectClient = (html) => {
54
54
  var devServer = async (options2 = {}) => {
55
55
  const root = await realpath(resolve(options2.rootDir ?? "."));
56
56
  const host = options2.host ?? "localhost";
57
+ const headers = Object.fromEntries(
58
+ Object.entries(options2.headers ?? {}).map(([name, value]) => [name.toLowerCase(), value])
59
+ );
57
60
  const clients = /* @__PURE__ */ new Set();
58
61
  const send = (event, data) => {
59
62
  const frame = `event: ${event}
@@ -62,22 +65,29 @@ data: ${JSON.stringify(data)}
62
65
  `;
63
66
  clients.forEach((client) => client.write(frame));
64
67
  };
68
+ const head = (res, status, own = {}) => {
69
+ const merged = { ...own, ...headers };
70
+ for (const name of ["content-type", "content-length"]) {
71
+ if (own[name] !== void 0) merged[name] = own[name];
72
+ }
73
+ return res.writeHead(status, merged);
74
+ };
65
75
  const serveStatic = async (req, res, pathname) => {
66
76
  let file;
67
77
  try {
68
78
  file = resolve(join(root, decodeURIComponent(pathname)));
69
79
  } catch {
70
- res.writeHead(400).end("bad request");
80
+ head(res, 400).end("bad request");
71
81
  return;
72
82
  }
73
83
  if (file !== root && !file.startsWith(root + sep)) {
74
- res.writeHead(403).end("forbidden");
84
+ head(res, 403).end("forbidden");
75
85
  return;
76
86
  }
77
87
  try {
78
88
  if ((await stat(file)).isDirectory()) {
79
89
  if (!pathname.endsWith("/")) {
80
- res.writeHead(301, { location: pathname + "/" }).end();
90
+ head(res, 301, { location: pathname + "/" }).end();
81
91
  return;
82
92
  }
83
93
  file = join(file, "index.html");
@@ -86,7 +96,7 @@ data: ${JSON.stringify(data)}
86
96
  const type = CONTENT_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream";
87
97
  const html = type.startsWith("text/html") && isDocument(req);
88
98
  const payload = html ? Buffer.from(injectClient(body.toString("utf8"))) : body;
89
- res.writeHead(200, {
99
+ head(res, 200, {
90
100
  "content-type": type,
91
101
  "content-length": payload.byteLength,
92
102
  "cache-control": "no-store"
@@ -94,18 +104,18 @@ data: ${JSON.stringify(data)}
94
104
  });
95
105
  res.end(payload);
96
106
  } catch {
97
- res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("not found");
107
+ head(res, 404, { "content-type": "text/plain; charset=utf-8" }).end("not found");
98
108
  }
99
109
  };
100
110
  const server2 = createServer((req, res) => {
101
111
  const pathname = (req.url ?? "/").split(/[?#]/)[0];
102
112
  if (pathname === CLIENT_URL) {
103
- res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" });
113
+ head(res, 200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" });
104
114
  res.end(CLIENT);
105
115
  return;
106
116
  }
107
117
  if (pathname === EVENTS_URL) {
108
- res.writeHead(200, {
118
+ head(res, 200, {
109
119
  "content-type": "text/event-stream",
110
120
  "cache-control": "no-store",
111
121
  connection: "keep-alive"
@@ -119,29 +129,96 @@ data: ${JSON.stringify(data)}
119
129
  });
120
130
  const ignored = (rel) => rel.split(sep).some((part) => part.startsWith(".") || part === "node_modules");
121
131
  const pending = /* @__PURE__ */ new Map();
122
- const changed = async (rel) => {
123
- const file = join(root, rel);
132
+ const cwd = process.cwd();
133
+ const entries = (options2.watch ?? []).map((entry) => ({
134
+ patterns: [entry.pattern].flat(),
135
+ fn: entry.fn,
136
+ batch: /* @__PURE__ */ new Set(),
137
+ running: false
138
+ }));
139
+ const claims = (entry, file) => entry.patterns.some(
140
+ (pattern) => matchesGlob(isAbsolute(pattern) ? posix(file) : posix(relative(cwd, file)), pattern)
141
+ );
142
+ const collect = (entry, file) => {
143
+ entry.batch.add(file);
144
+ clearTimeout(entry.timer);
145
+ entry.timer = setTimeout(() => void fire(entry), 30);
146
+ };
147
+ const fire = async (entry) => {
148
+ if (entry.running || !entry.batch.size) return;
149
+ const files = [...entry.batch];
150
+ entry.batch.clear();
151
+ entry.running = true;
152
+ try {
153
+ await entry.fn?.(files);
154
+ } catch (error) {
155
+ console.error(`jq79 dev: watch handler for ${entry.patterns.join(", ")} failed
156
+ `, error);
157
+ } finally {
158
+ entry.running = false;
159
+ if (entry.batch.size) void fire(entry);
160
+ }
161
+ };
162
+ const changed = async (file, dir) => {
163
+ const rel = relative(root, file);
164
+ const served = !!rel && !rel.startsWith("..");
124
165
  let src = null;
125
166
  try {
126
167
  if ((await stat(file)).isDirectory()) return;
127
- if (rel.endsWith(".html")) src = await readFile(file, "utf8");
168
+ if (served && file.endsWith(".html")) src = await readFile(file, "utf8");
128
169
  } catch {
129
- if (rel === basename(root)) return;
170
+ if (file === join(dir, basename(dir))) return;
171
+ }
172
+ const claimed = entries.filter((entry) => claims(entry, file));
173
+ claimed.forEach((entry) => entry.fn && collect(entry, file));
174
+ if (served) {
175
+ const url = "/" + posix(rel);
176
+ if (src === null) send("reload", { url });
177
+ else send("update", { url, src });
178
+ return;
130
179
  }
131
- const url = "/" + posix(rel);
132
- if (src === null) send("reload", { url });
133
- else send("update", { url, src });
180
+ if (claimed.some((entry) => !entry.fn)) send("reload", { url: posix(rel) });
134
181
  };
135
- const watcher = watch(root, { recursive: true }, (_event, filename) => {
136
- if (!filename) return;
137
- const rel = relative(root, resolve(root, filename.toString()));
138
- if (!rel || rel.startsWith("..") || ignored(rel)) return;
139
- clearTimeout(pending.get(rel));
140
- pending.set(rel, setTimeout(() => {
141
- pending.delete(rel);
142
- void changed(rel);
182
+ const queue = (file, dir) => {
183
+ clearTimeout(pending.get(file));
184
+ pending.set(file, setTimeout(() => {
185
+ pending.delete(file);
186
+ void changed(file, dir);
143
187
  }, 30));
144
- });
188
+ };
189
+ const watchers = [];
190
+ const watchTree = (dir, only) => {
191
+ watchers.push(watch(dir, { recursive: !only }, (_event, filename) => {
192
+ if (!filename) return;
193
+ const file = resolve(dir, filename.toString());
194
+ const rel = relative(dir, file);
195
+ if (!rel || rel.startsWith("..")) return;
196
+ if (only ? rel !== only : ignored(rel)) return;
197
+ queue(file, dir);
198
+ }));
199
+ };
200
+ const literalHead = (pattern) => {
201
+ const parts = pattern.split("/");
202
+ const magic = parts.findIndex((part) => /[*?[\]{}!]/.test(part));
203
+ return resolve(magic === -1 ? pattern : parts.slice(0, magic).join("/") || ".");
204
+ };
205
+ const extra = /* @__PURE__ */ new Map();
206
+ for (const entry of entries) {
207
+ for (const [i, pattern] of entry.patterns.entries()) {
208
+ const head2 = literalHead(pattern);
209
+ const info = await stat(head2).catch(() => null);
210
+ if (!info) throw new Error(`jq79 dev: nothing to watch at ${head2}`);
211
+ if (info.isDirectory() && head2 === resolve(pattern)) {
212
+ entry.patterns[i] = pattern.replace(/\/+$/, "") + "/**";
213
+ }
214
+ const real = await realpath(head2);
215
+ if (real === root || real.startsWith(root + sep)) continue;
216
+ const watched = info.isDirectory() ? { dir: real } : { dir: dirname(real), only: basename(real) };
217
+ extra.set(`${watched.dir}\0${watched.only ?? ""}`, watched);
218
+ }
219
+ }
220
+ watchTree(root);
221
+ extra.forEach(({ dir, only }) => watchTree(dir, only));
145
222
  const heartbeat = setInterval(() => clients.forEach((client) => client.write(": ping\n\n")), 3e4);
146
223
  heartbeat.unref();
147
224
  await new Promise((done, fail) => {
@@ -154,8 +231,9 @@ data: ${JSON.stringify(data)}
154
231
  port,
155
232
  close: () => new Promise((done) => {
156
233
  clearInterval(heartbeat);
157
- watcher.close();
234
+ watchers.forEach((watcher) => watcher.close());
158
235
  pending.forEach(clearTimeout);
236
+ entries.forEach((entry) => clearTimeout(entry.timer));
159
237
  clients.forEach((client) => client.end());
160
238
  clients.clear();
161
239
  server2.close(() => done());
@@ -172,6 +250,9 @@ usage:
172
250
  options:
173
251
  -p, --port <port> port to listen on (default: 4179)
174
252
  -H, --host <host> host to bind (default: localhost)
253
+ -w, --watch <glob> also watch this path or glob, reloading on a change
254
+ (repeatable; handlers need the js api)
255
+ --header <h> response header, "name: value" (repeatable)
175
256
  -h, --help show this message
176
257
  `;
177
258
  var args = process.argv.slice(2);
@@ -188,7 +269,23 @@ for (let i = 0; i < rest.length; i++) {
188
269
  const arg = rest[i];
189
270
  if (arg === "-p" || arg === "--port") options.port = Number(rest[++i]);
190
271
  else if (arg === "-H" || arg === "--host") options.host = rest[++i];
191
- else if (!arg.startsWith("-")) options.rootDir ??= arg;
272
+ else if (arg === "-w" || arg === "--watch") {
273
+ const pattern = rest[++i];
274
+ if (!pattern) {
275
+ console.error("--watch takes a path or glob");
276
+ process.exit(1);
277
+ }
278
+ ;
279
+ (options.watch ??= []).push({ pattern });
280
+ } else if (arg === "--header") {
281
+ const [name, ...value] = (rest[++i] ?? "").split(":");
282
+ if (!name || !value.length) {
283
+ console.error(`--header takes "name: value"`);
284
+ process.exit(1);
285
+ }
286
+ ;
287
+ (options.headers ??= {})[name.trim()] = value.join(":").trim();
288
+ } else if (!arg.startsWith("-")) options.rootDir ??= arg;
192
289
  else {
193
290
  console.error(`unknown option: ${arg}
194
291
  ${USAGE}`);
@@ -199,7 +296,10 @@ if (options.port !== void 0 && !Number.isInteger(options.port)) {
199
296
  console.error("--port takes a number");
200
297
  process.exit(1);
201
298
  }
202
- var server = await devServer(options);
299
+ var server = await devServer(options).catch((error) => {
300
+ console.error(error instanceof Error ? error.message : error);
301
+ process.exit(1);
302
+ });
203
303
  console.log(`jq79 dev \u2192 ${server.url}`);
204
304
  var stop = () => {
205
305
  void server.close().then(() => process.exit(0));