jq79 0.5.10 → 0.5.12

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/dev.ts CHANGED
@@ -32,10 +32,18 @@ export interface DevServerOptions {
32
32
  // response headers to send on everything - a static host is configured, and
33
33
  // these are that configuration (default: none)
34
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[]
35
38
  // what to watch on top of rootDir, and what to do about it (default: none)
36
39
  watch?: WatchEntry[]
37
40
  }
38
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
+
39
47
  // the handler gets every file of the burst that woke it, absolute, so a save
40
48
  // that touches three of them is one build rather than three
41
49
  export type WatchHandler = (files: string[]) => void | Promise<void>
@@ -59,6 +67,10 @@ const CONTENT_TYPES: Record<string, string> = {
59
67
  ".html": "text/html; charset=utf-8",
60
68
  ".js": "text/javascript; charset=utf-8",
61
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",
62
74
  ".json": "application/json; charset=utf-8",
63
75
  ".css": "text/css; charset=utf-8",
64
76
  ".svg": "image/svg+xml",
@@ -124,12 +136,15 @@ export const devServer = async (options: DevServerOptions = {}): Promise<DevServ
124
136
  const root = await realpath(resolve(options.rootDir ?? "."))
125
137
  const host = options.host ?? "localhost"
126
138
 
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]),
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()),
132
146
  )
147
+ const hooks = options.beforeResponse ?? []
133
148
 
134
149
  const clients = new Set<ServerResponse>()
135
150
 
@@ -140,16 +155,16 @@ export const devServer = async (options: DevServerOptions = {}): Promise<DevServ
140
155
 
141
156
  // --- serving ---------------------------------------------------------------
142
157
 
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
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
147
163
  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)
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)
153
168
  }
154
169
 
155
170
  const serveStatic = async (req: IncomingMessage, res: ServerResponse, pathname: string) => {
@@ -187,39 +202,60 @@ export const devServer = async (options: DevServerOptions = {}): Promise<DevServ
187
202
  const html = type.startsWith("text/html") && isDocument(req)
188
203
  const payload = html ? Buffer.from(injectClient(body.toString("utf8"))) : body
189
204
 
190
- head(res, 200, {
191
- "content-type": type,
192
- "content-length": payload.byteLength,
193
- "cache-control": "no-store", // the file on disk is always the truth here
194
- })
205
+ head(res, 200, { "content-type": type, "content-length": payload.byteLength })
195
206
  res.end(payload)
196
207
  } catch {
197
208
  head(res, 404, { "content-type": "text/plain; charset=utf-8" }).end("not found")
198
209
  }
199
210
  }
200
211
 
201
- 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
+
202
226
  const pathname = (req.url ?? "/").split(/[?#]/)[0]
203
227
 
204
228
  if (pathname === CLIENT_URL) {
205
- head(res, 200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" })
229
+ head(res, 200, { "content-type": "text/javascript; charset=utf-8" })
206
230
  res.end(CLIENT)
207
231
  return
208
232
  }
209
233
 
210
234
  if (pathname === EVENTS_URL) {
211
- head(res, 200, {
212
- "content-type": "text/event-stream",
213
- "cache-control": "no-store",
214
- connection: "keep-alive",
215
- })
235
+ head(res, 200, { "content-type": "text/event-stream", connection: "keep-alive" })
216
236
  res.write(": jq79\n\n") // opens the stream, so the browser fires onopen
217
237
  clients.add(res)
218
238
  req.on("close", () => clients.delete(res))
219
239
  return
220
240
  }
221
241
 
222
- 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
+ })
223
259
  })
224
260
 
225
261
  // --- watching --------------------------------------------------------------
