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/dist/dev.cjs CHANGED
@@ -76,6 +76,9 @@ var injectClient = (html) => {
76
76
  var devServer = async (options = {}) => {
77
77
  const root = await (0, import_promises.realpath)((0, import_node_path.resolve)(options.rootDir ?? "."));
78
78
  const host = options.host ?? "localhost";
79
+ const headers = Object.fromEntries(
80
+ Object.entries(options.headers ?? {}).map(([name, value]) => [name.toLowerCase(), value])
81
+ );
79
82
  const clients = /* @__PURE__ */ new Set();
80
83
  const send = (event, data) => {
81
84
  const frame = `event: ${event}
@@ -84,22 +87,29 @@ data: ${JSON.stringify(data)}
84
87
  `;
85
88
  clients.forEach((client) => client.write(frame));
86
89
  };
90
+ 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
+ };
87
97
  const serveStatic = async (req, res, pathname) => {
88
98
  let file;
89
99
  try {
90
100
  file = (0, import_node_path.resolve)((0, import_node_path.join)(root, decodeURIComponent(pathname)));
91
101
  } catch {
92
- res.writeHead(400).end("bad request");
102
+ head(res, 400).end("bad request");
93
103
  return;
94
104
  }
95
105
  if (file !== root && !file.startsWith(root + import_node_path.sep)) {
96
- res.writeHead(403).end("forbidden");
106
+ head(res, 403).end("forbidden");
97
107
  return;
98
108
  }
99
109
  try {
100
110
  if ((await (0, import_promises.stat)(file)).isDirectory()) {
101
111
  if (!pathname.endsWith("/")) {
102
- res.writeHead(301, { location: pathname + "/" }).end();
112
+ head(res, 301, { location: pathname + "/" }).end();
103
113
  return;
104
114
  }
105
115
  file = (0, import_node_path.join)(file, "index.html");
@@ -108,7 +118,7 @@ data: ${JSON.stringify(data)}
108
118
  const type = CONTENT_TYPES[(0, import_node_path.extname)(file).toLowerCase()] ?? "application/octet-stream";
109
119
  const html = type.startsWith("text/html") && isDocument(req);
110
120
  const payload = html ? Buffer.from(injectClient(body.toString("utf8"))) : body;
111
- res.writeHead(200, {
121
+ head(res, 200, {
112
122
  "content-type": type,
113
123
  "content-length": payload.byteLength,
114
124
  "cache-control": "no-store"
@@ -116,18 +126,18 @@ data: ${JSON.stringify(data)}
116
126
  });
117
127
  res.end(payload);
118
128
  } catch {
119
- res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("not found");
129
+ head(res, 404, { "content-type": "text/plain; charset=utf-8" }).end("not found");
120
130
  }
121
131
  };
122
132
  const server = (0, import_node_http.createServer)((req, res) => {
123
133
  const pathname = (req.url ?? "/").split(/[?#]/)[0];
124
134
  if (pathname === CLIENT_URL) {
125
- res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" });
135
+ head(res, 200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" });
126
136
  res.end(CLIENT);
127
137
  return;
128
138
  }
129
139
  if (pathname === EVENTS_URL) {
130
- res.writeHead(200, {
140
+ head(res, 200, {
131
141
  "content-type": "text/event-stream",
132
142
  "cache-control": "no-store",
133
143
  connection: "keep-alive"
@@ -141,29 +151,96 @@ data: ${JSON.stringify(data)}
141
151
  });
142
152
  const ignored = (rel) => rel.split(import_node_path.sep).some((part) => part.startsWith(".") || part === "node_modules");
143
153
  const pending = /* @__PURE__ */ new Map();
144
- const changed = async (rel) => {
145
- const file = (0, import_node_path.join)(root, rel);
154
+ const cwd = process.cwd();
155
+ const entries = (options.watch ?? []).map((entry) => ({
156
+ patterns: [entry.pattern].flat(),
157
+ fn: entry.fn,
158
+ batch: /* @__PURE__ */ new Set(),
159
+ running: false
160
+ }));
161
+ const claims = (entry, file) => entry.patterns.some(
162
+ (pattern) => (0, import_node_path.matchesGlob)((0, import_node_path.isAbsolute)(pattern) ? posix(file) : posix((0, import_node_path.relative)(cwd, file)), pattern)
163
+ );
164
+ const collect = (entry, file) => {
165
+ entry.batch.add(file);
166
+ clearTimeout(entry.timer);
167
+ entry.timer = setTimeout(() => void fire(entry), 30);
168
+ };
169
+ const fire = async (entry) => {
170
+ if (entry.running || !entry.batch.size) return;
171
+ const files = [...entry.batch];
172
+ entry.batch.clear();
173
+ entry.running = true;
174
+ try {
175
+ await entry.fn?.(files);
176
+ } catch (error) {
177
+ console.error(`jq79 dev: watch handler for ${entry.patterns.join(", ")} failed
178
+ `, error);
179
+ } finally {
180
+ entry.running = false;
181
+ if (entry.batch.size) void fire(entry);
182
+ }
183
+ };
184
+ const changed = async (file, dir) => {
185
+ const rel = (0, import_node_path.relative)(root, file);
186
+ const served = !!rel && !rel.startsWith("..");
146
187
  let src = null;
147
188
  try {
148
189
  if ((await (0, import_promises.stat)(file)).isDirectory()) return;
149
- if (rel.endsWith(".html")) src = await (0, import_promises.readFile)(file, "utf8");
190
+ if (served && file.endsWith(".html")) src = await (0, import_promises.readFile)(file, "utf8");
150
191
  } catch {
151
- if (rel === (0, import_node_path.basename)(root)) return;
192
+ if (file === (0, import_node_path.join)(dir, (0, import_node_path.basename)(dir))) return;
193
+ }
194
+ const claimed = entries.filter((entry) => claims(entry, file));
195
+ claimed.forEach((entry) => entry.fn && collect(entry, file));
196
+ if (served) {
197
+ const url = "/" + posix(rel);
198
+ if (src === null) send("reload", { url });
199
+ else send("update", { url, src });
200
+ return;
152
201
  }
153
- const url = "/" + posix(rel);
154
- if (src === null) send("reload", { url });
155
- else send("update", { url, src });
202
+ if (claimed.some((entry) => !entry.fn)) send("reload", { url: posix(rel) });
156
203
  };
157
- const watcher = (0, import_node_fs.watch)(root, { recursive: true }, (_event, filename) => {
158
- if (!filename) return;
159
- const rel = (0, import_node_path.relative)(root, (0, import_node_path.resolve)(root, filename.toString()));
160
- if (!rel || rel.startsWith("..") || ignored(rel)) return;
161
- clearTimeout(pending.get(rel));
162
- pending.set(rel, setTimeout(() => {
163
- pending.delete(rel);
164
- void changed(rel);
204
+ const queue = (file, dir) => {
205
+ clearTimeout(pending.get(file));
206
+ pending.set(file, setTimeout(() => {
207
+ pending.delete(file);
208
+ void changed(file, dir);
165
209
  }, 30));
166
- });
210
+ };
211
+ const watchers = [];
212
+ const watchTree = (dir, only) => {
213
+ watchers.push((0, import_node_fs.watch)(dir, { recursive: !only }, (_event, filename) => {
214
+ if (!filename) return;
215
+ const file = (0, import_node_path.resolve)(dir, filename.toString());
216
+ const rel = (0, import_node_path.relative)(dir, file);
217
+ if (!rel || rel.startsWith("..")) return;
218
+ if (only ? rel !== only : ignored(rel)) return;
219
+ queue(file, dir);
220
+ }));
221
+ };
222
+ const literalHead = (pattern) => {
223
+ const parts = pattern.split("/");
224
+ const magic = parts.findIndex((part) => /[*?[\]{}!]/.test(part));
225
+ return (0, import_node_path.resolve)(magic === -1 ? pattern : parts.slice(0, magic).join("/") || ".");
226
+ };
227
+ const extra = /* @__PURE__ */ new Map();
228
+ for (const entry of entries) {
229
+ for (const [i, pattern] of entry.patterns.entries()) {
230
+ const head2 = literalHead(pattern);
231
+ const info = await (0, import_promises.stat)(head2).catch(() => null);
232
+ if (!info) throw new Error(`jq79 dev: nothing to watch at ${head2}`);
233
+ if (info.isDirectory() && head2 === (0, import_node_path.resolve)(pattern)) {
234
+ entry.patterns[i] = pattern.replace(/\/+$/, "") + "/**";
235
+ }
236
+ const real = await (0, import_promises.realpath)(head2);
237
+ if (real === root || real.startsWith(root + import_node_path.sep)) continue;
238
+ const watched = info.isDirectory() ? { dir: real } : { dir: (0, import_node_path.dirname)(real), only: (0, import_node_path.basename)(real) };
239
+ extra.set(`${watched.dir}\0${watched.only ?? ""}`, watched);
240
+ }
241
+ }
242
+ watchTree(root);
243
+ extra.forEach(({ dir, only }) => watchTree(dir, only));
167
244
  const heartbeat = setInterval(() => clients.forEach((client) => client.write(": ping\n\n")), 3e4);
168
245
  heartbeat.unref();
169
246
  await new Promise((done, fail) => {
@@ -176,8 +253,9 @@ data: ${JSON.stringify(data)}
176
253
  port,
177
254
  close: () => new Promise((done) => {
178
255
  clearInterval(heartbeat);
179
- watcher.close();
256
+ watchers.forEach((watcher) => watcher.close());
180
257
  pending.forEach(clearTimeout);
258
+ entries.forEach((entry) => clearTimeout(entry.timer));
181
259
  clients.forEach((client) => client.end());
182
260
  clients.clear();
183
261
  server.close(() => done());
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, extname, join, 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}\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 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 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 res.writeHead(400).end(\"bad request\")\n return\n }\n if (file !== root && !file.startsWith(root + sep)) {\n res.writeHead(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 res.writeHead(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 res.writeHead(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 res.writeHead(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 res.writeHead(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 res.writeHead(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 const changed = async (rel: string) => {\n const file = join(root, rel)\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 (rel.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 // the watched directory *itself* under its own basename, which resolves to\n // a path inside it that does not exist\n if (rel === basename(root)) return\n }\n\n // the url is the one the component was served from, because that is what the\n // 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 }\n\n const watcher: FSWatcher = watch(root, { recursive: true }, (_event, filename) => {\n if (!filename) return\n const rel = relative(root, resolve(root, filename.toString()))\n if (!rel || rel.startsWith(\"..\") || ignored(rel)) return\n\n clearTimeout(pending.get(rel))\n pending.set(rel, setTimeout(() => {\n pending.delete(rel)\n void changed(rel)\n }, 30))\n })\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 watcher.close()\n pending.forEach(clearTimeout)\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":[]}
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"]}
package/dist/dev.d.ts CHANGED
@@ -2,6 +2,13 @@ export interface DevServerOptions {
2
2
  rootDir?: string;
3
3
  port?: number;
4
4
  host?: string;
5
+ headers?: Record<string, string>;
6
+ watch?: WatchEntry[];
7
+ }
8
+ export type WatchHandler = (files: string[]) => void | Promise<void>;
9
+ export interface WatchEntry {
10
+ pattern: string | string[];
11
+ fn?: WatchHandler;
5
12
  }
6
13
  export interface DevServer {
7
14
  url: string;
package/dist/dev.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { createServer } from "http";
3
3
  import { readFile, realpath, stat } from "fs/promises";
4
4
  import { watch } from "fs";
5
- import { basename, extname, join, relative, resolve, sep } from "path";
5
+ import { basename, dirname, extname, isAbsolute, join, matchesGlob, relative, resolve, sep } from "path";
6
6
  var CONTENT_TYPES = {
7
7
  ".html": "text/html; charset=utf-8",
8
8
  ".js": "text/javascript; charset=utf-8",
@@ -52,6 +52,9 @@ var injectClient = (html) => {
52
52
  var devServer = async (options = {}) => {
53
53
  const root = await realpath(resolve(options.rootDir ?? "."));
54
54
  const host = options.host ?? "localhost";
55
+ const headers = Object.fromEntries(
56
+ Object.entries(options.headers ?? {}).map(([name, value]) => [name.toLowerCase(), value])
57
+ );
55
58
  const clients = /* @__PURE__ */ new Set();
56
59
  const send = (event, data) => {
57
60
  const frame = `event: ${event}
@@ -60,22 +63,29 @@ data: ${JSON.stringify(data)}
60
63
  `;
61
64
  clients.forEach((client) => client.write(frame));
62
65
  };
66
+ 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
+ };
63
73
  const serveStatic = async (req, res, pathname) => {
64
74
  let file;
65
75
  try {
66
76
  file = resolve(join(root, decodeURIComponent(pathname)));
67
77
  } catch {
68
- res.writeHead(400).end("bad request");
78
+ head(res, 400).end("bad request");
69
79
  return;
70
80
  }
71
81
  if (file !== root && !file.startsWith(root + sep)) {
72
- res.writeHead(403).end("forbidden");
82
+ head(res, 403).end("forbidden");
73
83
  return;
74
84
  }
75
85
  try {
76
86
  if ((await stat(file)).isDirectory()) {
77
87
  if (!pathname.endsWith("/")) {
78
- res.writeHead(301, { location: pathname + "/" }).end();
88
+ head(res, 301, { location: pathname + "/" }).end();
79
89
  return;
80
90
  }
81
91
  file = join(file, "index.html");
@@ -84,7 +94,7 @@ data: ${JSON.stringify(data)}
84
94
  const type = CONTENT_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream";
85
95
  const html = type.startsWith("text/html") && isDocument(req);
86
96
  const payload = html ? Buffer.from(injectClient(body.toString("utf8"))) : body;
87
- res.writeHead(200, {
97
+ head(res, 200, {
88
98
  "content-type": type,
89
99
  "content-length": payload.byteLength,
90
100
  "cache-control": "no-store"
@@ -92,18 +102,18 @@ data: ${JSON.stringify(data)}
92
102
  });
93
103
  res.end(payload);
94
104
  } catch {
95
- res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("not found");
105
+ head(res, 404, { "content-type": "text/plain; charset=utf-8" }).end("not found");
96
106
  }
97
107
  };
98
108
  const server = createServer((req, res) => {
99
109
  const pathname = (req.url ?? "/").split(/[?#]/)[0];
100
110
  if (pathname === CLIENT_URL) {
101
- res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" });
111
+ head(res, 200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" });
102
112
  res.end(CLIENT);
103
113
  return;
104
114
  }
105
115
  if (pathname === EVENTS_URL) {
106
- res.writeHead(200, {
116
+ head(res, 200, {
107
117
  "content-type": "text/event-stream",
108
118
  "cache-control": "no-store",
109
119
  connection: "keep-alive"
@@ -117,29 +127,96 @@ data: ${JSON.stringify(data)}
117
127
  });
118
128
  const ignored = (rel) => rel.split(sep).some((part) => part.startsWith(".") || part === "node_modules");
119
129
  const pending = /* @__PURE__ */ new Map();
120
- const changed = async (rel) => {
121
- const file = join(root, rel);
130
+ const cwd = process.cwd();
131
+ const entries = (options.watch ?? []).map((entry) => ({
132
+ patterns: [entry.pattern].flat(),
133
+ fn: entry.fn,
134
+ batch: /* @__PURE__ */ new Set(),
135
+ running: false
136
+ }));
137
+ const claims = (entry, file) => entry.patterns.some(
138
+ (pattern) => matchesGlob(isAbsolute(pattern) ? posix(file) : posix(relative(cwd, file)), pattern)
139
+ );
140
+ const collect = (entry, file) => {
141
+ entry.batch.add(file);
142
+ clearTimeout(entry.timer);
143
+ entry.timer = setTimeout(() => void fire(entry), 30);
144
+ };
145
+ const fire = async (entry) => {
146
+ if (entry.running || !entry.batch.size) return;
147
+ const files = [...entry.batch];
148
+ entry.batch.clear();
149
+ entry.running = true;
150
+ try {
151
+ await entry.fn?.(files);
152
+ } catch (error) {
153
+ console.error(`jq79 dev: watch handler for ${entry.patterns.join(", ")} failed
154
+ `, error);
155
+ } finally {
156
+ entry.running = false;
157
+ if (entry.batch.size) void fire(entry);
158
+ }
159
+ };
160
+ const changed = async (file, dir) => {
161
+ const rel = relative(root, file);
162
+ const served = !!rel && !rel.startsWith("..");
122
163
  let src = null;
123
164
  try {
124
165
  if ((await stat(file)).isDirectory()) return;
125
- if (rel.endsWith(".html")) src = await readFile(file, "utf8");
166
+ if (served && file.endsWith(".html")) src = await readFile(file, "utf8");
126
167
  } catch {
127
- if (rel === basename(root)) return;
168
+ if (file === join(dir, basename(dir))) return;
169
+ }
170
+ const claimed = entries.filter((entry) => claims(entry, file));
171
+ claimed.forEach((entry) => entry.fn && collect(entry, file));
172
+ if (served) {
173
+ const url = "/" + posix(rel);
174
+ if (src === null) send("reload", { url });
175
+ else send("update", { url, src });
176
+ return;
128
177
  }
129
- const url = "/" + posix(rel);
130
- if (src === null) send("reload", { url });
131
- else send("update", { url, src });
178
+ if (claimed.some((entry) => !entry.fn)) send("reload", { url: posix(rel) });
132
179
  };
133
- const watcher = watch(root, { recursive: true }, (_event, filename) => {
134
- if (!filename) return;
135
- const rel = relative(root, resolve(root, filename.toString()));
136
- if (!rel || rel.startsWith("..") || ignored(rel)) return;
137
- clearTimeout(pending.get(rel));
138
- pending.set(rel, setTimeout(() => {
139
- pending.delete(rel);
140
- void changed(rel);
180
+ const queue = (file, dir) => {
181
+ clearTimeout(pending.get(file));
182
+ pending.set(file, setTimeout(() => {
183
+ pending.delete(file);
184
+ void changed(file, dir);
141
185
  }, 30));
142
- });
186
+ };
187
+ const watchers = [];
188
+ const watchTree = (dir, only) => {
189
+ watchers.push(watch(dir, { recursive: !only }, (_event, filename) => {
190
+ if (!filename) return;
191
+ const file = resolve(dir, filename.toString());
192
+ const rel = relative(dir, file);
193
+ if (!rel || rel.startsWith("..")) return;
194
+ if (only ? rel !== only : ignored(rel)) return;
195
+ queue(file, dir);
196
+ }));
197
+ };
198
+ const literalHead = (pattern) => {
199
+ const parts = pattern.split("/");
200
+ const magic = parts.findIndex((part) => /[*?[\]{}!]/.test(part));
201
+ return resolve(magic === -1 ? pattern : parts.slice(0, magic).join("/") || ".");
202
+ };
203
+ const extra = /* @__PURE__ */ new Map();
204
+ for (const entry of entries) {
205
+ for (const [i, pattern] of entry.patterns.entries()) {
206
+ const head2 = literalHead(pattern);
207
+ const info = await stat(head2).catch(() => null);
208
+ if (!info) throw new Error(`jq79 dev: nothing to watch at ${head2}`);
209
+ if (info.isDirectory() && head2 === resolve(pattern)) {
210
+ entry.patterns[i] = pattern.replace(/\/+$/, "") + "/**";
211
+ }
212
+ const real = await realpath(head2);
213
+ if (real === root || real.startsWith(root + sep)) continue;
214
+ const watched = info.isDirectory() ? { dir: real } : { dir: dirname(real), only: basename(real) };
215
+ extra.set(`${watched.dir}\0${watched.only ?? ""}`, watched);
216
+ }
217
+ }
218
+ watchTree(root);
219
+ extra.forEach(({ dir, only }) => watchTree(dir, only));
143
220
  const heartbeat = setInterval(() => clients.forEach((client) => client.write(": ping\n\n")), 3e4);
144
221
  heartbeat.unref();
145
222
  await new Promise((done, fail) => {
@@ -152,8 +229,9 @@ data: ${JSON.stringify(data)}
152
229
  port,
153
230
  close: () => new Promise((done) => {
154
231
  clearInterval(heartbeat);
155
- watcher.close();
232
+ watchers.forEach((watcher) => watcher.close());
156
233
  pending.forEach(clearTimeout);
234
+ entries.forEach((entry) => clearTimeout(entry.timer));
157
235
  clients.forEach((client) => client.end());
158
236
  clients.clear();
159
237
  server.close(() => done());
package/dist/dev.js.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, extname, join, 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}\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 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 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 res.writeHead(400).end(\"bad request\")\n return\n }\n if (file !== root && !file.startsWith(root + sep)) {\n res.writeHead(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 res.writeHead(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 res.writeHead(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 res.writeHead(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 res.writeHead(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 res.writeHead(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 const changed = async (rel: string) => {\n const file = join(root, rel)\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 (rel.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 // the watched directory *itself* under its own basename, which resolves to\n // a path inside it that does not exist\n if (rel === basename(root)) return\n }\n\n // the url is the one the component was served from, because that is what the\n // 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 }\n\n const watcher: FSWatcher = watch(root, { recursive: true }, (_event, filename) => {\n if (!filename) return\n const rel = relative(root, resolve(root, filename.toString()))\n if (!rel || rel.startsWith(\"..\") || ignored(rel)) return\n\n clearTimeout(pending.get(rel))\n pending.set(rel, setTimeout(() => {\n pending.delete(rel)\n void changed(rel)\n }, 30))\n })\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 watcher.close()\n pending.forEach(clearTimeout)\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":[]}
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"]}