jq79 0.5.8 → 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 +37 -5
- package/dev/dev.ts +191 -30
- package/dist/cli.js +127 -27
- package/dist/dev.cjs +102 -24
- package/dist/dev.cjs.map +1 -1
- package/dist/dev.d.ts +7 -0
- package/dist/dev.js +103 -25
- package/dist/dev.js.map +1 -1
- package/dist/jq79.cjs +13 -13
- package/dist/jq79.cjs.map +1 -1
- package/dist/jq79.d.ts +1 -0
- package/dist/jq79.global.js +13 -13
- package/dist/jq79.global.js.map +1 -1
- package/dist/jq79.js +13 -13
- package/dist/jq79.js.map +1 -1
- package/package.json +1 -1
- package/src/jq79.ts +150 -17
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: {
|
|
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 (
|
|
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
|
-
|
|
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
|
|
162
|
+
head(res, 400).end("bad request")
|
|
126
163
|
return
|
|
127
164
|
}
|
|
128
165
|
if (file !== root && !file.startsWith(root + sep)) {
|
|
129
|
-
res
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
198
|
-
|
|
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 (
|
|
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
|
-
//
|
|
211
|
-
//
|
|
212
|
-
if (
|
|
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
|
-
//
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
228
|
-
pending.
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
80
|
+
head(res, 400).end("bad request");
|
|
71
81
|
return;
|
|
72
82
|
}
|
|
73
83
|
if (file !== root && !file.startsWith(root + sep)) {
|
|
74
|
-
res
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
123
|
-
|
|
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 (
|
|
168
|
+
if (served && file.endsWith(".html")) src = await readFile(file, "utf8");
|
|
128
169
|
} catch {
|
|
129
|
-
if (
|
|
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
|
-
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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 (
|
|
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));
|