package/dist/cli.js CHANGED
@@ -9,6 +9,10 @@ var CONTENT_TYPES = {
9
9
  ".html": "text/html; charset=utf-8",
10
10
  ".js": "text/javascript; charset=utf-8",
11
11
  ".mjs": "text/javascript; charset=utf-8",
12
+ // the streaming WebAssembly APIs reject every other type, including the
13
+ // default below - a module served as octet-stream still runs, but it is
14
+ // buffered whole instead of compiling as it downloads
15
+ ".wasm": "application/wasm",
12
16
  ".json": "application/json; charset=utf-8",
13
17
  ".css": "text/css; charset=utf-8",
14
18
  ".svg": "image/svg+xml",
@@ -54,9 +58,10 @@ var injectClient = (html) => {
54
58
  var devServer = async (options2 = {}) => {
55
59
  const root = await realpath(resolve(options2.rootDir ?? "."));
56
60
  const host = options2.host ?? "localhost";
57
- const headers = Object.fromEntries(
58
- Object.entries(options2.headers ?? {}).map(([name, value]) => [name.toLowerCase(), value])
61
+ const headers = Object.entries(options2.headers ?? {}).filter(
62
+ ([name]) => !["content-type", "content-length"].includes(name.toLowerCase())
59
63
  );
64
+ const hooks = options2.beforeResponse ?? [];
60
65
  const clients = /* @__PURE__ */ new Set();
61
66
  const send = (event, data) => {
62
67
  const frame = `event: ${event}
@@ -66,11 +71,10 @@ data: ${JSON.stringify(data)}
66
71
  clients.forEach((client) => client.write(frame));
67
72
  };
68
73
  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
+ res.removeHeader("content-length");
75
+ const write = { ...own };
76
+ if (res.hasHeader("content-type")) delete write["content-type"];
77
+ return res.writeHead(status, write);
74
78
  };
75
79
  const serveStatic = async (req, res, pathname) => {
76
80
  let file;
@@ -96,36 +100,42 @@ data: ${JSON.stringify(data)}
96
100
  const type = CONTENT_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream";
97
101
  const html = type.startsWith("text/html") && isDocument(req);
98
102
  const payload = html ? Buffer.from(injectClient(body.toString("utf8"))) : body;
99
- head(res, 200, {
100
- "content-type": type,
101
- "content-length": payload.byteLength,
102
- "cache-control": "no-store"
103
- // the file on disk is always the truth here
104
- });
103
+ head(res, 200, { "content-type": type, "content-length": payload.byteLength });
105
104
  res.end(payload);
106
105
  } catch {
107
106
  head(res, 404, { "content-type": "text/plain; charset=utf-8" }).end("not found");
108
107
  }
109
108
  };
110
- const server2 = createServer((req, res) => {
109
+ const handle = async (req, res) => {
110
+ res.setHeader("cache-control", "no-store");
111
+ for (const [name, value] of headers) res.setHeader(name, value);
112
+ for (const hook of hooks) await hook(req, res);
113
+ if (res.headersSent || res.writableEnded) return;
111
114
  const pathname = (req.url ?? "/").split(/[?#]/)[0];
112
115
  if (pathname === CLIENT_URL) {
113
- head(res, 200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" });
116
+ head(res, 200, { "content-type": "text/javascript; charset=utf-8" });
114
117
  res.end(CLIENT);
115
118
  return;
116
119
  }
117
120
  if (pathname === EVENTS_URL) {
118
- head(res, 200, {
119
- "content-type": "text/event-stream",
120
- "cache-control": "no-store",
121
- connection: "keep-alive"
122
- });
121
+ head(res, 200, { "content-type": "text/event-stream", connection: "keep-alive" });
123
122
  res.write(": jq79\n\n");
124
123
  clients.add(res);
125
124
  req.on("close", () => clients.delete(res));
126
125
  return;
127
126
  }
128
- void serveStatic(req, res, pathname);
127
+ await serveStatic(req, res, pathname);
128
+ };
129
+ const server2 = createServer((req, res) => {
130
+ void handle(req, res).catch((error) => {
131
+ console.error(`jq79 dev: failed to respond to ${req.url}
132
+ `, error);
133
+ if (!res.headersSent) {
134
+ res.removeHeader("content-length");
135
+ res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
136
+ }
137
+ if (!res.writableEnded) res.end("internal error");
138
+ });
129
139
  });
130
140
  const ignored = (rel) => rel.split(sep).some((part) => part.startsWith(".") || part === "node_modules");
131
141
  const pending = /* @__PURE__ */ new Map();
package/dist/dev.cjs CHANGED
@@ -31,6 +31,10 @@ var CONTENT_TYPES = {
31
31
  ".html": "text/html; charset=utf-8",
32
32
  ".js": "text/javascript; charset=utf-8",
33
33
  ".mjs": "text/javascript; charset=utf-8",
34
+ // the streaming WebAssembly APIs reject every other type, including the
35
+ // default below - a module served as octet-stream still runs, but it is
36
+ // buffered whole instead of compiling as it downloads
37
+ ".wasm": "application/wasm",
34
38
  ".json": "application/json; charset=utf-8",
35
39
  ".css": "text/css; charset=utf-8",
36
40
  ".svg": "image/svg+xml",
@@ -76,9 +80,10 @@ var injectClient = (html) => {
76
80
  var devServer = async (options = {}) => {
77
81
  const root = await (0, import_promises.realpath)((0, import_node_path.resolve)(options.rootDir ?? "."));
78
82
  const host = options.host ?? "localhost";
79
- const headers = Object.fromEntries(
80
- Object.entries(options.headers ?? {}).map(([name, value]) => [name.toLowerCase(), value])
83
+ const headers = Object.entries(options.headers ?? {}).filter(
84
+ ([name]) => !["content-type", "content-length"].includes(name.toLowerCase())
81
85
  );
86
+ const hooks = options.beforeResponse ?? [];
82
87
  const clients = /* @__PURE__ */ new Set();
83
88
  const send = (event, data) => {
84
89
  const frame = `event: ${event}
@@ -88,11 +93,10 @@ data: ${JSON.stringify(data)}
88
93
  clients.forEach((client) => client.write(frame));
89
94
  };
90
95
  const head = (res, status, own = {}) => {
91
- const merged = { ...own, ...headers };
92
- for (const name of ["content-type", "content-length"]) {
93
- if (own[name] !== void 0) merged[name] = own[name];
94
- }
95
- return res.writeHead(status, merged);
96
+ res.removeHeader("content-length");
97
+ const write = { ...own };
98
+ if (res.hasHeader("content-type")) delete write["content-type"];
99
+ return res.writeHead(status, write);
96
100
  };
97
101
  const serveStatic = async (req, res, pathname) => {
98
102
  let file;
@@ -118,36 +122,42 @@ data: ${JSON.stringify(data)}
118
122
  const type = CONTENT_TYPES[(0, import_node_path.extname)(file).toLowerCase()] ?? "application/octet-stream";
119
123
  const html = type.startsWith("text/html") && isDocument(req);
120
124
  const payload = html ? Buffer.from(injectClient(body.toString("utf8"))) : body;
121
- head(res, 200, {
122
- "content-type": type,
123
- "content-length": payload.byteLength,
124
- "cache-control": "no-store"
125
- // the file on disk is always the truth here
126
- });
125
+ head(res, 200, { "content-type": type, "content-length": payload.byteLength });
127
126
  res.end(payload);
128
127
  } catch {
129
128
  head(res, 404, { "content-type": "text/plain; charset=utf-8" }).end("not found");
130
129
  }
131
130
  };
132
- const server = (0, import_node_http.createServer)((req, res) => {
131
+ const handle = async (req, res) => {
132
+ res.setHeader("cache-control", "no-store");
133
+ for (const [name, value] of headers) res.setHeader(name, value);
134
+ for (const hook of hooks) await hook(req, res);
135
+ if (res.headersSent || res.writableEnded) return;
133
136
  const pathname = (req.url ?? "/").split(/[?#]/)[0];
134
137
  if (pathname === CLIENT_URL) {
135
- head(res, 200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" });
138
+ head(res, 200, { "content-type": "text/javascript; charset=utf-8" });
136
139
  res.end(CLIENT);
137
140
  return;
138
141
  }
139
142
  if (pathname === EVENTS_URL) {
140
- head(res, 200, {
141
- "content-type": "text/event-stream",
142
- "cache-control": "no-store",
143
- connection: "keep-alive"
144
- });
143
+ head(res, 200, { "content-type": "text/event-stream", connection: "keep-alive" });
145
144
  res.write(": jq79\n\n");
146
145
  clients.add(res);
147
146
  req.on("close", () => clients.delete(res));
148
147
  return;
149
148
  }
150
- void serveStatic(req, res, pathname);
149
+ await serveStatic(req, res, pathname);
150
+ };
151
+ const server = (0, import_node_http.createServer)((req, res) => {
152
+ void handle(req, res).catch((error) => {
153
+ console.error(`jq79 dev: failed to respond to ${req.url}
154
+ `, error);
155
+ if (!res.headersSent) {
156
+ res.removeHeader("content-length");
157
+ res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
158
+ }
159
+ if (!res.writableEnded) res.end("internal error");
160
+ });
151
161
  });
152
162
  const ignored = (rel) => rel.split(import_node_path.sep).some((part) => part.startsWith(".") || part === "node_modules");
153
163
  const pending = /* @__PURE__ */ new Map();
package/dist/dev.cjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../dev/dev.ts"],"sourcesContent":["import { createServer, type Server, type IncomingMessage, type ServerResponse } from \"node:http\"\nimport { readFile, realpath, stat } from \"node:fs/promises\"\nimport { watch, type FSWatcher } from \"node:fs\"\nimport { basename, dirname, extname, isAbsolute, join, matchesGlob, relative, resolve, sep } from \"node:path\"\n\n// A dev server for the no-bundle path: serve a directory of .html components\n// over HTTP, watch it, and hot-reload the components that changed.\n//\n// npx jq79 dev // the CLI\n// import { devServer } from \"jq79/dev\" // or from a script\n// await devServer({ rootDir: \".\" })\n//\n// It is a static file server and nothing else - no transforms, no bundling, no\n// module graph. Which is the point: the files it serves are the files you would\n// deploy, so what you develop against and what a static host serves are the same\n// bytes. The one thing it adds is the hot-reload channel, and it adds it to\n// *documents* only (a component fetched by the runtime is served verbatim).\n//\n// The reload is fine-grained. On a change the server pushes the new source down\n// an SSE channel and the runtime swaps it into every live instance of that file,\n// keeping its data - see hotUpdate in jq79.ts. Anything the runtime can't place\n// (a page, a stylesheet, a component nothing has mounted yet) falls back to a\n// full page reload.\n\nexport interface DevServerOptions {\n // the directory to serve, and to watch (default: the current directory)\n rootDir?: string\n // default: 4179, or the first free port after it\n port?: number\n // default: localhost\n host?: string\n // response headers to send on everything - a static host is configured, and\n // these are that configuration (default: none)\n headers?: Record<string, string>\n // what to watch on top of rootDir, and what to do about it (default: none)\n watch?: WatchEntry[]\n}\n\n// the handler gets every file of the burst that woke it, absolute, so a save\n// that touches three of them is one build rather than three\nexport type WatchHandler = (files: string[]) => void | Promise<void>\n\nexport interface WatchEntry {\n // glob(s) resolved against the cwd, like rootDir: \"styles/**/*.scss\". A bare\n // directory means everything under it\n pattern: string | string[]\n // what a match runs. Without one, a match outside the served root reloads the\n // page - which is the only other thing a file served from nowhere can do\n fn?: WatchHandler\n}\n\nexport interface DevServer {\n url: string\n port: number\n close: () => Promise<void>\n}\n\nconst CONTENT_TYPES: Record<string, string> = {\n \".html\": \"text/html; charset=utf-8\",\n \".js\": \"text/javascript; charset=utf-8\",\n \".mjs\": \"text/javascript; charset=utf-8\",\n \".json\": \"application/json; charset=utf-8\",\n \".css\": \"text/css; charset=utf-8\",\n \".svg\": \"image/svg+xml\",\n \".png\": \"image/png\",\n \".jpg\": \"image/jpeg\",\n \".jpeg\": \"image/jpeg\",\n \".gif\": \"image/gif\",\n \".webp\": \"image/webp\",\n \".avif\": \"image/avif\",\n \".ico\": \"image/x-icon\",\n \".woff\": \"font/woff\",\n \".woff2\": \"font/woff2\",\n \".map\": \"application/json; charset=utf-8\",\n}\n\nconst CLIENT_URL = \"/__jq79/client.js\"\nconst EVENTS_URL = \"/__jq79/events\"\n\n// Served as a *classic* script, and injected into the <head>: classic scripts\n// run at parse time and module scripts are deferred, so the flag is set before\n// the page's `import ... from \"jq79\"` evaluates - which is what the runtime\n// waits for before it starts tracking instances. The client can't import the\n// runtime itself: the page's copy may come from a CDN or an import map, and a\n// second copy would have a second, empty registry.\nconst CLIENT = `(() => {\n window.__JQ79_HMR_ENABLED__ = true\n\n const events = new EventSource(${JSON.stringify(EVENTS_URL)})\n\n events.addEventListener(\"update\", event => {\n const { url, src } = JSON.parse(event.data)\n const runtime = window.__JQ79_HMR__\n // no runtime (the page doesn't use jq79), or no live instance from this\n // file (it isn't mounted, or it *is* the page) - nothing to swap into\n const patched = runtime ? runtime.update(url, src) : 0\n if (patched) console.log(\"[jq79] hot-updated \" + url + \" (\" + patched + (patched === 1 ? \" instance)\" : \" instances)\"))\n else location.reload()\n })\n\n events.addEventListener(\"reload\", () => location.reload())\n})()`\n\nconst posix = (path: string) => path.split(sep).join(\"/\")\n\nconst isDocument = (req: IncomingMessage) => req.headers[\"sec-fetch-dest\"] === \"document\"\n\n// the client goes in the <head> so it is the first thing the page runs. A file\n// with neither <head> nor <body> is still a document a browser will render, so\n// fall back to the top of it rather than skipping the injection\nconst injectClient = (html: string): string => {\n const tag = `<script src=\"${CLIENT_URL}\"></script>`\n // (\\s[^>]*)? rather than [^>]*, or <header> would pass for <head>\n const open = /<head(\\s[^>]*)?>/i.exec(html) ?? /<body(\\s[^>]*)?>/i.exec(html)\n if (!open) return tag + html\n const at = open.index + open[0].length\n return html.slice(0, at) + tag + html.slice(at)\n}\n\nexport const devServer = async (options: DevServerOptions = {}): Promise<DevServer> => {\n // the *real* path: the watcher reports what changed relative to the directory\n // it actually opened, so a root reached through a symlink (/tmp and /var are\n // symlinks on macOS) would hand back paths that don't line up with it\n const root = await realpath(resolve(options.rootDir ?? \".\"))\n const host = options.host ?? \"localhost\"\n\n // lowercased once: header names are case-insensitive and an object is not, so\n // a \"Cache-Control\" in the options would otherwise go out *beside* the\n // server's own cache-control rather than replacing it\n const headers: Record<string, string> = Object.fromEntries(\n Object.entries(options.headers ?? {}).map(([name, value]) => [name.toLowerCase(), value]),\n )\n\n const clients = new Set<ServerResponse>()\n\n const send = (event: string, data: unknown) => {\n const frame = `event: ${event}\\ndata: ${JSON.stringify(data)}\\n\\n`\n clients.forEach(client => client.write(frame))\n }\n\n // --- serving ---------------------------------------------------------------\n\n // every response carries the configured headers - a page, a component, the\n // client, the event stream, a 404. All but two: content-type and\n // content-length describe the bytes of the response they are on (a wrong\n // length truncates the body or hangs the socket), so those stay the server's\n const head = (res: ServerResponse, status: number, own: Record<string, string | number> = {}) => {\n const merged: Record<string, string | number> = { ...own, ...headers }\n for (const name of [\"content-type\", \"content-length\"]) {\n if (own[name] !== undefined) merged[name] = own[name]\n }\n return res.writeHead(status, merged)\n }\n\n const serveStatic = async (req: IncomingMessage, res: ServerResponse, pathname: string) => {\n // a URL path is not a file path: decode it, then keep the result inside the\n // root (\"..\" in a request must not walk out of the served directory)\n let file: string\n try {\n file = resolve(join(root, decodeURIComponent(pathname)))\n } catch {\n head(res, 400).end(\"bad request\")\n return\n }\n if (file !== root && !file.startsWith(root + sep)) {\n head(res, 403).end(\"forbidden\")\n return\n }\n\n try {\n if ((await stat(file)).isDirectory()) {\n // a directory is served through its trailing-slash URL, like every\n // static host: /docs rendered as-is would resolve its relative links\n // against the parent (\"img.png\" -> /img.png instead of /docs/img.png)\n if (!pathname.endsWith(\"/\")) {\n head(res, 301, { location: pathname + \"/\" }).end()\n return\n }\n file = join(file, \"index.html\")\n }\n const body = await readFile(file)\n const type = CONTENT_TYPES[extname(file).toLowerCase()] ?? \"application/octet-stream\"\n\n // only a navigation gets the hot-reload client. A component is fetched by\n // the runtime (sec-fetch-dest: empty), and it must arrive as written -\n // injecting a <script> into it would make the runtime parse and run it\n const html = type.startsWith(\"text/html\") && isDocument(req)\n const payload = html ? Buffer.from(injectClient(body.toString(\"utf8\"))) : body\n\n head(res, 200, {\n \"content-type\": type,\n \"content-length\": payload.byteLength,\n \"cache-control\": \"no-store\", // the file on disk is always the truth here\n })\n res.end(payload)\n } catch {\n head(res, 404, { \"content-type\": \"text/plain; charset=utf-8\" }).end(\"not found\")\n }\n }\n\n const server: Server = createServer((req, res) => {\n const pathname = (req.url ?? \"/\").split(/[?#]/)[0]\n\n if (pathname === CLIENT_URL) {\n head(res, 200, { \"content-type\": \"text/javascript; charset=utf-8\", \"cache-control\": \"no-store\" })\n res.end(CLIENT)\n return\n }\n\n if (pathname === EVENTS_URL) {\n head(res, 200, {\n \"content-type\": \"text/event-stream\",\n \"cache-control\": \"no-store\",\n connection: \"keep-alive\",\n })\n res.write(\": jq79\\n\\n\") // opens the stream, so the browser fires onopen\n clients.add(res)\n req.on(\"close\", () => clients.delete(res))\n return\n }\n\n void serveStatic(req, res, pathname)\n })\n\n // --- watching --------------------------------------------------------------\n\n const ignored = (rel: string) =>\n rel.split(sep).some(part => part.startsWith(\".\") || part === \"node_modules\")\n\n // one save can arrive as several events (a rename plus a change, an editor's\n // atomic write); collapsing per file keeps that down to one push\n const pending = new Map<string, NodeJS.Timeout>()\n\n // a pattern is written against the cwd (`resolve` reads it the same way), and\n // captured here so a later chdir can't move what the globs mean\n const cwd = process.cwd()\n\n type Entry = {\n patterns: string[]\n fn?: WatchHandler\n batch: Set<string>\n timer?: NodeJS.Timeout\n running: boolean\n }\n\n const entries: Entry[] = (options.watch ?? []).map(entry => ({\n patterns: [entry.pattern].flat(),\n fn: entry.fn,\n batch: new Set(),\n running: false,\n }))\n\n const claims = (entry: Entry, file: string) =>\n entry.patterns.some(pattern =>\n matchesGlob(isAbsolute(pattern) ? posix(file) : posix(relative(cwd, file)), pattern),\n )\n\n // a burst is one call: the handler is a build step, and building once per file\n // of a save that touched four is three builds nobody asked for\n const collect = (entry: Entry, file: string) => {\n entry.batch.add(file)\n clearTimeout(entry.timer)\n entry.timer = setTimeout(() => void fire(entry), 30)\n }\n\n const fire = async (entry: Entry) => {\n if (entry.running || !entry.batch.size) return\n const files = [...entry.batch]\n entry.batch.clear()\n entry.running = true\n try {\n await entry.fn?.(files)\n } catch (error) {\n // a handler is someone's build script, and a build that fails is a normal\n // morning. Reporting it and staying up beats taking the server with it\n console.error(`jq79 dev: watch handler for ${entry.patterns.join(\", \")} failed\\n`, error)\n } finally {\n entry.running = false\n if (entry.batch.size) void fire(entry) // saved again while it ran\n }\n }\n\n // `dir` is the directory whose watcher reported this - only needed for the\n // macOS quirk below, and only when the file turns out not to exist\n const changed = async (file: string, dir: string) => {\n // where the browser knows the file from, which only exists for a file under\n // the served root: a watched path outside it is served from nowhere, so\n // there is no url for the runtime to match an instance against\n const rel = relative(root, file)\n const served = !!rel && !rel.startsWith(\"..\")\n\n let src: string | null = null\n try {\n // a directory changes whenever anything inside it does, and the event for\n // the file itself is already on its way - acting on both would reload the\n // page every time a component is saved\n if ((await stat(file)).isDirectory()) return\n if (served && file.endsWith(\".html\")) src = await readFile(file, \"utf8\")\n } catch {\n // gone: deleted, or renamed away - and there is nothing to swap in, so the\n // page has to reload. Unless it was never there: macOS reports a change to\n // a watched directory *itself* under its own basename, which resolves to a\n // path inside it that does not exist\n if (file === join(dir, basename(dir))) return\n }\n\n // every entry that names this file gets it, wherever the file lives. What\n // the root does with its own files is not up for negotiation: no pattern can\n // switch hot reload off, so a handler can never cost you the thing you came\n // for by matching more than its author meant it to\n const claimed = entries.filter(entry => claims(entry, file))\n claimed.forEach(entry => entry.fn && collect(entry, file))\n\n if (served) {\n // the url is the one the component was served from, because that is what\n // the runtime resolves its instances' filenames against\n const url = \"/\" + posix(rel)\n if (src === null) send(\"reload\", { url })\n else send(\"update\", { url, src })\n return\n }\n\n // outside the root a handler *is* the answer: it ran, and whatever it writes\n // into the served directory comes back round as a change of its own, with a\n // url. An entry with no handler is asking for the page, and gets a path\n // relative to the root - the client only logs it, and an absolute one would\n // publish the machine's layout to the page\n if (claimed.some(entry => !entry.fn)) send(\"reload\", { url: posix(rel) })\n }\n\n const queue = (file: string, dir: string) => {\n clearTimeout(pending.get(file))\n pending.set(file, setTimeout(() => {\n pending.delete(file)\n void changed(file, dir)\n }, 30))\n }\n\n const watchers: FSWatcher[] = []\n\n // a directory is watched whole. A single file is watched through the directory\n // it sits in (`only` filtering the rest back out), because a watcher on a file\n // holds its inode, and an editor's atomic save renames a new one over it - the\n // watcher survives as a handle on a file nothing will ever write to again\n const watchTree = (dir: string, only?: string) => {\n watchers.push(watch(dir, { recursive: !only }, (_event, filename) => {\n if (!filename) return\n const file = resolve(dir, filename.toString())\n const rel = relative(dir, file)\n if (!rel || rel.startsWith(\"..\")) return\n if (only ? rel !== only : ignored(rel)) return\n queue(file, dir)\n }))\n }\n\n // a glob is a filter and a watcher needs a directory to open, so the watch\n // starts at the literal head of the pattern - everything before the first\n // magic character. \"styles/**/*.scss\" opens styles/, \"**/*.scss\" opens the cwd\n const literalHead = (pattern: string) => {\n const parts = pattern.split(\"/\")\n const magic = parts.findIndex(part => /[*?[\\]{}!]/.test(part))\n return resolve(magic === -1 ? pattern : parts.slice(0, magic).join(\"/\") || \".\")\n }\n\n // the patterns are resolved like rootDir, against the cwd, and all checked\n // before anything is watched: a path that isn't there is a typo, and a watcher\n // that silently isn't running is worse than a server that won't start\n const extra = new Map<string, { dir: string; only?: string }>()\n for (const entry of entries) {\n for (const [i, pattern] of entry.patterns.entries()) {\n const head = literalHead(pattern)\n const info = await stat(head).catch(() => null)\n if (!info) throw new Error(`jq79 dev: nothing to watch at ${head}`)\n\n // a bare directory means everything under it, which is what it looks like\n // it means - as a pattern it would watch the directory and match nothing\n // in it, and the mistake is invisible until a save doesn't fire\n if (info.isDirectory() && head === resolve(pattern)) {\n entry.patterns[i] = pattern.replace(/\\/+$/, \"\") + \"/**\"\n }\n\n // real paths on both sides, so a symlink pointing out of the root reads as\n // what it is - outside, and not covered by the recursive watch below\n const real = await realpath(head)\n if (real === root || real.startsWith(root + sep)) continue // already watched\n const watched = info.isDirectory() ? { dir: real } : { dir: dirname(real), only: basename(real) }\n extra.set(`${watched.dir}\\0${watched.only ?? \"\"}`, watched) // two patterns can share a head\n }\n }\n\n watchTree(root)\n extra.forEach(({ dir, only }) => watchTree(dir, only))\n\n // proxies and load balancers cut an idle stream; a comment every 30s is the\n // conventional way to keep it open. unref'd, so it never holds the process up\n const heartbeat = setInterval(() => clients.forEach(client => client.write(\": ping\\n\\n\")), 30_000)\n heartbeat.unref()\n\n // --- go --------------------------------------------------------------------\n\n await new Promise<void>((done, fail) => {\n server.once(\"error\", fail)\n server.listen(options.port ?? 4179, host, done)\n })\n const { port } = server.address() as { port: number }\n\n return {\n url: `http://${host}:${port}`,\n port,\n close: () =>\n new Promise(done => {\n clearInterval(heartbeat)\n watchers.forEach(watcher => watcher.close())\n pending.forEach(clearTimeout)\n entries.forEach(entry => clearTimeout(entry.timer))\n clients.forEach(client => client.end())\n clients.clear()\n server.close(() => done())\n }),\n }\n}\n\nexport default devServer\n"],"mappings":"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","names":["head"]}
1
+ {"version":3,"sources":["../dev/dev.ts"],"sourcesContent":["import { createServer, type Server, type IncomingMessage, type ServerResponse } from \"node:http\"\nimport { readFile, realpath, stat } from \"node:fs/promises\"\nimport { watch, type FSWatcher } from \"node:fs\"\nimport { basename, dirname, extname, isAbsolute, join, matchesGlob, relative, resolve, sep } from \"node:path\"\n\n// A dev server for the no-bundle path: serve a directory of .html components\n// over HTTP, watch it, and hot-reload the components that changed.\n//\n// npx jq79 dev // the CLI\n// import { devServer } from \"jq79/dev\" // or from a script\n// await devServer({ rootDir: \".\" })\n//\n// It is a static file server and nothing else - no transforms, no bundling, no\n// module graph. Which is the point: the files it serves are the files you would\n// deploy, so what you develop against and what a static host serves are the same\n// bytes. The one thing it adds is the hot-reload channel, and it adds it to\n// *documents* only (a component fetched by the runtime is served verbatim).\n//\n// The reload is fine-grained. On a change the server pushes the new source down\n// an SSE channel and the runtime swaps it into every live instance of that file,\n// keeping its data - see hotUpdate in jq79.ts. Anything the runtime can't place\n// (a page, a stylesheet, a component nothing has mounted yet) falls back to a\n// full page reload.\n\nexport interface DevServerOptions {\n // the directory to serve, and to watch (default: the current directory)\n rootDir?: string\n // default: 4179, or the first free port after it\n port?: number\n // default: localhost\n host?: string\n // response headers to send on everything - a static host is configured, and\n // these are that configuration (default: none)\n headers?: Record<string, string>\n // the same thing per request: a hook gets the request and its response before\n // anything has been written to it (default: none)\n beforeResponse?: ResponseHook[]\n // what to watch on top of rootDir, and what to do about it (default: none)\n watch?: WatchEntry[]\n}\n\n// runs on every response the server makes - a page, a component, the client,\n// the event stream, a 404 - with nothing written yet, so `res.setHeader` still\n// applies. A hook that answers the request itself (`res.end`) is left alone\nexport type ResponseHook = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>\n\n// the handler gets every file of the burst that woke it, absolute, so a save\n// that touches three of them is one build rather than three\nexport type WatchHandler = (files: string[]) => void | Promise<void>\n\nexport interface WatchEntry {\n // glob(s) resolved against the cwd, like rootDir: \"styles/**/*.scss\". A bare\n // directory means everything under it\n pattern: string | string[]\n // what a match runs. Without one, a match outside the served root reloads the\n // page - which is the only other thing a file served from nowhere can do\n fn?: WatchHandler\n}\n\nexport interface DevServer {\n url: string\n port: number\n close: () => Promise<void>\n}\n\nconst CONTENT_TYPES: Record<string, string> = {\n \".html\": \"text/html; charset=utf-8\",\n \".js\": \"text/javascript; charset=utf-8\",\n \".mjs\": \"text/javascript; charset=utf-8\",\n // the streaming WebAssembly APIs reject every other type, including the\n // default below - a module served as octet-stream still runs, but it is\n // buffered whole instead of compiling as it downloads\n \".wasm\": \"application/wasm\",\n \".json\": \"application/json; charset=utf-8\",\n \".css\": \"text/css; charset=utf-8\",\n \".svg\": \"image/svg+xml\",\n \".png\": \"image/png\",\n \".jpg\": \"image/jpeg\",\n \".jpeg\": \"image/jpeg\",\n \".gif\": \"image/gif\",\n \".webp\": \"image/webp\",\n \".avif\": \"image/avif\",\n \".ico\": \"image/x-icon\",\n \".woff\": \"font/woff\",\n \".woff2\": \"font/woff2\",\n \".map\": \"application/json; charset=utf-8\",\n}\n\nconst CLIENT_URL = \"/__jq79/client.js\"\nconst EVENTS_URL = \"/__jq79/events\"\n\n// Served as a *classic* script, and injected into the <head>: classic scripts\n// run at parse time and module scripts are deferred, so the flag is set before\n// the page's `import ... from \"jq79\"` evaluates - which is what the runtime\n// waits for before it starts tracking instances. The client can't import the\n// runtime itself: the page's copy may come from a CDN or an import map, and a\n// second copy would have a second, empty registry.\nconst CLIENT = `(() => {\n window.__JQ79_HMR_ENABLED__ = true\n\n const events = new EventSource(${JSON.stringify(EVENTS_URL)})\n\n events.addEventListener(\"update\", event => {\n const { url, src } = JSON.parse(event.data)\n const runtime = window.__JQ79_HMR__\n // no runtime (the page doesn't use jq79), or no live instance from this\n // file (it isn't mounted, or it *is* the page) - nothing to swap into\n const patched = runtime ? runtime.update(url, src) : 0\n if (patched) console.log(\"[jq79] hot-updated \" + url + \" (\" + patched + (patched === 1 ? \" instance)\" : \" instances)\"))\n else location.reload()\n })\n\n events.addEventListener(\"reload\", () => location.reload())\n})()`\n\nconst posix = (path: string) => path.split(sep).join(\"/\")\n\nconst isDocument = (req: IncomingMessage) => req.headers[\"sec-fetch-dest\"] === \"document\"\n\n// the client goes in the <head> so it is the first thing the page runs. A file\n// with neither <head> nor <body> is still a document a browser will render, so\n// fall back to the top of it rather than skipping the injection\nconst injectClient = (html: string): string => {\n const tag = `<script src=\"${CLIENT_URL}\"></script>`\n // (\\s[^>]*)? rather than [^>]*, or <header> would pass for <head>\n const open = /<head(\\s[^>]*)?>/i.exec(html) ?? /<body(\\s[^>]*)?>/i.exec(html)\n if (!open) return tag + html\n const at = open.index + open[0].length\n return html.slice(0, at) + tag + html.slice(at)\n}\n\nexport const devServer = async (options: DevServerOptions = {}): Promise<DevServer> => {\n // the *real* path: the watcher reports what changed relative to the directory\n // it actually opened, so a root reached through a symlink (/tmp and /var are\n // symlinks on macOS) would hand back paths that don't line up with it\n const root = await realpath(resolve(options.rootDir ?? \".\"))\n const host = options.host ?? \"localhost\"\n\n // a configured header goes on every response, which is the one thing\n // content-type and content-length cannot do - each describes the bytes of a\n // single response. Dropped here rather than at write time, so that either of\n // them still standing by then can only have come from a hook, which saw the\n // request and is entitled to say\n const headers = Object.entries(options.headers ?? {}).filter(\n ([name]) => ![\"content-type\", \"content-length\"].includes(name.toLowerCase()),\n )\n const hooks = options.beforeResponse ?? []\n\n const clients = new Set<ServerResponse>()\n\n const send = (event: string, data: unknown) => {\n const frame = `event: ${event}\\ndata: ${JSON.stringify(data)}\\n\\n`\n clients.forEach(client => client.write(frame))\n }\n\n // --- serving ---------------------------------------------------------------\n\n // what the response says about its own bytes, written last so the layers\n // below can't contradict it. A hook is the one exception: it saw the request,\n // so it is entitled to an opinion about what these bytes *are* - never about\n // how many there are, since a wrong content-length truncates the body or\n // hangs the socket\n const head = (res: ServerResponse, status: number, own: Record<string, string | number> = {}) => {\n res.removeHeader(\"content-length\")\n const write = { ...own }\n if (res.hasHeader(\"content-type\")) delete write[\"content-type\"]\n return res.writeHead(status, write)\n }\n\n const serveStatic = async (req: IncomingMessage, res: ServerResponse, pathname: string) => {\n // a URL path is not a file path: decode it, then keep the result inside the\n // root (\"..\" in a request must not walk out of the served directory)\n let file: string\n try {\n file = resolve(join(root, decodeURIComponent(pathname)))\n } catch {\n head(res, 400).end(\"bad request\")\n return\n }\n if (file !== root && !file.startsWith(root + sep)) {\n head(res, 403).end(\"forbidden\")\n return\n }\n\n try {\n if ((await stat(file)).isDirectory()) {\n // a directory is served through its trailing-slash URL, like every\n // static host: /docs rendered as-is would resolve its relative links\n // against the parent (\"img.png\" -> /img.png instead of /docs/img.png)\n if (!pathname.endsWith(\"/\")) {\n head(res, 301, { location: pathname + \"/\" }).end()\n return\n }\n file = join(file, \"index.html\")\n }\n const body = await readFile(file)\n const type = CONTENT_TYPES[extname(file).toLowerCase()] ?? \"application/octet-stream\"\n\n // only a navigation gets the hot-reload client. A component is fetched by\n // the runtime (sec-fetch-dest: empty), and it must arrive as written -\n // injecting a <script> into it would make the runtime parse and run it\n const html = type.startsWith(\"text/html\") && isDocument(req)\n const payload = html ? Buffer.from(injectClient(body.toString(\"utf8\"))) : body\n\n head(res, 200, { \"content-type\": type, \"content-length\": payload.byteLength })\n res.end(payload)\n } catch {\n head(res, 404, { \"content-type\": \"text/plain; charset=utf-8\" }).end(\"not found\")\n }\n }\n\n // three layers, general to specific, each one free to replace the last: the\n // server's own defaults, the configured headers over them, and a hook - which\n // saw the request - over those. They go on with setHeader rather than into an\n // object, because setHeader is case-insensitive where an object is not: a\n // \"Cache-Control\" replaces the cache-control below it instead of arriving\n // beside it, and the same goes for whatever a hook names\n const handle = async (req: IncomingMessage, res: ServerResponse) => {\n res.setHeader(\"cache-control\", \"no-store\") // the file on disk is always the truth here\n for (const [name, value] of headers) res.setHeader(name, value)\n for (const hook of hooks) await hook(req, res)\n\n // a hook can answer the request itself, and one that did needs nothing else\n if (res.headersSent || res.writableEnded) return\n\n const pathname = (req.url ?? \"/\").split(/[?#]/)[0]\n\n if (pathname === CLIENT_URL) {\n head(res, 200, { \"content-type\": \"text/javascript; charset=utf-8\" })\n res.end(CLIENT)\n return\n }\n\n if (pathname === EVENTS_URL) {\n head(res, 200, { \"content-type\": \"text/event-stream\", connection: \"keep-alive\" })\n res.write(\": jq79\\n\\n\") // opens the stream, so the browser fires onopen\n clients.add(res)\n req.on(\"close\", () => clients.delete(res))\n return\n }\n\n await serveStatic(req, res, pathname)\n }\n\n const server: Server = createServer((req, res) => {\n // a hook is someone's code, and the watch handlers already settled what that\n // means around here: report it and stay up. This one owes the browser a\n // reply as well, because a socket nobody answers hangs the page\n void handle(req, res).catch(error => {\n console.error(`jq79 dev: failed to respond to ${req.url}\\n`, error)\n if (!res.headersSent) {\n // whatever the hook had said about the bytes, it is not what is being\n // sent now - and a content-length it set before throwing would hang this\n res.removeHeader(\"content-length\")\n res.writeHead(500, { \"content-type\": \"text/plain; charset=utf-8\" })\n }\n if (!res.writableEnded) res.end(\"internal error\")\n })\n })\n\n // --- watching --------------------------------------------------------------\n\n const ignored = (rel: string) =>\n rel.split(sep).some(part => part.startsWith(\".\") || part === \"node_modules\")\n\n // one save can arrive as several events (a rename plus a change, an editor's\n // atomic write); collapsing per file keeps that down to one push\n const pending = new Map<string, NodeJS.Timeout>()\n\n // a pattern is written against the cwd (`resolve` reads it the same way), and\n // captured here so a later chdir can't move what the globs mean\n const cwd = process.cwd()\n\n type Entry = {\n patterns: string[]\n fn?: WatchHandler\n batch: Set<string>\n timer?: NodeJS.Timeout\n running: boolean\n }\n\n const entries: Entry[] = (options.watch ?? []).map(entry => ({\n patterns: [entry.pattern].flat(),\n fn: entry.fn,\n batch: new Set(),\n running: false,\n }))\n\n const claims = (entry: Entry, file: string) =>\n entry.patterns.some(pattern =>\n matchesGlob(isAbsolute(pattern) ? posix(file) : posix(relative(cwd, file)), pattern),\n )\n\n // a burst is one call: the handler is a build step, and building once per file\n // of a save that touched four is three builds nobody asked for\n const collect = (entry: Entry, file: string) => {\n entry.batch.add(file)\n clearTimeout(entry.timer)\n entry.timer = setTimeout(() => void fire(entry), 30)\n }\n\n const fire = async (entry: Entry) => {\n if (entry.running || !entry.batch.size) return\n const files = [...entry.batch]\n entry.batch.clear()\n entry.running = true\n try {\n await entry.fn?.(files)\n } catch (error) {\n // a handler is someone's build script, and a build that fails is a normal\n // morning. Reporting it and staying up beats taking the server with it\n console.error(`jq79 dev: watch handler for ${entry.patterns.join(\", \")} failed\\n`, error)\n } finally {\n entry.running = false\n if (entry.batch.size) void fire(entry) // saved again while it ran\n }\n }\n\n // `dir` is the directory whose watcher reported this - only needed for the\n // macOS quirk below, and only when the file turns out not to exist\n const changed = async (file: string, dir: string) => {\n // where the browser knows the file from, which only exists for a file under\n // the served root: a watched path outside it is served from nowhere, so\n // there is no url for the runtime to match an instance against\n const rel = relative(root, file)\n const served = !!rel && !rel.startsWith(\"..\")\n\n let src: string | null = null\n try {\n // a directory changes whenever anything inside it does, and the event for\n // the file itself is already on its way - acting on both would reload the\n // page every time a component is saved\n if ((await stat(file)).isDirectory()) return\n if (served && file.endsWith(\".html\")) src = await readFile(file, \"utf8\")\n } catch {\n // gone: deleted, or renamed away - and there is nothing to swap in, so the\n // page has to reload. Unless it was never there: macOS reports a change to\n // a watched directory *itself* under its own basename, which resolves to a\n // path inside it that does not exist\n if (file === join(dir, basename(dir))) return\n }\n\n // every entry that names this file gets it, wherever the file lives. What\n // the root does with its own files is not up for negotiation: no pattern can\n // switch hot reload off, so a handler can never cost you the thing you came\n // for by matching more than its author meant it to\n const claimed = entries.filter(entry => claims(entry, file))\n claimed.forEach(entry => entry.fn && collect(entry, file))\n\n if (served) {\n // the url is the one the component was served from, because that is what\n // the runtime resolves its instances' filenames against\n const url = \"/\" + posix(rel)\n if (src === null) send(\"reload\", { url })\n else send(\"update\", { url, src })\n return\n }\n\n // outside the root a handler *is* the answer: it ran, and whatever it writes\n // into the served directory comes back round as a change of its own, with a\n // url. An entry with no handler is asking for the page, and gets a path\n // relative to the root - the client only logs it, and an absolute one would\n // publish the machine's layout to the page\n if (claimed.some(entry => !entry.fn)) send(\"reload\", { url: posix(rel) })\n }\n\n const queue = (file: string, dir: string) => {\n clearTimeout(pending.get(file))\n pending.set(file, setTimeout(() => {\n pending.delete(file)\n void changed(file, dir)\n }, 30))\n }\n\n const watchers: FSWatcher[] = []\n\n // a directory is watched whole. A single file is watched through the directory\n // it sits in (`only` filtering the rest back out), because a watcher on a file\n // holds its inode, and an editor's atomic save renames a new one over it - the\n // watcher survives as a handle on a file nothing will ever write to again\n const watchTree = (dir: string, only?: string) => {\n watchers.push(watch(dir, { recursive: !only }, (_event, filename) => {\n if (!filename) return\n const file = resolve(dir, filename.toString())\n const rel = relative(dir, file)\n if (!rel || rel.startsWith(\"..\")) return\n if (only ? rel !== only : ignored(rel)) return\n queue(file, dir)\n }))\n }\n\n // a glob is a filter and a watcher needs a directory to open, so the watch\n // starts at the literal head of the pattern - everything before the first\n // magic character. \"styles/**/*.scss\" opens styles/, \"**/*.scss\" opens the cwd\n const literalHead = (pattern: string) => {\n const parts = pattern.split(\"/\")\n const magic = parts.findIndex(part => /[*?[\\]{}!]/.test(part))\n return resolve(magic === -1 ? pattern : parts.slice(0, magic).join(\"/\") || \".\")\n }\n\n // the patterns are resolved like rootDir, against the cwd, and all checked\n // before anything is watched: a path that isn't there is a typo, and a watcher\n // that silently isn't running is worse than a server that won't start\n const extra = new Map<string, { dir: string; only?: string }>()\n for (const entry of entries) {\n for (const [i, pattern] of entry.patterns.entries()) {\n const head = literalHead(pattern)\n const info = await stat(head).catch(() => null)\n if (!info) throw new Error(`jq79 dev: nothing to watch at ${head}`)\n\n // a bare directory means everything under it, which is what it looks like\n // it means - as a pattern it would watch the directory and match nothing\n // in it, and the mistake is invisible until a save doesn't fire\n if (info.isDirectory() && head === resolve(pattern)) {\n entry.patterns[i] = pattern.replace(/\\/+$/, \"\") + \"/**\"\n }\n\n // real paths on both sides, so a symlink pointing out of the root reads as\n // what it is - outside, and not covered by the recursive watch below\n const real = await realpath(head)\n if (real === root || real.startsWith(root + sep)) continue // already watched\n const watched = info.isDirectory() ? { dir: real } : { dir: dirname(real), only: basename(real) }\n extra.set(`${watched.dir}\\0${watched.only ?? \"\"}`, watched) // two patterns can share a head\n }\n }\n\n watchTree(root)\n extra.forEach(({ dir, only }) => watchTree(dir, only))\n\n // proxies and load balancers cut an idle stream; a comment every 30s is the\n // conventional way to keep it open. unref'd, so it never holds the process up\n const heartbeat = setInterval(() => clients.forEach(client => client.write(\": ping\\n\\n\")), 30_000)\n heartbeat.unref()\n\n // --- go --------------------------------------------------------------------\n\n await new Promise<void>((done, fail) => {\n server.once(\"error\", fail)\n server.listen(options.port ?? 4179, host, done)\n })\n const { port } = server.address() as { port: number }\n\n return {\n url: `http://${host}:${port}`,\n port,\n close: () =>\n new Promise(done => {\n clearInterval(heartbeat)\n watchers.forEach(watcher => watcher.close())\n pending.forEach(clearTimeout)\n entries.forEach(entry => clearTimeout(entry.timer))\n clients.forEach(client => client.end())\n clients.clear()\n server.close(() => done())\n }),\n }\n}\n\nexport default devServer\n"],"mappings":"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","names":["head"]}
package/dist/dev.d.ts CHANGED
@@ -1,10 +1,13 @@
1
+ import { type IncomingMessage, type ServerResponse } from "node:http";
1
2
  export interface DevServerOptions {
2
3
  rootDir?: string;
3
4
  port?: number;
4
5
  host?: string;
5
6
  headers?: Record<string, string>;
7
+ beforeResponse?: ResponseHook[];
6
8
  watch?: WatchEntry[];
7
9
  }
10
+ export type ResponseHook = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;
8
11
  export type WatchHandler = (files: string[]) => void | Promise<void>;
9
12
  export interface WatchEntry {
10
13
  pattern: string | string[];
package/dist/dev.js CHANGED
@@ -7,6 +7,10 @@ var CONTENT_TYPES = {
7
7
  ".html": "text/html; charset=utf-8",
8
8
  ".js": "text/javascript; charset=utf-8",
9
9
  ".mjs": "text/javascript; charset=utf-8",
10
+ // the streaming WebAssembly APIs reject every other type, including the
11
+ // default below - a module served as octet-stream still runs, but it is
12
+ // buffered whole instead of compiling as it downloads
13
+ ".wasm": "application/wasm",
10
14
  ".json": "application/json; charset=utf-8",
11
15
  ".css": "text/css; charset=utf-8",
12
16
  ".svg": "image/svg+xml",
@@ -52,9 +56,10 @@ var injectClient = (html) => {
52
56
  var devServer = async (options = {}) => {
53
57
  const root = await realpath(resolve(options.rootDir ?? "."));
54
58
  const host = options.host ?? "localhost";
55
- const headers = Object.fromEntries(
56
- Object.entries(options.headers ?? {}).map(([name, value]) => [name.toLowerCase(), value])
59
+ const headers = Object.entries(options.headers ?? {}).filter(
60
+ ([name]) => !["content-type", "content-length"].includes(name.toLowerCase())
57
61
  );
62
+ const hooks = options.beforeResponse ?? [];
58
63
  const clients = /* @__PURE__ */ new Set();
59
64
  const send = (event, data) => {
60
65
  const frame = `event: ${event}
@@ -64,11 +69,10 @@ data: ${JSON.stringify(data)}
64
69
  clients.forEach((client) => client.write(frame));
65
70
  };
66
71
  const head = (res, status, own = {}) => {
67
- const merged = { ...own, ...headers };
68
- for (const name of ["content-type", "content-length"]) {
69
- if (own[name] !== void 0) merged[name] = own[name];
70
- }
71
- return res.writeHead(status, merged);
72
+ res.removeHeader("content-length");
73
+ const write = { ...own };
74
+ if (res.hasHeader("content-type")) delete write["content-type"];
75
+ return res.writeHead(status, write);
72
76
  };
73
77
  const serveStatic = async (req, res, pathname) => {
74
78
  let file;
@@ -94,36 +98,42 @@ data: ${JSON.stringify(data)}
94
98
  const type = CONTENT_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream";
95
99
  const html = type.startsWith("text/html") && isDocument(req);
96
100
  const payload = html ? Buffer.from(injectClient(body.toString("utf8"))) : body;
97
- head(res, 200, {
98
- "content-type": type,
99
- "content-length": payload.byteLength,
100
- "cache-control": "no-store"
101
- // the file on disk is always the truth here
102
- });
101
+ head(res, 200, { "content-type": type, "content-length": payload.byteLength });
103
102
  res.end(payload);
104
103
  } catch {
105
104
  head(res, 404, { "content-type": "text/plain; charset=utf-8" }).end("not found");
106
105
  }
107
106
  };
108
- const server = createServer((req, res) => {
107
+ const handle = async (req, res) => {
108
+ res.setHeader("cache-control", "no-store");
109
+ for (const [name, value] of headers) res.setHeader(name, value);
110
+ for (const hook of hooks) await hook(req, res);
111
+ if (res.headersSent || res.writableEnded) return;
109
112
  const pathname = (req.url ?? "/").split(/[?#]/)[0];
110
113
  if (pathname === CLIENT_URL) {
111
- head(res, 200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" });
114
+ head(res, 200, { "content-type": "text/javascript; charset=utf-8" });
112
115
  res.end(CLIENT);
113
116
  return;
114
117
  }
115
118
  if (pathname === EVENTS_URL) {
116
- head(res, 200, {
117
- "content-type": "text/event-stream",
118
- "cache-control": "no-store",
119
- connection: "keep-alive"
120
- });
119
+ head(res, 200, { "content-type": "text/event-stream", connection: "keep-alive" });
121
120
  res.write(": jq79\n\n");
122
121
  clients.add(res);
123
122
  req.on("close", () => clients.delete(res));
124
123
  return;
125
124
  }
126
- void serveStatic(req, res, pathname);
125
+ await serveStatic(req, res, pathname);
126
+ };
127
+ const server = createServer((req, res) => {
128
+ void handle(req, res).catch((error) => {
129
+ console.error(`jq79 dev: failed to respond to ${req.url}
130
+ `, error);
131
+ if (!res.headersSent) {
132
+ res.removeHeader("content-length");
133
+ res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
134
+ }
135
+ if (!res.writableEnded) res.end("internal error");
136
+ });
127
137
  });
128
138
  const ignored = (rel) => rel.split(sep).some((part) => part.startsWith(".") || part === "node_modules");
129
139
  const pending = /* @__PURE__ */ new Map();