@davidsouther/jiffies 2026.4.1 → 2026.24.0

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.
Files changed (121) hide show
  1. package/README.md +0 -3
  2. package/package.json +11 -6
  3. package/src/404.html +1 -1
  4. package/src/components/accordion.ts +25 -0
  5. package/src/components/alert.ts +47 -0
  6. package/src/components/card.ts +54 -0
  7. package/src/components/children.ts +11 -0
  8. package/src/components/form.ts +25 -0
  9. package/src/components/index.ts +22 -0
  10. package/src/components/link.ts +22 -0
  11. package/src/components/modal.ts +15 -0
  12. package/src/components/nav.ts +42 -0
  13. package/src/components/property.ts +32 -0
  14. package/src/components/tabs.ts +82 -0
  15. package/src/components/virtual_scroll.ts +1 -1
  16. package/src/dom/README.md +7 -2
  17. package/src/dom/SKILL.md +201 -0
  18. package/src/dom/dom.ts +185 -41
  19. package/src/dom/fc.ts +3 -2
  20. package/src/dom/form/form.app.ts +35 -41
  21. package/src/dom/form/form.ts +79 -10
  22. package/src/dom/form/index.html +2 -2
  23. package/src/dom/hydrate.ts +206 -0
  24. package/src/dom/navigation/index.ts +349 -0
  25. package/src/dom/render.ts +41 -0
  26. package/src/dom/svg.ts +6 -2
  27. package/src/fs_node.ts +2 -2
  28. package/src/log.ts +154 -2
  29. package/src/server/http/response.ts +6 -3
  30. package/src/server/http/sitemap.ts +10 -34
  31. package/src/server/http/static.ts +0 -2
  32. package/src/server/live-reload.ts +208 -0
  33. package/src/server/main.ts +14 -7
  34. package/src/server/ws/frame.ts +36 -0
  35. package/src/server/ws/handshake.ts +42 -0
  36. package/src/server/ws/index.ts +100 -0
  37. package/src/ssg/bundle.ts +85 -0
  38. package/src/ssg/copy-public.ts +44 -0
  39. package/src/ssg/discover.ts +143 -0
  40. package/src/ssg/main.ts +168 -0
  41. package/src/ssg/rewrite.ts +18 -0
  42. package/src/ssg/ssg.ts +134 -0
  43. package/src/components/test.ts +0 -5
  44. package/src/components/virtual_scroll.test.ts +0 -30
  45. package/src/context.test.ts +0 -58
  46. package/src/context.ts +0 -67
  47. package/src/diff.test.ts +0 -48
  48. package/src/dom/fc.test.ts +0 -43
  49. package/src/dom/form/form.test.ts +0 -0
  50. package/src/dom/html.test.ts +0 -74
  51. package/src/dom/observable.test.ts +0 -43
  52. package/src/dom/test.ts +0 -11
  53. package/src/equal.test.ts +0 -23
  54. package/src/flags.test.ts +0 -43
  55. package/src/flags.ts +0 -53
  56. package/src/fs.test.ts +0 -106
  57. package/src/fs_win.test.ts +0 -11
  58. package/src/generator.test.ts +0 -27
  59. package/src/index.html +0 -82
  60. package/src/is_browser.js +0 -1
  61. package/src/lock.test.ts +0 -17
  62. package/src/observable/observable.test.ts +0 -73
  63. package/src/pico/_variables.scss +0 -66
  64. package/src/pico/components/_accordion.scss +0 -112
  65. package/src/pico/components/_button-group.scss +0 -51
  66. package/src/pico/components/_card.scss +0 -47
  67. package/src/pico/components/_dropdown.scss +0 -203
  68. package/src/pico/components/_modal.scss +0 -181
  69. package/src/pico/components/_nav.scss +0 -79
  70. package/src/pico/components/_progress.scss +0 -70
  71. package/src/pico/components/_property.scss +0 -34
  72. package/src/pico/content/_button.scss +0 -152
  73. package/src/pico/content/_code.scss +0 -63
  74. package/src/pico/content/_embedded.scss +0 -0
  75. package/src/pico/content/_form-alt.scss +0 -276
  76. package/src/pico/content/_form.scss +0 -259
  77. package/src/pico/content/_misc.scss +0 -0
  78. package/src/pico/content/_table.scss +0 -28
  79. package/src/pico/content/_toggle.scss +0 -132
  80. package/src/pico/content/_typography.scss +0 -232
  81. package/src/pico/layout/_container.scss +0 -40
  82. package/src/pico/layout/_document.scss +0 -0
  83. package/src/pico/layout/_flex.scss +0 -46
  84. package/src/pico/layout/_grid.scss +0 -24
  85. package/src/pico/layout/_scroller.scss +0 -16
  86. package/src/pico/layout/_section.scss +0 -8
  87. package/src/pico/layout/_sectioning.scss +0 -55
  88. package/src/pico/pico.scss +0 -60
  89. package/src/pico/reset/_accessibility.scss +0 -34
  90. package/src/pico/reset/_button.scss +0 -17
  91. package/src/pico/reset/_code.scss +0 -15
  92. package/src/pico/reset/_document.scss +0 -48
  93. package/src/pico/reset/_embedded.scss +0 -39
  94. package/src/pico/reset/_form.scss +0 -97
  95. package/src/pico/reset/_misc.scss +0 -23
  96. package/src/pico/reset/_nav.scss +0 -5
  97. package/src/pico/reset/_progress.scss +0 -4
  98. package/src/pico/reset/_table.scss +0 -8
  99. package/src/pico/reset/_typography.scss +0 -25
  100. package/src/pico/themes/default/_colors.scss +0 -65
  101. package/src/pico/themes/default/_dark.scss +0 -148
  102. package/src/pico/themes/default/_light.scss +0 -149
  103. package/src/pico/themes/default/_styles.scss +0 -272
  104. package/src/pico/themes/default.scss +0 -34
  105. package/src/pico/utilities/_accessibility.scss +0 -3
  106. package/src/pico/utilities/_loading.scss +0 -52
  107. package/src/pico/utilities/_reduce-motion.scss +0 -27
  108. package/src/pico/utilities/_tooltip.scss +0 -101
  109. package/src/result.test.ts +0 -101
  110. package/src/scope/describe.ts +0 -81
  111. package/src/scope/display/console.ts +0 -26
  112. package/src/scope/display/dom.ts +0 -36
  113. package/src/scope/display/junit.ts +0 -64
  114. package/src/scope/execute.ts +0 -110
  115. package/src/scope/expect.ts +0 -169
  116. package/src/scope/fix.ts +0 -30
  117. package/src/scope/index.ts +0 -11
  118. package/src/scope/scope.ts +0 -21
  119. package/src/scope/state.ts +0 -13
  120. package/src/test.mjs +0 -33
  121. package/src/test_all.ts +0 -35
