@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.
- package/README.md +0 -3
- package/package.json +11 -6
- package/src/404.html +1 -1
- package/src/components/accordion.ts +25 -0
- package/src/components/alert.ts +47 -0
- package/src/components/card.ts +54 -0
- package/src/components/children.ts +11 -0
- package/src/components/form.ts +25 -0
- package/src/components/index.ts +22 -0
- package/src/components/link.ts +22 -0
- package/src/components/modal.ts +15 -0
- package/src/components/nav.ts +42 -0
- package/src/components/property.ts +32 -0
- package/src/components/tabs.ts +82 -0
- package/src/components/virtual_scroll.ts +1 -1
- package/src/dom/README.md +7 -2
- package/src/dom/SKILL.md +201 -0
- package/src/dom/dom.ts +185 -41
- package/src/dom/fc.ts +3 -2
- package/src/dom/form/form.app.ts +35 -41
- package/src/dom/form/form.ts +79 -10
- package/src/dom/form/index.html +2 -2
- package/src/dom/hydrate.ts +206 -0
- package/src/dom/navigation/index.ts +349 -0
- package/src/dom/render.ts +41 -0
- package/src/dom/svg.ts +6 -2
- package/src/fs_node.ts +2 -2
- package/src/log.ts +154 -2
- package/src/server/http/response.ts +6 -3
- package/src/server/http/sitemap.ts +10 -34
- package/src/server/http/static.ts +0 -2
- package/src/server/live-reload.ts +208 -0
- package/src/server/main.ts +14 -7
- package/src/server/ws/frame.ts +36 -0
- package/src/server/ws/handshake.ts +42 -0
- package/src/server/ws/index.ts +100 -0
- package/src/ssg/bundle.ts +85 -0
- package/src/ssg/copy-public.ts +44 -0
- package/src/ssg/discover.ts +143 -0
- package/src/ssg/main.ts +168 -0
- package/src/ssg/rewrite.ts +18 -0
- package/src/ssg/ssg.ts +134 -0
- package/src/components/test.ts +0 -5
- package/src/components/virtual_scroll.test.ts +0 -30
- package/src/context.test.ts +0 -58
- package/src/context.ts +0 -67
- package/src/diff.test.ts +0 -48
- package/src/dom/fc.test.ts +0 -43
- package/src/dom/form/form.test.ts +0 -0
- package/src/dom/html.test.ts +0 -74
- package/src/dom/observable.test.ts +0 -43
- package/src/dom/test.ts +0 -11
- package/src/equal.test.ts +0 -23
- package/src/flags.test.ts +0 -43
- package/src/flags.ts +0 -53
- package/src/fs.test.ts +0 -106
- package/src/fs_win.test.ts +0 -11
- package/src/generator.test.ts +0 -27
- package/src/index.html +0 -82
- package/src/is_browser.js +0 -1
- package/src/lock.test.ts +0 -17
- package/src/observable/observable.test.ts +0 -73
- package/src/pico/_variables.scss +0 -66
- package/src/pico/components/_accordion.scss +0 -112
- package/src/pico/components/_button-group.scss +0 -51
- package/src/pico/components/_card.scss +0 -47
- package/src/pico/components/_dropdown.scss +0 -203
- package/src/pico/components/_modal.scss +0 -181
- package/src/pico/components/_nav.scss +0 -79
- package/src/pico/components/_progress.scss +0 -70
- package/src/pico/components/_property.scss +0 -34
- package/src/pico/content/_button.scss +0 -152
- package/src/pico/content/_code.scss +0 -63
- package/src/pico/content/_embedded.scss +0 -0
- package/src/pico/content/_form-alt.scss +0 -276
- package/src/pico/content/_form.scss +0 -259
- package/src/pico/content/_misc.scss +0 -0
- package/src/pico/content/_table.scss +0 -28
- package/src/pico/content/_toggle.scss +0 -132
- package/src/pico/content/_typography.scss +0 -232
- package/src/pico/layout/_container.scss +0 -40
- package/src/pico/layout/_document.scss +0 -0
- package/src/pico/layout/_flex.scss +0 -46
- package/src/pico/layout/_grid.scss +0 -24
- package/src/pico/layout/_scroller.scss +0 -16
- package/src/pico/layout/_section.scss +0 -8
- package/src/pico/layout/_sectioning.scss +0 -55
- package/src/pico/pico.scss +0 -60
- package/src/pico/reset/_accessibility.scss +0 -34
- package/src/pico/reset/_button.scss +0 -17
- package/src/pico/reset/_code.scss +0 -15
- package/src/pico/reset/_document.scss +0 -48
- package/src/pico/reset/_embedded.scss +0 -39
- package/src/pico/reset/_form.scss +0 -97
- package/src/pico/reset/_misc.scss +0 -23
- package/src/pico/reset/_nav.scss +0 -5
- package/src/pico/reset/_progress.scss +0 -4
- package/src/pico/reset/_table.scss +0 -8
- package/src/pico/reset/_typography.scss +0 -25
- package/src/pico/themes/default/_colors.scss +0 -65
- package/src/pico/themes/default/_dark.scss +0 -148
- package/src/pico/themes/default/_light.scss +0 -149
- package/src/pico/themes/default/_styles.scss +0 -272
- package/src/pico/themes/default.scss +0 -34
- package/src/pico/utilities/_accessibility.scss +0 -3
- package/src/pico/utilities/_loading.scss +0 -52
- package/src/pico/utilities/_reduce-motion.scss +0 -27
- package/src/pico/utilities/_tooltip.scss +0 -101
- package/src/result.test.ts +0 -101
- package/src/scope/describe.ts +0 -81
- package/src/scope/display/console.ts +0 -26
- package/src/scope/display/dom.ts +0 -36
- package/src/scope/display/junit.ts +0 -64
- package/src/scope/execute.ts +0 -110
- package/src/scope/expect.ts +0 -169
- package/src/scope/fix.ts +0 -30
- package/src/scope/index.ts +0 -11
- package/src/scope/scope.ts +0 -21
- package/src/scope/state.ts +0 -13
- package/src/test.mjs +0 -33
- 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, "&")
|
|
26
|
+
.replace(/</g, "<")
|
|
27
|
+
.replace(/>/g, ">");
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
40
|
-
|
|
41
|
-
|
|
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
|
+
}
|
package/src/server/main.ts
CHANGED
|
@@ -1,23 +1,30 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
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 {
|
|
8
|
+
import { parseArgs } from "node:util";
|
|
9
9
|
|
|
10
|
-
|
|
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
|
+
}
|