jq79 0.5.9 → 0.5.11

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,32 @@ 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
+ // the same thing per request: a hook gets the request and its response before
36
+ // anything has been written to it (default: none)
37
+ beforeResponse?: ResponseHook[]
38
+ // what to watch on top of rootDir, and what to do about it (default: none)
39
+ watch?: WatchEntry[]
40
+ }
41
+
42
+ // runs on every response the server makes - a page, a component, the client,
43
+ // the event stream, a 404 - with nothing written yet, so `res.setHeader` still
44
+ // applies. A hook that answers the request itself (`res.end`) is left alone
45
+ export type ResponseHook = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
46
+
47
+ // the handler gets every file of the burst that woke it, absolute, so a save
48
+ // that touches three of them is one build rather than three
49
+ export type WatchHandler = (files: string[]) => void | Promise<void>
50
+
51
+ export interface WatchEntry {
52
+ // glob(s) resolved against the cwd, like rootDir: "styles/**/*.scss". A bare
53
+ // directory means everything under it
54
+ pattern: string | string[]
55
+ // what a match runs. Without one, a match outside the served root reloads the
56
+ // page - which is the only other thing a file served from nowhere can do
57
+ fn?: WatchHandler
32
58
  }
33
59
 
34
60
  export interface DevServer {
@@ -41,6 +67,10 @@ const CONTENT_TYPES: Record<string, string> = {
41
67
  ".html": "text/html; charset=utf-8",
42
68
  ".js": "text/javascript; charset=utf-8",
43
69
  ".mjs": "text/javascript; charset=utf-8",
70
+ // the streaming WebAssembly APIs reject every other type, including the
71
+ // default below - a module served as octet-stream still runs, but it is
72
+ // buffered whole instead of compiling as it downloads
73
+ ".wasm": "application/wasm",
44
74
  ".json": "application/json; charset=utf-8",
45
75
  ".css": "text/css; charset=utf-8",
46
76
  ".svg": "image/svg+xml",
@@ -106,6 +136,16 @@ export const devServer = async (options: DevServerOptions = {}): Promise<DevServ
106
136
  const root = await realpath(resolve(options.rootDir ?? "."))
107
137
  const host = options.host ?? "localhost"
108
138
 
139
+ // a configured header goes on every response, which is the one thing
140
+ // content-type and content-length cannot do - each describes the bytes of a
141
+ // single response. Dropped here rather than at write time, so that either of
142
+ // them still standing by then can only have come from a hook, which saw the
143
+ // request and is entitled to say
144
+ const headers = Object.entries(options.headers ?? {}).filter(
145
+ ([name]) => !["content-type", "content-length"].includes(name.toLowerCase()),
146
+ )
147
+ const hooks = options.beforeResponse ?? []
148
+
109
149
  const clients = new Set<ServerResponse>()
110
150
 
111
151
  const send = (event: string, data: unknown) => {
@@ -115,6 +155,18 @@ export const devServer = async (options: DevServerOptions = {}): Promise<DevServ
115
155
 
116
156
  // --- serving ---------------------------------------------------------------
117
157
 
158
+ // what the response says about its own bytes, written last so the layers
159
+ // below can't contradict it. A hook is the one exception: it saw the request,
160
+ // so it is entitled to an opinion about what these bytes *are* - never about
161
+ // how many there are, since a wrong content-length truncates the body or
162
+ // hangs the socket
163
+ const head = (res: ServerResponse, status: number, own: Record<string, string | number> = {}) => {
164
+ res.removeHeader("content-length")
165
+ const write = { ...own }
166
+ if (res.hasHeader("content-type")) delete write["content-type"]
167
+ return res.writeHead(status, write)
168
+ }
169
+
118
170
  const serveStatic = async (req: IncomingMessage, res: ServerResponse, pathname: string) => {
119
171
  // a URL path is not a file path: decode it, then keep the result inside the
120
172
  // root (".." in a request must not walk out of the served directory)
@@ -122,11 +174,11 @@ export const devServer = async (options: DevServerOptions = {}): Promise<DevServ
122
174
  try {
123
175
  file = resolve(join(root, decodeURIComponent(pathname)))
124
176
  } catch {
125
- res.writeHead(400).end("bad request")
177
+ head(res, 400).end("bad request")
126
178
  return
127
179
  }
128
180
  if (file !== root && !file.startsWith(root + sep)) {
129
- res.writeHead(403).end("forbidden")
181
+ head(res, 403).end("forbidden")
130
182
  return
131
183
  }
132
184
 
@@ -136,7 +188,7 @@ export const devServer = async (options: DevServerOptions = {}): Promise<DevServ
136
188
  // static host: /docs rendered as-is would resolve its relative links
137
189
  // against the parent ("img.png" -> /img.png instead of /docs/img.png)
138
190
  if (!pathname.endsWith("/")) {
139
- res.writeHead(301, { location: pathname + "/" }).end()
191
+ head(res, 301, { location: pathname + "/" }).end()
140
192
  return
141
193
  }
142
194
  file = join(file, "index.html")
@@ -150,39 +202,60 @@ export const devServer = async (options: DevServerOptions = {}): Promise<DevServ
150
202
  const html = type.startsWith("text/html") && isDocument(req)
151
203
  const payload = html ? Buffer.from(injectClient(body.toString("utf8"))) : body
152
204
 
153
- res.writeHead(200, {
154
- "content-type": type,
155
- "content-length": payload.byteLength,
156
- "cache-control": "no-store", // the file on disk is always the truth here
157
- })
205
+ head(res, 200, { "content-type": type, "content-length": payload.byteLength })
158
206
  res.end(payload)
159
207
  } catch {
160
- res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("not found")
208
+ head(res, 404, { "content-type": "text/plain; charset=utf-8" }).end("not found")
161
209
  }
162
210
  }
163
211
 
164
- const server: Server = createServer((req, res) => {
212
+ // three layers, general to specific, each one free to replace the last: the
213
+ // server's own defaults, the configured headers over them, and a hook - which
214
+ // saw the request - over those. They go on with setHeader rather than into an
215
+ // object, because setHeader is case-insensitive where an object is not: a
216
+ // "Cache-Control" replaces the cache-control below it instead of arriving
217
+ // beside it, and the same goes for whatever a hook names
218
+ const handle = async (req: IncomingMessage, res: ServerResponse) => {
219
+ res.setHeader("cache-control", "no-store") // the file on disk is always the truth here
220
+ for (const [name, value] of headers) res.setHeader(name, value)
221
+ for (const hook of hooks) await hook(req, res)
222
+
223
+ // a hook can answer the request itself, and one that did needs nothing else
224
+ if (res.headersSent || res.writableEnded) return
225
+
165
226
  const pathname = (req.url ?? "/").split(/[?#]/)[0]
166
227
 
167
228
  if (pathname === CLIENT_URL) {
168
- res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" })
229
+ head(res, 200, { "content-type": "text/javascript; charset=utf-8" })
169
230
  res.end(CLIENT)
170
231
  return
171
232
  }
172
233
 
173
234
  if (pathname === EVENTS_URL) {
174
- res.writeHead(200, {
175
- "content-type": "text/event-stream",
176
- "cache-control": "no-store",
177
- connection: "keep-alive",
178
- })
235
+ head(res, 200, { "content-type": "text/event-stream", connection: "keep-alive" })
179
236
  res.write(": jq79\n\n") // opens the stream, so the browser fires onopen
180
237
  clients.add(res)
181
238
  req.on("close", () => clients.delete(res))
182
239
  return
183
240
  }
184
241
 
185
- void serveStatic(req, res, pathname)
242
+ await serveStatic(req, res, pathname)
243
+ }
244
+
245
+ const server: Server = createServer((req, res) => {
246
+ // a hook is someone's code, and the watch handlers already settled what that
247
+ // means around here: report it and stay up. This one owes the browser a
248
+ // reply as well, because a socket nobody answers hangs the page
249
+ void handle(req, res).catch(error => {
250
+ console.error(`jq79 dev: failed to respond to ${req.url}\n`, error)
251
+ if (!res.headersSent) {
252
+ // whatever the hook had said about the bytes, it is not what is being
253
+ // sent now - and a content-length it set before throwing would hang this
254
+ res.removeHeader("content-length")
255
+ res.writeHead(500, { "content-type": "text/plain; charset=utf-8" })
256
+ }
257
+ if (!res.writableEnded) res.end("internal error")
258
+ })
186
259
  })
187
260
 
188
261
  // --- watching --------------------------------------------------------------
@@ -194,8 +267,63 @@ export const devServer = async (options: DevServerOptions = {}): Promise<DevServ
194
267
  // atomic write); collapsing per file keeps that down to one push
195
268
  const pending = new Map<string, NodeJS.Timeout>()
196
269
 
197
- const changed = async (rel: string) => {
198
- const file = join(root, rel)
270
+ // a pattern is written against the cwd (`resolve` reads it the same way), and
271
+ // captured here so a later chdir can't move what the globs mean
272
+ const cwd = process.cwd()
273
+
274
+ type Entry = {
275
+ patterns: string[]
276
+ fn?: WatchHandler
277
+ batch: Set<string>
278
+ timer?: NodeJS.Timeout
279
+ running: boolean
280
+ }
281
+
282
+ const entries: Entry[] = (options.watch ?? []).map(entry => ({
283
+ patterns: [entry.pattern].flat(),
284
+ fn: entry.fn,
285
+ batch: new Set(),
286
+ running: false,
287
+ }))
288
+
289
+ const claims = (entry: Entry, file: string) =>
290
+ entry.patterns.some(pattern =>
291
+ matchesGlob(isAbsolute(pattern) ? posix(file) : posix(relative(cwd, file)), pattern),
292
+ )
293
+
294
+ // a burst is one call: the handler is a build step, and building once per file
295
+ // of a save that touched four is three builds nobody asked for
296
+ const collect = (entry: Entry, file: string) => {
297
+ entry.batch.add(file)
298
+ clearTimeout(entry.timer)
299
+ entry.timer = setTimeout(() => void fire(entry), 30)
300
+ }
301
+
302
+ const fire = async (entry: Entry) => {
303
+ if (entry.running || !entry.batch.size) return
304
+ const files = [...entry.batch]
305
+ entry.batch.clear()
306
+ entry.running = true
307
+ try {
308
+ await entry.fn?.(files)
309
+ } catch (error) {
310
+ // a handler is someone's build script, and a build that fails is a normal
311
+ // morning. Reporting it and staying up beats taking the server with it
312
+ console.error(`jq79 dev: watch handler for ${entry.patterns.join(", ")} failed\n`, error)
313
+ } finally {
314
+ entry.running = false
315
+ if (entry.batch.size) void fire(entry) // saved again while it ran
316
+ }
317
+ }
318
+
319
+ // `dir` is the directory whose watcher reported this - only needed for the
320
+ // macOS quirk below, and only when the file turns out not to exist
321
+ const changed = async (file: string, dir: string) => {
322
+ // where the browser knows the file from, which only exists for a file under
323
+ // the served root: a watched path outside it is served from nowhere, so
324
+ // there is no url for the runtime to match an instance against
325
+ const rel = relative(root, file)
326
+ const served = !!rel && !rel.startsWith("..")
199
327
 
200
328
  let src: string | null = null
201
329
  try {
@@ -203,33 +331,101 @@ export const devServer = async (options: DevServerOptions = {}): Promise<DevServ
203
331
  // the file itself is already on its way - acting on both would reload the
204
332
  // page every time a component is saved
205
333
  if ((await stat(file)).isDirectory()) return
206
- if (rel.endsWith(".html")) src = await readFile(file, "utf8")
334
+ if (served && file.endsWith(".html")) src = await readFile(file, "utf8")
207
335
  } catch {
208
336
  // gone: deleted, or renamed away - and there is nothing to swap in, so the
209
337
  // 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
338
+ // a watched directory *itself* under its own basename, which resolves to a
339
+ // path inside it that does not exist
340
+ if (file === join(dir, basename(dir))) return
213
341
  }
214
342
 
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
- }
343
+ // every entry that names this file gets it, wherever the file lives. What
344
+ // the root does with its own files is not up for negotiation: no pattern can
345
+ // switch hot reload off, so a handler can never cost you the thing you came
346
+ // for by matching more than its author meant it to
347
+ const claimed = entries.filter(entry => claims(entry, file))
348
+ claimed.forEach(entry => entry.fn && collect(entry, file))
349
+
350
+ if (served) {
351
+ // the url is the one the component was served from, because that is what
352
+ // the runtime resolves its instances' filenames against
353
+ const url = "/" + posix(rel)
354
+ if (src === null) send("reload", { url })
355
+ else send("update", { url, src })
356
+ return
357
+ }
221
358
 
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
359
+ // outside the root a handler *is* the answer: it ran, and whatever it writes
360
+ // into the served directory comes back round as a change of its own, with a
361
+ // url. An entry with no handler is asking for the page, and gets a path
362
+ // relative to the root - the client only logs it, and an absolute one would
363
+ // publish the machine's layout to the page
364
+ if (claimed.some(entry => !entry.fn)) send("reload", { url: posix(rel) })
365
+ }
226
366
 
227
- clearTimeout(pending.get(rel))
228
- pending.set(rel, setTimeout(() => {
229
- pending.delete(rel)
230
- void changed(rel)
367
+ const queue = (file: string, dir: string) => {
368
+ clearTimeout(pending.get(file))
369
+ pending.set(file, setTimeout(() => {
370
+ pending.delete(file)
371
+ void changed(file, dir)
231
372
  }, 30))
232
- })
373
+ }
374
+
375
+ const watchers: FSWatcher[] = []
376
+
377
+ // a directory is watched whole. A single file is watched through the directory
378
+ // it sits in (`only` filtering the rest back out), because a watcher on a file
379
+ // holds its inode, and an editor's atomic save renames a new one over it - the
380
+ // watcher survives as a handle on a file nothing will ever write to again
381
+ const watchTree = (dir: string, only?: string) => {
382
+ watchers.push(watch(dir, { recursive: !only }, (_event, filename) => {
383
+ if (!filename) return
384
+ const file = resolve(dir, filename.toString())
385
+ const rel = relative(dir, file)
386
+ if (!rel || rel.startsWith("..")) return
387
+ if (only ? rel !== only : ignored(rel)) return
388
+ queue(file, dir)
389
+ }))
390
+ }
391
+
392
+ // a glob is a filter and a watcher needs a directory to open, so the watch
393
+ // starts at the literal head of the pattern - everything before the first
394
+ // magic character. "styles/**/*.scss" opens styles/, "**/*.scss" opens the cwd
395
+ const literalHead = (pattern: string) => {
396
+ const parts = pattern.split("/")
397
+ const magic = parts.findIndex(part => /[*?[\]{}!]/.test(part))
398
+ return resolve(magic === -1 ? pattern : parts.slice(0, magic).join("/") || ".")
399
+ }
400
+
401
+ // the patterns are resolved like rootDir, against the cwd, and all checked
402
+ // before anything is watched: a path that isn't there is a typo, and a watcher
403
+ // that silently isn't running is worse than a server that won't start
404
+ const extra = new Map<string, { dir: string; only?: string }>()
405
+ for (const entry of entries) {
406
+ for (const [i, pattern] of entry.patterns.entries()) {
407
+ const head = literalHead(pattern)
408
+ const info = await stat(head).catch(() => null)
409
+ if (!info) throw new Error(`jq79 dev: nothing to watch at ${head}`)
410
+
411
+ // a bare directory means everything under it, which is what it looks like
412
+ // it means - as a pattern it would watch the directory and match nothing
413
+ // in it, and the mistake is invisible until a save doesn't fire
414
+ if (info.isDirectory() && head === resolve(pattern)) {
415
+ entry.patterns[i] = pattern.replace(/\/+$/, "") + "/**"
416
+ }
417
+
418
+ // real paths on both sides, so a symlink pointing out of the root reads as
419
+ // what it is - outside, and not covered by the recursive watch below
420
+ const real = await realpath(head)
421
+ if (real === root || real.startsWith(root + sep)) continue // already watched
422
+ const watched = info.isDirectory() ? { dir: real } : { dir: dirname(real), only: basename(real) }
423
+ extra.set(`${watched.dir}\0${watched.only ?? ""}`, watched) // two patterns can share a head
424
+ }
425
+ }
426
+
427
+ watchTree(root)
428
+ extra.forEach(({ dir, only }) => watchTree(dir, only))
233
429
 
234
430
  // proxies and load balancers cut an idle stream; a comment every 30s is the
235
431
  // conventional way to keep it open. unref'd, so it never holds the process up
@@ -250,8 +446,9 @@ export const devServer = async (options: DevServerOptions = {}): Promise<DevServ
250
446
  close: () =>
251
447
  new Promise(done => {
252
448
  clearInterval(heartbeat)
253
- watcher.close()
449
+ watchers.forEach(watcher => watcher.close())
254
450
  pending.forEach(clearTimeout)
451
+ entries.forEach(entry => clearTimeout(entry.timer))
255
452
  clients.forEach(client => client.end())
256
453
  clients.clear()
257
454
  server.close(() => done())