@@ -0,0 +1,41 @@
1
+ import { XHTML_NAMESPACE_URI } from "./dom.ts";
2
+
3
+ export interface DocumentOptions {
4
+ body: Node | Node[];
5
+ head?: Node | Node[];
6
+ lang?: string;
7
+ doctype?: string;
8
+ }
9
+
10
+ export function renderToString(node: Node | Node[]): string {
11
+ if (Array.isArray(node)) return node.map(renderToString).join("");
12
+ if (node.nodeType === 1) {
13
+ const el = node as Element;
14
+ const html = el.outerHTML;
15
+ const ns = el.namespaceURI;
16
+ if (ns && ns !== XHTML_NAMESPACE_URI && !html.includes("xmlns=")) {
17
+ return html.replace(/^<([^\s>]+)/, `<$1 xmlns="${ns}"`);
18
+ }
19
+ return html;
20
+ }
21
+ if (node.nodeType === 11)
22
+ return Array.from(node.childNodes).map(renderToString).join("");
23
+ if (node.nodeType === 3) {
24
+ return (node.textContent ?? "")
25
+ .replace(/&/g, "&amp;")
26
+ .replace(/</g, "&lt;")
27
+ .replace(/>/g, "&gt;");
28
+ }
29
+ return "";
30
+ }
31
+
32
+ export function renderDocument({
33
+ body,
34
+ head,
35
+ lang = "en",
36
+ doctype = "<!doctype html>",
37
+ }: DocumentOptions): string {
38
+ const headStr = head != null ? renderToString(head) : "";
39
+ const bodyStr = renderToString(body);
40
+ return `${doctype}<html lang="${lang}"><head>${headStr}</head><body>${bodyStr}</body></html>`;
41
+ }
package/src/dom/svg.ts CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  type DenormChildren,
4
4
  SVG_NAMESPACE_URI,
5
5
  up,
6
- } from "./dom.js";
6
+ } from "./dom.ts";
7
7
 
8
8
  const makeSVGElement =
9
9
  <K extends keyof SVGElementTagNameMap>(name: K) =>
@@ -11,7 +11,11 @@ const makeSVGElement =
11
11
  attrs?: DenormAttrs<SVGElementTagNameMap[K]>,
12
12
  ...children: DenormChildren[]
13
13
  ) =>
14
- up(document.createElementNS(SVG_NAMESPACE_URI, name), attrs, ...children);
14
+ up(
15
+ window.document.createElementNS(SVG_NAMESPACE_URI, name),
16
+ attrs,
17
+ ...children,
18
+ );
15
19
 
16
20
  export const a = makeSVGElement("a");
17
21
  export const animate = makeSVGElement("animate");
package/src/fs_node.ts CHANGED
@@ -34,8 +34,8 @@ export class NodeFileSystemAdapter implements FileSystemAdapter {
34
34
  readdir(path: string): Promise<string[]> {
35
35
  return readdir(path);
36
36
  }
37
- mkdir(path: string): Promise<void> {
38
- return mkdir(path);
37
+ async mkdir(path: string): Promise<void> {
38
+ await mkdir(path, { recursive: true });
39
39
  }
40
40
  async scandir(path: string): Promise<Stats[]> {
41
41
  return Promise.all(
package/src/log.ts CHANGED
@@ -72,7 +72,7 @@ function findSource() {
72
72
  return atLines[2]?.trim().slice("at ".length) ?? "(unknown)";
73
73
  }
74
74
 
75
- type LoggerFormatFn = <
75
+ export type LoggerFormatFn = <
76
76
  D extends {
77
77
  name: string;
78
78
  prefix: string;
@@ -84,6 +84,156 @@ type LoggerFormatFn = <
84
84
  data: D,
85
85
  ) => string;
86
86
 
87
+ // ── prettyLogFormatter ──────────────────────────────────────────────────────
88
+ // A TTY-aware formatter: compact, color-and-glyph-coded human lines on a
89
+ // terminal, byte-identical JSON.stringify when piped/redirected so machine
90
+ // tooling is unchanged. See docs/developer/2026-06-09-A-log-formatter/design.md.
91
+
92
+ // Raw ANSI escapes; no color/log runtime dependency in this library.
93
+ const ANSI = {
94
+ reset: "\x1b[0m",
95
+ dim: "\x1b[2m",
96
+ bold: "\x1b[1m",
97
+ green: "\x1b[32m",
98
+ yellow: "\x1b[33m",
99
+ red: "\x1b[31m",
100
+ cyan: "\x1b[36m",
101
+ white: "\x1b[37m",
102
+ } as const;
103
+ type AnsiCode = keyof typeof ANSI;
104
+
105
+ // HH:MM:SS.mmm sliced from a UTC ISO 8601 string. Timezone-stable by
106
+ // construction: it never reads local-time accessors, so output depends only on
107
+ // the ISO input, not the runner's TZ.
108
+ function shortClock(iso: string): string {
109
+ return iso.slice(11, 23);
110
+ }
111
+
112
+ // Glyph + color per severity, keyed on the numeric level (DEBUG 1, INFO 2,
113
+ // WARN 3, ERROR 4). Fixed single-column glyph so messages align vertically.
114
+ function levelGlyph(level: number): { glyph: string; color: AnsiCode } {
115
+ if (level <= LEVEL.DEBUG) return { glyph: "·", color: "dim" };
116
+ if (level === LEVEL.INFO) return { glyph: "ℹ", color: "green" };
117
+ if (level === LEVEL.WARN) return { glyph: "⚠", color: "yellow" };
118
+ return { glyph: "✖", color: "red" };
119
+ }
120
+
121
+ function methodColor(method: string): AnsiCode {
122
+ switch (method) {
123
+ case "GET":
124
+ return "cyan";
125
+ case "POST":
126
+ return "green";
127
+ case "PUT":
128
+ case "PATCH":
129
+ return "yellow";
130
+ case "DELETE":
131
+ return "red";
132
+ default:
133
+ return "white";
134
+ }
135
+ }
136
+
137
+ function statusColor(status: number): AnsiCode {
138
+ if (status >= 500) return "red";
139
+ if (status >= 400) return "yellow";
140
+ if (status >= 300) return "cyan";
141
+ return "green";
142
+ }
143
+
144
+ // Fields that are pure metadata noise on a human terminal: `name`/`level` are
145
+ // constant or duplicate `prefix`, and `source` resolves to the info() wrapper.
146
+ // `message` is rendered on its own, never echoed into the trailing tail.
147
+ const HUMAN_DROP = new Set(["name", "prefix", "level", "source", "message"]);
148
+
149
+ export interface PrettyLogOptions {
150
+ /** Render colored, human-shaped lines. Default: !!process.stdout.isTTY. */
151
+ tty?: boolean;
152
+ /** Emit ANSI color escapes. Default: same as `tty`. */
153
+ color?: boolean;
154
+ /** Injectable clock for deterministic timestamps in tests. Default: new Date. */
155
+ now?: () => Date;
156
+ }
157
+
158
+ /**
159
+ * Factory returning a {@link LoggerFormatFn} that is pretty + colored on a TTY
160
+ * and falls back to `JSON.stringify` when not. Options resolve once at
161
+ * construction (`tty` reads `process.stdout.isTTY`), so production piped output
162
+ * stays JSON; tests inject `tty`/`color`/`now` for a deterministic colorless
163
+ * layout assertable without a real terminal.
164
+ */
165
+ export function prettyLogFormatter(
166
+ options: PrettyLogOptions = {},
167
+ ): LoggerFormatFn {
168
+ const tty = options.tty ?? !!process.stdout.isTTY;
169
+ const color = options.color ?? tty;
170
+ const now = options.now ?? (() => new Date());
171
+ const paint = (code: AnsiCode, s: string): string =>
172
+ color ? `${ANSI[code]}${s}${ANSI.reset}` : s;
173
+
174
+ return <
175
+ D extends {
176
+ name: string;
177
+ prefix: string;
178
+ level: number;
179
+ message: string;
180
+ source: string;
181
+ },
182
+ >(
183
+ data: D,
184
+ ): string => {
185
+ if (!tty) return JSON.stringify(data);
186
+
187
+ const record = data as unknown as Record<string, unknown>;
188
+ const { glyph, color: glyphColor } = levelGlyph(data.level);
189
+ const mark = paint(glyphColor, glyph);
190
+
191
+ // Access-log shape: `<glyph> <clock> <METHOD> <path> <client> [status] [ms]`.
192
+ // Clock comes from the request's `when` ISO (UTC slice), not now().
193
+ if (data.message === "Request") {
194
+ const when = typeof record.when === "string" ? record.when : "";
195
+ const how = typeof record.how === "string" ? record.how : "";
196
+ const space = how.indexOf(" ");
197
+ const method = space === -1 ? how : how.slice(0, space);
198
+ const path = space === -1 ? "" : how.slice(space + 1);
199
+ const segments = [
200
+ mark,
201
+ paint("dim", shortClock(when || now().toISOString())),
202
+ paint(methodColor(method), method),
203
+ paint("bold", path),
204
+ ];
205
+ if (record.who !== undefined) {
206
+ segments.push(paint("dim", String(record.who)));
207
+ }
208
+ if (record.status !== undefined) {
209
+ segments.push(
210
+ paint(statusColor(Number(record.status)), String(record.status)),
211
+ );
212
+ }
213
+ if (record.ms !== undefined) {
214
+ segments.push(paint("dim", `${record.ms}ms`));
215
+ }
216
+ return segments.filter((s) => s !== "").join(" ");
217
+ }
218
+
219
+ // Generic shape: `<glyph> <clock> <message> <dim key=value …>`.
220
+ const tail = Object.entries(record)
221
+ .filter(([key]) => !HUMAN_DROP.has(key))
222
+ .map(([key, value]) =>
223
+ paint(
224
+ "dim",
225
+ `${key}=${typeof value === "string" ? value : JSON.stringify(value)}`,
226
+ ),
227
+ );
228
+ return [
229
+ mark,
230
+ paint("dim", shortClock(now().toISOString())),
231
+ paint("bold", data.message),
232
+ ...tail,
233
+ ].join(" ");
234
+ };
235
+ }
236
+
87
237
  export function getLogger(
88
238
  name: string,
89
239
  args: LoggerFormatFn | { format?: LoggerFormatFn; console?: Console } = {
@@ -132,7 +282,9 @@ export function getLogger(
132
282
  return logger as Logger;
133
283
  }
134
284
 
135
- export const DEFAULT_LOGGER = getLogger("default");
285
+ export const DEFAULT_LOGGER = getLogger("default", {
286
+ format: prettyLogFormatter(),
287
+ });
136
288
 
137
289
  export function debug(message: Display, data?: object) {
138
290
  if (data) DEFAULT_LOGGER.debug(message, data);
@@ -2,7 +2,7 @@ import type { Stats } from "node:fs";
2
2
  import * as fs from "node:fs/promises";
3
3
  import type { StaticResponse } from ".";
4
4
 
5
- const MIME_TYPES: Record<string, string> = {
5
+ const MIME_TYPES = {
6
6
  js: "text/javascript",
7
7
  ts: "text/javascript",
8
8
  json: "text/javascript",
@@ -16,13 +16,16 @@ const MIME_TYPES: Record<string, string> = {
16
16
  ttf: "application/font-ttf",
17
17
  woff: "application/font-woff",
18
18
  woff2: "application/font-woff2",
19
- };
19
+ } as const;
20
20
 
21
21
  const mime = (basename: string) => {
22
22
  const extension = basename
23
23
  .substring(basename.lastIndexOf(".") + 1)
24
24
  .toLowerCase();
25
- return MIME_TYPES[extension] ?? "application/octet-stream";
25
+ return (
26
+ MIME_TYPES[extension as keyof typeof MIME_TYPES] ??
27
+ "application/octet-stream"
28
+ );
26
29
  };
27
30
 
28
31
  export const fileResponse =
@@ -4,41 +4,17 @@ import { info } from "../../log.ts";
4
4
  import type { MiddlewareFactory } from "./index.ts";
5
5
  import { contentResponse } from "./response.ts";
6
6
 
7
- const findSiteMap = async (root: string, prefix = root) => {
8
- if (root.startsWith("node_modules")) {
9
- return [];
10
- }
11
- const children = (await fs.readdir(root, { withFileTypes: true })).map(
12
- async (entry): Promise<string[]> => {
13
- const next = path
14
- .join(root, entry.name)
15
- // Normalize separators for web
16
- .replaceAll(path.sep, "/");
17
- if (entry.isFile()) {
18
- if (entry.name === "index.html") {
19
- const index = next.replace(prefix, "");
20
- info("Adding to sitemap", { index });
21
- return [index];
22
- }
23
- } else if (entry.isDirectory()) {
24
- if (entry.name.startsWith(".")) {
25
- return [];
26
- }
27
- const flattened = (
28
- await Promise.all(await findSiteMap(next, prefix))
29
- ).flat();
30
- return flattened;
31
- }
32
- return [];
33
- },
34
- );
35
- return children;
36
- };
37
-
38
7
  export const sitemap: MiddlewareFactory = async ({ root }) => {
39
- const apps = await (await Promise.all(await findSiteMap(root)))
40
- .flat()
41
- .filter((a) => a !== undefined);
8
+ const apps: string[] = [];
9
+ for await (const file of fs.glob("**/index.html", {
10
+ cwd: root,
11
+ withFileTypes: false,
12
+ exclude: (name) => name === "node_modules",
13
+ })) {
14
+ const index = `/${file.replaceAll(path.sep, "/")}`;
15
+ info("Adding to sitemap", { index });
16
+ apps.push(index);
17
+ }
42
18
  return async (req) => {
43
19
  if ((req.url ?? "").endsWith("sitemap.json")) {
44
20
  return contentResponse(JSON.stringify(apps), "application/json");
@@ -14,8 +14,6 @@ export const staticFileServer: MiddlewareFactory =
14
14
  ? url.pathname.replace(scope[0], scope[1])
15
15
  : url.pathname;
16
16
  const filename = path.join(root, pathname);
17
- // Expand url with found scope
18
- console.log(scope, req.url, filename);
19
17
 
20
18
  try {
21
19
  const stat = await fs.stat(filename);
@@ -0,0 +1,208 @@
1
+ import { type FSWatcher, watch } from "node:fs";
2
+ import * as fs from "node:fs/promises";
3
+ import type { AddressInfo } from "node:net";
4
+ import * as path from "node:path";
5
+ import { type MiddlewareFactory, makeServer } from "./http/index.ts";
6
+ import { contentResponse } from "./http/response.ts";
7
+ import { attachWebSocketServer, type WebSocketHub } from "./ws/index.ts";
8
+
9
+ // Live-reload feature for a (optionally watched) jiffies static dev server.
10
+ // Shaped as stock jiffies middlewares + a generic watcher composed with the
11
+ // ws/ transport, so a downstream server adds `--port`/`--host`/`--watch` via
12
+ // jiffiesArgs and injects only its own build command and watched paths.
13
+ // See .agents/developer/2026-06-10-A-upstream-livereload-client/design.md.
14
+
15
+ // The injected client snippet opens a WebSocket to `path` and reloads the page
16
+ // on any pushed message. On close it reconnects with a short fixed backoff,
17
+ // which recovers from a server restart or a socket dropped during a rebuild.
18
+ // Errors are swallowed; the close handler drives the reconnect. `path` is a
19
+ // parameter so the caller owns the reload route.
20
+ export function webSocketReloadClient(path: string): string {
21
+ return `<script>(function(){function connect(){var ws=new WebSocket((location.protocol==="https:"?"wss://":"ws://")+location.host+${JSON.stringify(
22
+ path,
23
+ )});ws.onmessage=function(){location.reload();};ws.onerror=function(){};ws.onclose=function(){setTimeout(connect,1000);};}connect();})();</script>`;
24
+ }
25
+
26
+ // Injects the reload snippet into HTML navigations. Mirrors jiffies findIndex
27
+ // (walk up for index.html) for extensionless paths; serves .html directly.
28
+ // Asset/CSS/TS requests fall through to the base middlewares.
29
+ export const htmlInjector =
30
+ (snippet: string): MiddlewareFactory =>
31
+ async ({ root }) =>
32
+ async (req) => {
33
+ const { pathname } = new URL(req.url ?? "/", "http://localhost");
34
+ const decoded = decodeURIComponent(pathname);
35
+ const ext = path.extname(decoded);
36
+ let target: string | undefined;
37
+ if (ext === ".html") {
38
+ target = path.join(root, decoded);
39
+ } else if (ext === "") {
40
+ let dir = path.join(root, decoded);
41
+ while (dir.startsWith(root)) {
42
+ const idx = path.join(dir, "index.html");
43
+ try {
44
+ if ((await fs.stat(idx)).isFile()) {
45
+ target = idx;
46
+ break;
47
+ }
48
+ } catch {}
49
+ dir = path.dirname(dir);
50
+ }
51
+ }
52
+ if (!target) return undefined;
53
+ try {
54
+ if (!(await fs.stat(target)).isFile()) return undefined;
55
+ const html = await fs.readFile(target, "utf-8");
56
+ const injected = html.includes("</body>")
57
+ ? html.replace("</body>", `${snippet}</body>`)
58
+ : html + snippet;
59
+ return contentResponse(injected, "text/html");
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ };
64
+
65
+ // Ignores dotfiles and node_modules. The argument is the full resolved path, so
66
+ // callers can compose this with project-specific generated-output paths.
67
+ export const ignoreDefault = (file: string): boolean =>
68
+ /(^|[\\/])\./.test(file) || file.includes("node_modules");
69
+
70
+ // Watches dirs recursively, coalescing bursts into a single trailing call. The
71
+ // ignore predicate receives the resolved absolute path of each changed file, so
72
+ // it can exclude build outputs written back into a watched dir (which would
73
+ // otherwise retrigger the build endlessly).
74
+ export function watchTree(
75
+ dirs: string[],
76
+ onChange: () => void,
77
+ {
78
+ debounceMs = 150,
79
+ ignore = ignoreDefault,
80
+ }: { debounceMs?: number; ignore?: (file: string) => boolean } = {},
81
+ ): { close: () => void } {
82
+ let timer: ReturnType<typeof setTimeout> | undefined;
83
+ const watchers: FSWatcher[] = [];
84
+ for (const dir of dirs) {
85
+ try {
86
+ watchers.push(
87
+ watch(dir, { recursive: true }, (_event, filename) => {
88
+ if (!filename) return;
89
+ if (ignore(path.join(dir, filename.toString()))) return;
90
+ if (timer) clearTimeout(timer);
91
+ timer = setTimeout(onChange, debounceMs);
92
+ }),
93
+ );
94
+ } catch {
95
+ // Directory may not exist; skip it.
96
+ }
97
+ }
98
+ return {
99
+ close: () => {
100
+ if (timer) clearTimeout(timer);
101
+ for (const w of watchers) w.close();
102
+ },
103
+ };
104
+ }
105
+
106
+ // Serializes rebuilds: a change during a build sets a dirty flag that triggers
107
+ // exactly one more rebuild afterward. `onBuilt` runs once per successful rebuild
108
+ // (each time the inner rebuild resolves true), letting the caller broadcast a
109
+ // reload without the client needing a queryable build id.
110
+ export function createRebuilder(
111
+ rebuild: () => Promise<boolean>,
112
+ onBuilt?: () => void,
113
+ ): {
114
+ run: () => Promise<void>;
115
+ trigger: () => void;
116
+ } {
117
+ let building = false;
118
+ let dirty = false;
119
+ const run = async (): Promise<void> => {
120
+ if (building) {
121
+ dirty = true;
122
+ return;
123
+ }
124
+ building = true;
125
+ try {
126
+ do {
127
+ dirty = false;
128
+ if (await rebuild()) onBuilt?.();
129
+ } while (dirty);
130
+ } finally {
131
+ building = false;
132
+ }
133
+ };
134
+ return { run, trigger: () => void run() };
135
+ }
136
+
137
+ export interface WatchServerOptions {
138
+ root: string;
139
+ watchDirs: string[];
140
+ rebuild: () => Promise<boolean>;
141
+ debounceMs?: number;
142
+ // Resolved paths to exclude from watching (e.g. build outputs written back
143
+ // into a watched dir). Defaults to ignoreDefault when omitted.
144
+ ignore?: (file: string) => boolean;
145
+ port?: number;
146
+ host?: string;
147
+ // The reload route. Defaults to "/__livereload". The injected snippet and the
148
+ // ws hub are both bound to this path.
149
+ reloadPath?: string;
150
+ }
151
+
152
+ // Initial build, then serve `root` with the injector middleware, attach a
153
+ // WebSocket hub at reloadPath, and watch `watchDirs`. Each successful rebuild
154
+ // broadcasts "reload" to connected clients. Returns the bound port and a
155
+ // close() for clean shutdown. The hub is late-bound: the initial build runs
156
+ // before it exists, so its onBuilt broadcast is a no-op (no clients yet).
157
+ export async function startWatchServer(
158
+ opts: WatchServerOptions,
159
+ ): Promise<{ port: number; close: () => Promise<void> }> {
160
+ const reloadPath = opts.reloadPath ?? "/__livereload";
161
+ let hub: WebSocketHub | undefined;
162
+ const rebuilder = createRebuilder(opts.rebuild, () =>
163
+ hub?.broadcast("reload"),
164
+ );
165
+ await rebuilder.run();
166
+ const snippet = webSocketReloadClient(reloadPath);
167
+ const server = await makeServer({ root: opts.root }, [htmlInjector(snippet)]);
168
+ const watchOpts: { debounceMs?: number; ignore?: (file: string) => boolean } =
169
+ {};
170
+ if (opts.debounceMs !== undefined) watchOpts.debounceMs = opts.debounceMs;
171
+ if (opts.ignore !== undefined) watchOpts.ignore = opts.ignore;
172
+ const watcher = watchTree(opts.watchDirs, rebuilder.trigger, watchOpts);
173
+ await new Promise<void>((resolve) =>
174
+ server.listen(opts.port ?? 8080, opts.host ?? "127.0.0.1", resolve),
175
+ );
176
+ hub = attachWebSocketServer(server, { path: reloadPath });
177
+ const address = server.address() as AddressInfo;
178
+ return {
179
+ port: address.port,
180
+ close: () =>
181
+ new Promise<void>((resolve) => {
182
+ hub?.close();
183
+ watcher.close();
184
+ server.close(() => resolve());
185
+ }),
186
+ };
187
+ }
188
+
189
+ export interface JiffiesArgsParams {
190
+ port?: string; // default for --port (default "8080")
191
+ host?: string; // default for --host (default "0.0.0.0")
192
+ }
193
+
194
+ // A parseArgs `options` fragment for launching a (optionally watched) jiffies
195
+ // static server. Spread into a downstream's parseArgs options so it owns the
196
+ // entrypoint and the `values.watch` branch. `params` overrides the port/host
197
+ // defaults; `watch` defaults to false.
198
+ export function jiffiesArgs(params?: JiffiesArgsParams): {
199
+ port: { type: "string"; default: string };
200
+ host: { type: "string"; default: string };
201
+ watch: { type: "boolean"; default: false };
202
+ } {
203
+ return {
204
+ port: { type: "string", default: params?.port ?? "8080" },
205
+ host: { type: "string", default: params?.host ?? "0.0.0.0" },
206
+ watch: { type: "boolean", default: false },
207
+ };
208
+ }
@@ -1,23 +1,30 @@
1
- #!/usr/bin/env node --experimental-loader ../loader.mjs
1
+ #!/usr/bin/env node
2
2
  import * as process from "node:process";
3
3
 
4
4
  import { info } from "../log.ts";
5
5
 
6
6
  info("Starting server", { cwd: process.cwd() });
7
7
 
8
- import { parse } from "../flags.ts";
8
+ import { parseArgs } from "node:util";
9
9
 
10
- const FLAGS = parse(process.argv);
10
+ // Declared up front so an unknown flag fails fast: parseArgs defaults to
11
+ // strict: true, which throws on undeclared options. The throw happens at module
12
+ // top level (before main() listens), so a malformed invocation exits non-zero
13
+ // and never reaches the listening state. With no flags the defaults reproduce
14
+ // the prior bind target (0.0.0.0:8080).
15
+ const { values } = parseArgs({
16
+ options: {
17
+ port: { type: "string", default: "8080" },
18
+ host: { type: "string", default: "0.0.0.0" },
19
+ },
20
+ });
11
21
 
12
22
  import * as path from "node:path";
13
23
  import { makeServer } from "./http/index.ts";
14
24
 
15
25
  async function main() {
16
26
  const server = await makeServer({ root: path.join(process.cwd(), "src") });
17
- server.listen(
18
- FLAGS.asNumber("port", 8080),
19
- FLAGS.asString("host", "0.0.0.0"),
20
- );
27
+ server.listen(Number.parseInt(values.port, 10), values.host);
21
28
  }
22
29
 
23
30
  main();
@@ -0,0 +1,36 @@
1
+ // Encodes `text` as a single unmasked server->client WebSocket text frame:
2
+ // FIN set + opcode 0x1 => first byte 0x81; mask bit clear; 7-bit / 16-bit /
3
+ // 64-bit extended payload-length selection by utf8 byte count. Server-to-client
4
+ // frames are never masked (RFC 6455).
5
+ export function encodeTextFrame(text: string): Buffer {
6
+ const payload = Buffer.from(text, "utf-8");
7
+ const length = payload.length;
8
+ const fin = 0x81; // FIN set, opcode 0x1 (text).
9
+
10
+ let header: Buffer;
11
+ if (length < 126) {
12
+ header = Buffer.from([fin, length]);
13
+ } else if (length <= 0xffff) {
14
+ header = Buffer.alloc(4);
15
+ header[0] = fin;
16
+ header[1] = 0x7e;
17
+ header.writeUInt16BE(length, 2);
18
+ } else {
19
+ header = Buffer.alloc(10);
20
+ header[0] = fin;
21
+ header[1] = 0x7f;
22
+ header.writeBigUInt64BE(BigInt(length), 2);
23
+ }
24
+
25
+ return Buffer.concat([header, payload]);
26
+ }
27
+
28
+ // Reads the opcode nibble of an inbound (client, masked) frame for control-frame
29
+ // handling: 0x1 text, 0x8 close, 0x9 ping, 0xA pong, etc. Returns undefined for a
30
+ // partial/unreadable buffer. Does NOT decode the masked payload.
31
+ export function readOpcode(buffer: Buffer): number | undefined {
32
+ if (buffer.length === 0) {
33
+ return undefined;
34
+ }
35
+ return buffer[0] & 0x0f;
36
+ }
@@ -0,0 +1,42 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { IncomingMessage } from "node:http";
3
+ import type { Duplex } from "node:stream";
4
+
5
+ // The fixed GUID concatenated to the client key before hashing, per RFC 6455.
6
+ const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
7
+
8
+ // base64( SHA-1( key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" ) ), per RFC 6455.
9
+ // Pure function of the client's Sec-WebSocket-Key.
10
+ export function acceptKey(secWebSocketKey: string): string {
11
+ return createHash("sha1")
12
+ .update(secWebSocketKey + WS_GUID)
13
+ .digest("base64");
14
+ }
15
+
16
+ // Validates the upgrade request, writes the "101 Switching Protocols" response
17
+ // (Upgrade: websocket, Connection: Upgrade, Sec-WebSocket-Accept: acceptKey(...))
18
+ // to the raw socket, and returns true. Returns false WITHOUT writing on a
19
+ // malformed upgrade (missing/non-"websocket" Upgrade header or missing
20
+ // Sec-WebSocket-Key), leaving the socket for the caller to destroy.
21
+ export function completeHandshake(
22
+ req: IncomingMessage,
23
+ socket: Duplex,
24
+ ): boolean {
25
+ const key = req.headers["sec-websocket-key"];
26
+ const upgrade = req.headers.upgrade;
27
+ if (typeof key !== "string" || key.length === 0) {
28
+ return false;
29
+ }
30
+ if (typeof upgrade !== "string" || upgrade.toLowerCase() !== "websocket") {
31
+ return false;
32
+ }
33
+
34
+ const response =
35
+ "HTTP/1.1 101 Switching Protocols\r\n" +
36
+ "Upgrade: websocket\r\n" +
37
+ "Connection: Upgrade\r\n" +
38
+ `Sec-WebSocket-Accept: ${acceptKey(key)}\r\n` +
39
+ "\r\n";
40
+ socket.write(response);
41
+ return true;
42
+ }