@noy4/docserve 0.1.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/bin.mjs ADDED
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env node
2
+ // docserve — serve a folder of HTML as a card gallery with live reload.
3
+ //
4
+ // docserve [dir] [command] [options]
5
+ //
6
+ // Flow:
7
+ // parse args ──▶ stop / status ──▶ state.json
8
+ // └─ start
9
+ // ├─ single-instance check (readLiveState)
10
+ // ├─ --background ──▶ spawn detached ──▶ wait for state.json
11
+ // └─ foreground ──▶ runServer() (writes state.json on bind)
12
+ import fs from "node:fs"
13
+ import path from "node:path"
14
+ import { exec, spawn } from "node:child_process"
15
+ import { fileURLToPath } from "node:url"
16
+ import { parseArgs } from "node:util"
17
+ import { runServer } from "./server/index.mjs"
18
+ import { clearState, readLiveState } from "./server/state.mjs"
19
+
20
+ const pkg = JSON.parse(fs.readFileSync(new URL("./package.json", import.meta.url), "utf8"))
21
+ const VERSION = pkg.version
22
+ const DEFAULT_PORT = 4242
23
+ const SUBCOMMANDS = ["stop", "status"]
24
+
25
+ const HELP = `
26
+ docserve v${VERSION} — serve a folder of HTML as a card gallery with live reload.
27
+
28
+ Usage:
29
+ docserve [dir] [command] [options]
30
+
31
+ Arguments:
32
+ dir Folder of HTML files to serve (default: current directory)
33
+
34
+ Commands:
35
+ stop Stop the running docserve server
36
+ status Show whether a server is running (exit 1 if none)
37
+
38
+ Options:
39
+ --open Open the gallery in the browser once the port is bound
40
+ --port <number> Port to bind (default: ${DEFAULT_PORT}); falls back +1 up to 20 tries
41
+ --background Start detached; the bound port and pid land in state.json
42
+ -h, --help Show this help
43
+ -v, --version Show version
44
+ `
45
+
46
+ async function main() {
47
+ const args = parseCliArgs()
48
+ if (args.command === "stop") return stopServer()
49
+ if (args.command === "status") return showStatus()
50
+ return startServer(args)
51
+ }
52
+
53
+ function parseCliArgs() {
54
+ let parsed
55
+ try {
56
+ parsed = parseArgs({
57
+ allowPositionals: true,
58
+ options: {
59
+ open: { type: "boolean" },
60
+ port: { type: "string" },
61
+ background: { type: "boolean" },
62
+ help: { type: "boolean", short: "h" },
63
+ version: { type: "boolean", short: "v" },
64
+ },
65
+ })
66
+ } catch (err) {
67
+ console.error(`[docserve] ${err.message}`)
68
+ console.error(HELP)
69
+ process.exit(1)
70
+ }
71
+ if (parsed.values.help) {
72
+ console.log(HELP)
73
+ process.exit(0)
74
+ }
75
+ if (parsed.values.version) {
76
+ console.log(VERSION)
77
+ process.exit(0)
78
+ }
79
+
80
+ let dir = null
81
+ let command = null
82
+ for (const positional of parsed.positionals) {
83
+ if (dir === null && command === null && SUBCOMMANDS.includes(positional)) {
84
+ command = positional
85
+ continue
86
+ }
87
+ if (dir === null) {
88
+ dir = positional
89
+ continue
90
+ }
91
+ if (command === null && SUBCOMMANDS.includes(positional)) {
92
+ command = positional
93
+ continue
94
+ }
95
+ console.error(`[docserve] Unexpected argument "${positional}".`)
96
+ process.exit(1)
97
+ }
98
+
99
+ let port = DEFAULT_PORT
100
+ if (parsed.values.port !== undefined) {
101
+ port = Number(parsed.values.port)
102
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
103
+ console.error(`[docserve] --port expects an integer between 1 and 65535, got "${parsed.values.port}"`)
104
+ process.exit(1)
105
+ }
106
+ }
107
+
108
+ return {
109
+ dir,
110
+ command,
111
+ port,
112
+ open: parsed.values.open ?? false,
113
+ background: parsed.values.background ?? false,
114
+ }
115
+ }
116
+
117
+ function stopServer() {
118
+ const state = readLiveState() // stale state (dead pid) is removed silently
119
+ if (!state) {
120
+ console.log("docserve is not running.")
121
+ return
122
+ }
123
+ try {
124
+ process.kill(state.pid, "SIGTERM")
125
+ } catch {}
126
+ clearState()
127
+ console.log(`Stopped docserve (${state.url})`)
128
+ }
129
+
130
+ function showStatus() {
131
+ const state = readLiveState()
132
+ if (!state) {
133
+ console.error("docserve is not running.")
134
+ process.exit(1)
135
+ }
136
+ console.log(state.url)
137
+ console.log(`docs : ${state.docsDir}`)
138
+ }
139
+
140
+ async function startServer(args) {
141
+ const docsDir = path.resolve(args.dir ?? ".")
142
+ if (!isDirectory(docsDir)) {
143
+ console.error(`[docserve] Directory not found: ${docsDir}`)
144
+ process.exit(1)
145
+ }
146
+
147
+ // Single instance: same docsDir just opens its URL; a different docsDir
148
+ // requires docserve stop first.
149
+ const running = readLiveState()
150
+ if (running) {
151
+ if (running.docsDir === docsDir) {
152
+ if (args.open) openBrowser(running.url)
153
+ console.log(`docserve is already serving this folder: ${running.url}`)
154
+ return
155
+ }
156
+ console.error([
157
+ "[docserve] A server is already running for a different folder.",
158
+ ` url : ${running.url}`,
159
+ ` docs : ${running.docsDir}`,
160
+ "Stop it first: docserve stop",
161
+ ].join("\n"))
162
+ process.exit(1)
163
+ }
164
+
165
+ if (args.background) return startBackground(args, docsDir)
166
+
167
+ console.log(`docserve v${pkg.version}`)
168
+ console.log(`docs : ${docsDir}`)
169
+ await runServer({ docsDir, port: args.port, open: args.open })
170
+ }
171
+
172
+ async function startBackground(args, docsDir) {
173
+ const entry = fileURLToPath(import.meta.url)
174
+ const childArgs = [entry, docsDir]
175
+ if (args.open) childArgs.push("--open")
176
+ if (args.port !== DEFAULT_PORT) childArgs.push("--port", String(args.port))
177
+ const child = spawn(process.execPath, childArgs, { detached: true, stdio: "ignore" })
178
+ child.unref()
179
+
180
+ const state = await waitForState()
181
+ if (!state) {
182
+ console.error("[docserve] Server did not report startup (no state file). Try running in the foreground.")
183
+ process.exit(1)
184
+ }
185
+ console.log("docserve started in the background")
186
+ console.log(` pid : ${state.pid}`)
187
+ console.log(` url : ${state.url}`)
188
+ console.log(` docs : ${state.docsDir}`)
189
+ console.log(" stop : docserve stop")
190
+ }
191
+
192
+ async function waitForState({ timeoutMs = 10000, intervalMs = 200 } = {}) {
193
+ const deadline = Date.now() + timeoutMs
194
+ while (Date.now() < deadline) {
195
+ const state = readLiveState()
196
+ if (state) return state
197
+ await new Promise((resolve) => setTimeout(resolve, intervalMs))
198
+ }
199
+ return null
200
+ }
201
+
202
+ function openBrowser(url) {
203
+ if (process.platform === "darwin") exec(`open ${url}`)
204
+ }
205
+
206
+ function isDirectory(path) {
207
+ try {
208
+ return fs.statSync(path).isDirectory()
209
+ } catch {
210
+ return false
211
+ }
212
+ }
213
+
214
+ main().catch((err) => {
215
+ console.error(err?.message ?? err)
216
+ process.exit(1)
217
+ })
package/index.html ADDED
@@ -0,0 +1,343 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>docserve</title>
7
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236c8cff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z'/%3E%3Cpath d='M14 2v6h6'/%3E%3Cpath d='M8 13h8M8 17h5'/%3E%3C/svg%3E">
8
+ <style>
9
+ :root {
10
+ --bg: #0f1117;
11
+ --card: #1a1d27;
12
+ --border: #2a2e3d;
13
+ --text: #e6e8ee;
14
+ --muted: #9aa0b0;
15
+ --accent: #6c8cff;
16
+ --green: #4ade80;
17
+ --btn-bg: rgba(15, 17, 23, 0.82);
18
+ }
19
+ @media (prefers-color-scheme: light) {
20
+ :root {
21
+ --bg: #f8fafc;
22
+ --card: #ffffff;
23
+ --border: #e2e8f0;
24
+ --text: #334155;
25
+ --muted: #64748b;
26
+ --accent: #2563eb;
27
+ --green: #16a34a;
28
+ --btn-bg: rgba(255, 255, 255, 0.85);
29
+ }
30
+ }
31
+
32
+ * { box-sizing: border-box; margin: 0; padding: 0; }
33
+
34
+ body {
35
+ background: var(--bg);
36
+ color: var(--text);
37
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", "Noto Sans JP", sans-serif;
38
+ padding: 40px 24px;
39
+ }
40
+
41
+ h1 {
42
+ display: flex;
43
+ align-items: center;
44
+ gap: 10px;
45
+ font-size: 1.6rem;
46
+ margin-bottom: 8px;
47
+ }
48
+ h1 svg { width: 26px; height: 26px; color: var(--accent); flex-shrink: 0; }
49
+ .sub { color: var(--muted); margin-bottom: 32px; font-size: 0.9rem; }
50
+ .sub b { color: var(--green); }
51
+
52
+ .sub code {
53
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
54
+ font-size: 0.78rem;
55
+ background: var(--card);
56
+ border: 1px solid var(--border);
57
+ border-radius: 6px;
58
+ padding: 2px 8px;
59
+ word-break: break-all;
60
+ }
61
+
62
+ .grid {
63
+ display: grid;
64
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
65
+ gap: 16px;
66
+ }
67
+
68
+ .card {
69
+ display: flex;
70
+ flex-direction: column;
71
+ overflow: hidden;
72
+ background: var(--card);
73
+ border: 1px solid var(--border);
74
+ border-radius: 12px;
75
+ color: inherit;
76
+ text-decoration: none;
77
+ transition: transform 0.15s ease, border-color 0.15s ease;
78
+ }
79
+
80
+ .card:hover { border-color: var(--accent); }
81
+
82
+ .preview {
83
+ position: relative;
84
+ height: 160px;
85
+ overflow: hidden;
86
+ background: #fff;
87
+ }
88
+
89
+ .preview iframe {
90
+ border: 0;
91
+ transform-origin: top left;
92
+ pointer-events: none;
93
+ }
94
+
95
+ .preview::after {
96
+ content: "";
97
+ position: absolute;
98
+ inset: 0;
99
+ }
100
+
101
+ .copy-btn {
102
+ position: absolute;
103
+ top: 8px;
104
+ right: 8px;
105
+ z-index: 1;
106
+ display: grid;
107
+ place-items: center;
108
+ width: 26px;
109
+ height: 26px;
110
+ padding: 0;
111
+ border: 1px solid var(--border);
112
+ border-radius: 7px;
113
+ background: var(--btn-bg);
114
+ color: var(--text);
115
+ cursor: pointer;
116
+ opacity: 0;
117
+ transition: opacity 0.15s ease, color 0.15s ease, border-color 0.15s ease;
118
+ }
119
+
120
+ .copy-btn svg { width: 13px; height: 13px; }
121
+ .copy-btn .ic-check { display: none; }
122
+ .card:hover .copy-btn { opacity: 1; }
123
+ .copy-btn:hover { border-color: var(--accent); color: var(--accent); }
124
+ .copy-btn.copied { color: var(--green); border-color: var(--green); }
125
+ .copy-btn.copied .ic-copy { display: none; }
126
+ .copy-btn.copied .ic-check { display: block; }
127
+
128
+ @media (hover: none) {
129
+ .copy-btn { opacity: 0.7; }
130
+ }
131
+
132
+ .meta {
133
+ display: flex;
134
+ flex: 1;
135
+ flex-direction: column;
136
+ justify-content: space-between;
137
+ gap: 6px;
138
+ padding: 10px 12px;
139
+ border-top: 1px solid var(--border);
140
+ }
141
+
142
+ .meta .title { font-size: 0.85rem; font-weight: 600; line-height: 1.4; }
143
+ .date { font-size: 0.7rem; color: var(--muted); }
144
+
145
+ .empty {
146
+ max-width: 480px;
147
+ margin: 48px auto;
148
+ padding: 32px 24px;
149
+ border: 1px dashed var(--border);
150
+ border-radius: 12px;
151
+ color: var(--muted);
152
+ text-align: center;
153
+ }
154
+
155
+ .empty b { color: var(--text); }
156
+ </style>
157
+ </head>
158
+
159
+ <body>
160
+ <h1>
161
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8M8 17h5"/></svg>
162
+ docserve
163
+ </h1>
164
+ <p class="sub">
165
+ <code id="docs-dir">__DOCSERVE_DIR__</code> - <b id="total">0</b> files
166
+ </p>
167
+
168
+ <div class="grid" id="grid" hidden></div>
169
+
170
+ <div class="empty" id="empty" hidden>
171
+ <b>No HTML files found</b>
172
+ <p>Place .html files in <code id="empty-dir"></code> and they will appear here automatically</p>
173
+ </div>
174
+
175
+ <script type="module">
176
+ const VIEWPORT_W = 960
177
+ const VIEWPORT_H = 900
178
+
179
+ function el(tag, props = {}, children = []) {
180
+ const node = document.createElement(tag)
181
+ for (const [key, value] of Object.entries(props)) {
182
+ if (value == null || value === false) continue
183
+ if (key === "class") node.className = value
184
+ else if (key === "text") node.textContent = value
185
+ else if (key === "html") node.innerHTML = value
186
+ else if (key === "style" && typeof value === "object") Object.assign(node.style, value)
187
+ else if (key.startsWith("on") && typeof value === "function") node.addEventListener(key.slice(2).toLowerCase(), value)
188
+ else node.setAttribute(key, value === true ? "" : String(value))
189
+ }
190
+ for (const child of Array.isArray(children) ? children : [children]) {
191
+ if (child == null || child === false) continue
192
+ node.append(child)
193
+ }
194
+ return node
195
+ }
196
+
197
+ function icon(paths, { width = 2, class: name = "" } = {}) {
198
+ return `<svg${name ? ` class="${name}"` : ""} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`
199
+ }
200
+
201
+ const COPY_SVG = icon('<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>', { class: "ic-copy" })
202
+ const CHECK_SVG = icon('<polyline points="20 6 9 17 4 12"/>', { class: "ic-check", width: 2.5 })
203
+
204
+ class PreviewLoader {
205
+ queue = []
206
+ active = 0
207
+ max = 3
208
+ observer
209
+
210
+ constructor({ max = 3, rootMargin = "400px" } = {}) {
211
+ this.max = max
212
+ this.observer = new IntersectionObserver((entries) => {
213
+ for (const entry of entries) {
214
+ if (!entry.isIntersecting) continue
215
+ this.observer.unobserve(entry.target)
216
+ const preview = entry.target
217
+ const iframe = preview.querySelector("iframe")
218
+ const src = iframe?.dataset.src
219
+ if (iframe && src) this.enqueue(iframe, src)
220
+ }
221
+ }, { rootMargin })
222
+ }
223
+
224
+ watch(preview) {
225
+ this.observer.observe(preview)
226
+ }
227
+
228
+ enqueue(iframe, src) {
229
+ this.queue.push({ iframe, src })
230
+ this.pump()
231
+ }
232
+
233
+ pump() {
234
+ while (this.active < this.max && this.queue.length) {
235
+ const item = this.queue.shift()
236
+ if (!item) return
237
+ this.active++
238
+ item.iframe.addEventListener(
239
+ "load",
240
+ () => {
241
+ this.active--
242
+ this.pump()
243
+ },
244
+ { once: true },
245
+ )
246
+ item.iframe.src = item.src
247
+ }
248
+ }
249
+ }
250
+
251
+ const previews = new PreviewLoader()
252
+
253
+ function fitPreviews() {
254
+ for (const el of document.querySelectorAll(".preview")) {
255
+ const iframe = el.querySelector("iframe")
256
+ if (!iframe) continue
257
+ iframe.style.width = `${VIEWPORT_W}px`
258
+ iframe.style.height = `${VIEWPORT_H}px`
259
+ iframe.style.transform = `scale(${el.clientWidth / VIEWPORT_W})`
260
+ }
261
+ }
262
+
263
+ function displayDate(value) {
264
+ const date = new Date(typeof value === "number" ? value * 1000 : value)
265
+ if (Number.isNaN(date.getTime())) return ""
266
+ return `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, "0")}/${String(date.getDate()).padStart(2, "0")}`
267
+ }
268
+
269
+ function card(file) {
270
+ return el("a", { class: "card", href: file.url, target: "_blank", rel: "noopener" }, [
271
+ el("div", { class: "preview" }, [
272
+ el("iframe", { tabindex: -1, title: file.title ?? "", "data-src": file.url }),
273
+ el("button", {
274
+ type: "button",
275
+ class: "copy-btn",
276
+ title: file.path,
277
+ "aria-label": "Copy file path",
278
+ html: COPY_SVG + CHECK_SVG,
279
+ onclick: async (e) => {
280
+ const btn = e.currentTarget
281
+ e.preventDefault()
282
+ e.stopPropagation()
283
+ try {
284
+ await navigator.clipboard.writeText(btn.title)
285
+ btn.classList.add("copied")
286
+ setTimeout(() => btn.classList.remove("copied"), 1200)
287
+ } catch {}
288
+ },
289
+ }),
290
+ ]),
291
+ el("div", { class: "meta" }, [
292
+ el("div", { class: "title", text: file.title }),
293
+ el("div", { class: "date", text: displayDate(file.created) }),
294
+ ]),
295
+ ])
296
+ }
297
+
298
+ async function load() {
299
+ const docsDir = document.getElementById("docs-dir").textContent
300
+ document.getElementById("empty-dir").textContent = docsDir
301
+
302
+ let files = []
303
+ try {
304
+ const res = await fetch("/api/files")
305
+ files = await res.json()
306
+ } catch {}
307
+ if (!Array.isArray(files)) files = []
308
+
309
+ document.getElementById("total").textContent = String(files.length)
310
+ if (files.length === 0) {
311
+ document.getElementById("empty").hidden = false
312
+ return
313
+ }
314
+
315
+ const grid = document.getElementById("grid")
316
+ for (const f of files) grid.appendChild(card(f))
317
+ grid.hidden = false
318
+
319
+ for (const preview of grid.querySelectorAll(".preview")) {
320
+ previews.watch(preview)
321
+ }
322
+ fitPreviews()
323
+ new ResizeObserver(fitPreviews).observe(grid)
324
+ }
325
+
326
+ function connectLiveReload() {
327
+ // Reload when the set of files changes; a dropped socket recovers after restart.
328
+ const ws = new WebSocket(`${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/__reload`)
329
+ ws.addEventListener("message", (e) => {
330
+ let change
331
+ try { change = JSON.parse(e.data) } catch { return }
332
+ if (change?.type === "change" && change.contentSetChanged) location.reload()
333
+ })
334
+ ws.addEventListener("close", () => setTimeout(() => location.reload(), 1000))
335
+ }
336
+
337
+ window.addEventListener("resize", fitPreviews)
338
+ document.fonts?.ready.then(fitPreviews)
339
+ load()
340
+ connectLiveReload()
341
+ </script>
342
+ </body>
343
+ </html>
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@noy4/docserve",
3
+ "version": "0.1.0",
4
+ "description": "Serves a folder of HTML as a card gallery with live reload.",
5
+ "type": "module",
6
+ "bin": {
7
+ "docserve": "./bin.mjs"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test"
11
+ },
12
+ "files": [
13
+ "bin.mjs",
14
+ "index.html",
15
+ "server/"
16
+ ],
17
+ "engines": {
18
+ "node": ">=22.12.0"
19
+ },
20
+ "keywords": [
21
+ "static-server",
22
+ "live-reload",
23
+ "gallery",
24
+ "html"
25
+ ],
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/noy4/docserve.git"
30
+ },
31
+ "homepage": "https://github.com/noy4/docserve#readme",
32
+ "bugs": "https://github.com/noy4/docserve/issues",
33
+ "publishConfig": {
34
+ "access": "public",
35
+ "provenance": true
36
+ }
37
+ }
@@ -0,0 +1,189 @@
1
+ // File metadata for /api/files: recursive *.html listing (excluding index.html),
2
+ // <title> / <meta name="created_at"> extraction, and git date maps cached per
3
+ // repo root.
4
+ //
5
+ // Created-date priority: meta created_at > oldest git add commit > birthtime.
6
+ import { open, readdir, stat } from "node:fs/promises"
7
+ import { realpathSync } from "node:fs"
8
+ import { basename, isAbsolute, join, relative, sep } from "node:path"
9
+ import { execFile } from "node:child_process"
10
+ import { promisify } from "node:util"
11
+
12
+ const run = promisify(execFile)
13
+
14
+ // Walk docsDir recursively and collect .html files; any index.html is excluded
15
+ // from the gallery.
16
+ export async function listHtmlFiles(docsDir, out = []) {
17
+ let entries
18
+ try {
19
+ entries = await readdir(docsDir, { withFileTypes: true })
20
+ } catch {
21
+ return out
22
+ }
23
+ for (const entry of entries) {
24
+ const full = join(docsDir, entry.name)
25
+ if (entry.isDirectory()) {
26
+ await listHtmlFiles(full, out)
27
+ } else if (entry.name.endsWith(".html") && entry.name !== "index.html") {
28
+ out.push(full)
29
+ }
30
+ }
31
+ return out
32
+ }
33
+
34
+ // /api/files payload, sorted by created desc with title/path tiebreaks.
35
+ export async function apiFiles(docsDir) {
36
+ const paths = await listHtmlFiles(docsDir)
37
+ const { created, modified } = await gitDateMaps(docsDir)
38
+ const files = []
39
+ for (const full of paths) {
40
+ let st
41
+ try {
42
+ st = await stat(full)
43
+ } catch {
44
+ continue // deleted between listing and stat
45
+ }
46
+ const { title, date } = await metaOf(full, st.mtimeMs)
47
+ let key
48
+ try {
49
+ key = realpathSync(full)
50
+ } catch {
51
+ continue
52
+ }
53
+ const rel = normalizedRelative(docsDir, full)
54
+ files.push({
55
+ title: title ?? basename(full),
56
+ url: `/${rel}`,
57
+ path: full,
58
+ created: date ?? created.get(key) ?? Math.floor(st.birthtimeMs / 1000),
59
+ modified: modified.get(key) ?? Math.floor(st.mtimeMs / 1000),
60
+ })
61
+ }
62
+ files.sort((a, b) => b.created - a.created || a.title.localeCompare(b.title, "ja") || a.url.localeCompare(b.url))
63
+ return files
64
+ }
65
+
66
+ // <title> + <meta name="created_at"> extracted from the head of a report; cached by
67
+ // path + mtime so repeated /api/files calls only re-read files that changed.
68
+ const metaCache = new Map() // "path:mtimeMs" -> { title, created }
69
+
70
+ async function metaOf(full, mtimeMs) {
71
+ const key = `${full}:${mtimeMs}`
72
+ const cached = metaCache.get(key)
73
+ if (cached !== undefined) return cached
74
+ const meta = await extractMeta(full)
75
+ metaCache.set(key, meta)
76
+ return meta
77
+ }
78
+
79
+ async function extractMeta(full) {
80
+ const findTitle = (text) => text.match(/<title>([^<]*)<\/title>/)?.[1]?.trim() ?? null
81
+ const findDate = (text) => {
82
+ for (const tag of text.match(/<meta\b[^>]*>/gi) ?? []) {
83
+ if (!/\bname\s*=\s*["']created_at["']/i.test(tag)) continue
84
+ const d = new Date(tag.match(/\bcontent\s*=\s*["']([^"']*)["']/i)?.[1]?.trim() ?? "")
85
+ if (!isNaN(d.getTime())) return Math.floor(d.getTime() / 1000)
86
+ }
87
+ return null
88
+ }
89
+ try {
90
+ const handle = await open(full, "r")
91
+ try {
92
+ const read = async (len, pos) => {
93
+ const { buffer, bytesRead } = await handle.read(Buffer.alloc(len), 0, len, pos)
94
+ return buffer.toString("utf8", 0, bytesRead)
95
+ }
96
+ // Titles and meta tags live in <head>; read only the first 4 KB.
97
+ const head = await read(4096, 0)
98
+ let meta = { title: findTitle(head), date: findDate(head) }
99
+ if (meta.title === null || meta.date === null) {
100
+ // Fallback for documents with a long preamble (inline favicon etc.)
101
+ const text = await read(1 << 20, 0)
102
+ meta = {
103
+ title: meta.title ?? findTitle(text),
104
+ date: meta.date ?? findDate(text),
105
+ }
106
+ }
107
+ return meta
108
+ } finally {
109
+ await handle.close()
110
+ }
111
+ } catch {
112
+ return { title: null, date: null }
113
+ }
114
+ }
115
+
116
+ // Resolve created/modified dates from git history, keyed by absolute path.
117
+ // A single full-repo scan with cwd set to the docs dir, cached per repo root in
118
+ // module scope. git log outputs newest first: overwriting keeps the oldest add
119
+ // (= creation date) and the newest touch (= modified date).
120
+ const gitMapsCache = new Map() // repo root (or docsDir when not a repo) -> { created, modified }
121
+
122
+ async function gitDateMaps(docsDir) {
123
+ const cwd = realpath(docsDir)
124
+ const top = (await git(["rev-parse", "--show-toplevel"], cwd)).trim()
125
+ const root = top ? realpath(top) : null
126
+ const cacheKey = root ?? cwd
127
+ let maps = gitMapsCache.get(cacheKey)
128
+ if (maps) return maps
129
+
130
+ maps = { created: new Map(), modified: new Map() }
131
+ if (root) {
132
+ try {
133
+ const [first, last] = await Promise.all([
134
+ git(["log", "--diff-filter=A", "--name-only", "--format=c:%at"], cwd),
135
+ git(["log", "--name-only", "--format=m:%at"], cwd),
136
+ ])
137
+ let ts
138
+ for (const line of first.split("\n")) {
139
+ if (line.startsWith("c:")) {
140
+ ts = parseInt(line.slice(2), 10)
141
+ } else if (line && ts !== undefined) {
142
+ maps.created.set(join(root, line), ts) // oldest add wins
143
+ }
144
+ }
145
+ ts = undefined
146
+ for (const line of last.split("\n")) {
147
+ if (line.startsWith("m:")) {
148
+ ts = parseInt(line.slice(2), 10)
149
+ } else if (line && ts !== undefined) {
150
+ maps.modified.set(join(root, line), ts) // newest touch wins
151
+ }
152
+ }
153
+ } catch {
154
+ // unreadable history → fall back to fs dates
155
+ }
156
+ }
157
+ gitMapsCache.set(cacheKey, maps)
158
+ return maps
159
+ }
160
+
161
+ async function git(args, cwd) {
162
+ try {
163
+ const { stdout } = await run("git", args, { cwd, timeout: 10000, maxBuffer: 64 * 1024 * 1024 })
164
+ return stdout
165
+ } catch {
166
+ return ""
167
+ }
168
+ }
169
+
170
+ // On macOS /var is a symlink to /private/var; canonicalize to real paths so
171
+ // lookups match git output.
172
+ function realpath(p) {
173
+ try {
174
+ return realpathSync(p)
175
+ } catch {
176
+ return p
177
+ }
178
+ }
179
+
180
+ // --- Path utilities (shared by the HTTP handler and the watcher) ---
181
+
182
+ export function isWithin(base, path) {
183
+ const rel = relative(base, path)
184
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
185
+ }
186
+
187
+ export function normalizedRelative(base, path) {
188
+ return relative(base, path).split(sep).join("/")
189
+ }
@@ -0,0 +1,168 @@
1
+ // docserve server core: HTTP layer + server lifecycle.
2
+ //
3
+ // ┌────────────────────────────────────────────────────────┐
4
+ // │ runServer() │
5
+ // │ ├─ createServer ──▶ handler() │ # HTTP
6
+ // │ │ ├─ /api/files ──▶ apiFiles() │
7
+ // │ │ ├─ /, /index.html ──▶ gallery template │
8
+ // │ │ └─ other paths ──▶ raw files ──▶ injectIntoHtml() │
9
+ // │ ├─ WebSocketServer("/__reload") ◀─ UpdateListener │
10
+ // │ └─ listen ──▶ listener.start() + writeState() │
11
+ // └────────────────────────────────────────────────────────┘
12
+ import { createServer } from "node:http"
13
+ import { readFile } from "node:fs/promises"
14
+ import { extname, resolve } from "node:path"
15
+ import { exec } from "node:child_process"
16
+ import { clearState, writeState } from "./state.mjs"
17
+ import { apiFiles, isWithin, normalizedRelative } from "./files.mjs"
18
+ import { injectIntoHtml, WS_PATH } from "./inject.mjs"
19
+ import { WebSocketServer } from "./websocket.mjs"
20
+ import { UpdateListener } from "./watch.mjs"
21
+
22
+ const MAX_PORT_TRIES = 20 // on conflict, try the next port up to 20 times
23
+ const INDEX_TEMPLATE = resolve(import.meta.dirname, "..", "index.html")
24
+
25
+ const MIME = {
26
+ ".html": "text/html",
27
+ ".htm": "text/html",
28
+ ".js": "text/javascript",
29
+ ".mjs": "text/javascript",
30
+ ".css": "text/css",
31
+ ".json": "application/json",
32
+ ".txt": "text/plain",
33
+ ".md": "text/markdown",
34
+ ".xml": "application/xml",
35
+ ".png": "image/png",
36
+ ".jpg": "image/jpeg",
37
+ ".jpeg": "image/jpeg",
38
+ ".gif": "image/gif",
39
+ ".svg": "image/svg+xml",
40
+ ".webp": "image/webp",
41
+ ".ico": "image/x-icon",
42
+ ".pdf": "application/pdf",
43
+ ".woff": "font/woff",
44
+ ".woff2": "font/woff2",
45
+ ".mp4": "video/mp4",
46
+ ".webm": "video/webm",
47
+ }
48
+
49
+ export async function runServer({ docsDir, port: initialPort, open = false }) {
50
+ const wss = new WebSocketServer({ path: WS_PATH })
51
+ const updateListener = new UpdateListener({ wss, docsDir })
52
+ let port = initialPort // actually bound port (may fall back +1 on conflict)
53
+
54
+ const cleanup = () => {
55
+ updateListener.close()
56
+ clearState()
57
+ }
58
+ process.on("SIGINT", () => { cleanup(); process.exit(0) })
59
+ process.on("SIGTERM", () => { cleanup(); process.exit(0) })
60
+
61
+ const server = createServer(createNodeServerAdapter(createHandler({ docsDir })))
62
+ wss.attach(server)
63
+ server.on("error", (err) => {
64
+ if (err.code === "EADDRINUSE" && port < initialPort + MAX_PORT_TRIES) {
65
+ port += 1
66
+ server.listen(port, "127.0.0.1")
67
+ } else {
68
+ console.error(err)
69
+ process.exit(1)
70
+ }
71
+ })
72
+ server.listen(port, "127.0.0.1", async () => {
73
+ await updateListener.start()
74
+ const state = { pid: process.pid, port, url: `http://localhost:${port}/`, docsDir }
75
+ writeState(state)
76
+ console.log(state.url)
77
+ if (open && process.platform === "darwin") exec(`open ${state.url}`)
78
+ })
79
+ return { server, wss, updateListener }
80
+ }
81
+
82
+ // --- Node server adapter ---
83
+
84
+ function createNodeServerAdapter(handler) {
85
+ return async (req, res) => {
86
+ try {
87
+ const response = await handler(toWebRequest(req))
88
+ res.writeHead(response.status, Object.fromEntries(response.headers))
89
+ res.end(Buffer.from(await response.arrayBuffer()))
90
+ } catch (err) {
91
+ console.error(err)
92
+ res.writeHead(500, { "content-type": "text/plain" })
93
+ res.end("Internal Server Error")
94
+ }
95
+ }
96
+ }
97
+
98
+ function toWebRequest(req) {
99
+ const host = req.headers.host || "localhost"
100
+ return new Request(`http://${host}${req.url}`, { method: req.method, headers: req.headers })
101
+ }
102
+
103
+ // --- HTTP handler ---
104
+
105
+ function createHandler({ docsDir }) {
106
+ return async function handler(request) {
107
+ const url = new URL(request.url)
108
+
109
+ if (url.pathname === "/api/files") {
110
+ try {
111
+ return Response.json(await apiFiles(docsDir))
112
+ } catch (err) {
113
+ console.error(err)
114
+ return Response.json([])
115
+ }
116
+ }
117
+
118
+ let pathname
119
+ try {
120
+ pathname = decodeURIComponent(url.pathname)
121
+ } catch {
122
+ return new Response("Bad Request", { status: 400 })
123
+ }
124
+
125
+ if (pathname === "/" || pathname === "/index.html") {
126
+ return galleryResponse(docsDir)
127
+ }
128
+
129
+ if (pathname.includes("\0")) {
130
+ return new Response("Bad Request", { status: 400 })
131
+ }
132
+
133
+ const path = resolve(docsDir, pathname.slice(1))
134
+ if (!isWithin(docsDir, path)) {
135
+ return new Response("Forbidden", { status: 403 })
136
+ }
137
+
138
+ try {
139
+ const body = await readFile(path)
140
+ const type = MIME[extname(path).toLowerCase()] || "application/octet-stream"
141
+ // Inject the reload client + copy path button into docsDir pages only;
142
+ // the gallery template ships its own client.
143
+ if (type === "text/html") {
144
+ const html = injectIntoHtml(body.toString(), normalizedRelative(docsDir, path), path)
145
+ return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } })
146
+ }
147
+ return new Response(body, { headers: { "content-type": type } })
148
+ } catch {
149
+ return new Response("Not Found", { status: 404 })
150
+ }
151
+ }
152
+ }
153
+
154
+ // The gallery is an embedded template served at / and /index.html; docsDir's own
155
+ // index.html is shadowed by it. __DOCSERVE_DIR__ is substituted for display.
156
+ async function galleryResponse(docsDir) {
157
+ try {
158
+ let html = await readFile(INDEX_TEMPLATE, "utf8")
159
+ html = html.replaceAll("__DOCSERVE_DIR__", escapeHtml(docsDir))
160
+ return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } })
161
+ } catch {
162
+ return new Response("Gallery template missing", { status: 500 })
163
+ }
164
+ }
165
+
166
+ function escapeHtml(s) {
167
+ return s.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
168
+ }
@@ -0,0 +1,100 @@
1
+ // Injection: every text/html response served from docsDir gets one client script
2
+ // before the last </body> (appended at the end if the tag is missing). The script
3
+ // exits immediately inside iframes, so previews get neither part; the gallery
4
+ // template ships its own client.
5
+
6
+ export const WS_PATH = "/__reload"
7
+
8
+ export function injectIntoHtml(html, pageId, absPath) {
9
+ const idx = html.toLowerCase().lastIndexOf("</body>")
10
+ if (idx === -1) return html + clientJs(pageId, absPath)
11
+ return html.slice(0, idx) + clientJs(pageId, absPath) + html.slice(idx)
12
+ }
13
+
14
+ function clientJs(pageId, absPath) {
15
+ return `<script>
16
+ (() => {
17
+ // iframes: no reload loop from dropped sockets, no copy button
18
+ if (window.self !== window.top) return
19
+ const pageId = ${JSON.stringify(pageId).replaceAll("<", "\\u003c")}
20
+ const proto = location.protocol === "https:" ? "wss:" : "ws:"
21
+ const ws = new WebSocket(proto + "//" + location.host + "${WS_PATH}")
22
+ ws.addEventListener("message", (e) => {
23
+ let change
24
+ try { change = JSON.parse(e.data) } catch { return }
25
+ if (change?.type === "change" && Array.isArray(change.pages) && change.pages.includes(pageId)) {
26
+ location.reload()
27
+ }
28
+ })
29
+ ws.addEventListener("close", () => setTimeout(() => location.reload(), 1000))
30
+
31
+ const absPath = ${JSON.stringify(absPath).replaceAll("<", "\\u003c")}
32
+ const style = document.createElement("style")
33
+ style.textContent = \`#docserve-copy {
34
+ position: fixed;
35
+ top: 12px;
36
+ right: 12px;
37
+ z-index: 2147483647;
38
+ display: inline-flex;
39
+ align-items: center;
40
+ gap: 6px;
41
+ height: 28px;
42
+ padding: 0 10px 0 9px;
43
+ border: 1px solid rgba(127, 127, 138, 0.35);
44
+ border-radius: 8px;
45
+ background: rgba(127, 127, 138, 0.14);
46
+ color: inherit;
47
+ cursor: pointer;
48
+ opacity: 0.55;
49
+ transition: opacity 0.15s ease, color 0.15s ease, border-color 0.15s ease;
50
+ }
51
+ #docserve-copy svg {
52
+ width: 14px;
53
+ height: 14px;
54
+ pointer-events: none;
55
+ }
56
+ #docserve-copy .label {
57
+ font: 500 11.5px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", sans-serif;
58
+ letter-spacing: 0.01em;
59
+ white-space: nowrap;
60
+ }
61
+ #docserve-copy:hover {
62
+ opacity: 1;
63
+ border-color: #6c8cff;
64
+ color: #6c8cff;
65
+ }
66
+ #docserve-copy.copied {
67
+ opacity: 1;
68
+ color: #4ade80;
69
+ border-color: #4ade80;
70
+ }
71
+ @media (prefers-color-scheme: light) {
72
+ #docserve-copy:hover { border-color: #2563eb; color: #2563eb; }
73
+ #docserve-copy.copied { color: #16a34a; border-color: #16a34a; }
74
+ }
75
+ #docserve-copy .ic-check { display: none; }
76
+ #docserve-copy.copied .ic-copy { display: none; }
77
+ #docserve-copy.copied .ic-check { display: block; }\`
78
+ const COPY = '<svg class="ic-copy" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'
79
+ const CHECK = '<svg class="ic-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'
80
+ const btn = document.createElement("button")
81
+ btn.id = "docserve-copy"
82
+ btn.type = "button"
83
+ btn.title = absPath
84
+ btn.innerHTML = COPY + CHECK + '<span class="label">Copy file path</span>'
85
+ btn.addEventListener("click", async () => {
86
+ try {
87
+ await navigator.clipboard.writeText(absPath)
88
+ btn.classList.add("copied")
89
+ btn.querySelector(".label").textContent = "Copied"
90
+ setTimeout(() => {
91
+ btn.classList.remove("copied")
92
+ btn.querySelector(".label").textContent = "Copy file path"
93
+ }, 1200)
94
+ } catch {}
95
+ })
96
+ document.head.appendChild(style)
97
+ document.body.appendChild(btn)
98
+ })()
99
+ </script>`
100
+ }
@@ -0,0 +1,61 @@
1
+ // state.json read/write + pid liveness.
2
+ //
3
+ // The server writes ~/.cache/docserve/state.json once the port is bound and
4
+ // removes it on clean shutdown. Readers verify the PID is alive and treat
5
+ // dead-PID state as stale (removed in place).
6
+ import fs from "node:fs"
7
+ import os from "node:os"
8
+ import path from "node:path"
9
+
10
+ // DOCSERVE_STATE overrides the state file path (test isolation).
11
+ export function statePath() {
12
+ return process.env.DOCSERVE_STATE || path.join(os.homedir(), ".cache", "docserve", "state.json")
13
+ }
14
+
15
+ export function readState() {
16
+ try {
17
+ const state = JSON.parse(fs.readFileSync(statePath(), "utf8"))
18
+ if (
19
+ typeof state?.pid === "number" &&
20
+ typeof state?.port === "number" &&
21
+ typeof state?.url === "string" &&
22
+ typeof state?.docsDir === "string"
23
+ ) {
24
+ return state
25
+ }
26
+ return null
27
+ } catch {
28
+ return null
29
+ }
30
+ }
31
+
32
+ export function writeState(state) {
33
+ fs.mkdirSync(path.dirname(statePath()), { recursive: true })
34
+ fs.writeFileSync(statePath(), JSON.stringify(state, null, 2) + "\n")
35
+ }
36
+
37
+ export function clearState() {
38
+ try {
39
+ fs.rmSync(statePath(), { force: true })
40
+ } catch {}
41
+ }
42
+
43
+ export function isProcessAlive(pid) {
44
+ try {
45
+ process.kill(pid, 0)
46
+ return true
47
+ } catch {
48
+ return false
49
+ }
50
+ }
51
+
52
+ // Read the state and drop stale entries (dead pid) in place.
53
+ export function readLiveState() {
54
+ const state = readState()
55
+ if (!state) return null
56
+ if (!isProcessAlive(state.pid)) {
57
+ clearState()
58
+ return null
59
+ }
60
+ return state
61
+ }
@@ -0,0 +1,86 @@
1
+ // UpdateListener: watch docsDir recursively for *.html changes and broadcast
2
+ // debounced batches over the reload socket.
3
+ //
4
+ // The gallery list is every *.html except any index.html; files outside that
5
+ // set never flip contentSetChanged.
6
+ import { existsSync, watch } from "node:fs"
7
+ import { basename, resolve } from "node:path"
8
+ import { isWithin, listHtmlFiles, normalizedRelative } from "./files.mjs"
9
+
10
+ const DEBOUNCE_MS = 50 // like livePreview.previewDebounceDelay
11
+
12
+ export class UpdateListener {
13
+ constructor(options = {}) {
14
+ this.wss = options.wss
15
+ this.docsDir = options.docsDir
16
+ this.debounceMs = options.debounceMs ?? DEBOUNCE_MS
17
+ this.knownFiles = new Set()
18
+ this.broadcastQueue = Promise.resolve()
19
+ this.watchers = []
20
+
21
+ this.queueBroadcast = debounceBatch((touched) => {
22
+ this.broadcastQueue = this.broadcastQueue
23
+ .then(() => this.broadcastChanges(touched))
24
+ .catch((error) => console.error(error))
25
+ }, this.debounceMs)
26
+ }
27
+
28
+ async start() {
29
+ try {
30
+ this.knownFiles = new Set(
31
+ (await listHtmlFiles(this.docsDir)).map((p) => normalizedRelative(this.docsDir, p)),
32
+ )
33
+ } catch {
34
+ this.knownFiles = new Set()
35
+ }
36
+
37
+ this.watchers.push(
38
+ watch(this.docsDir, { recursive: true }, (event, filename) => {
39
+ if (!filename?.endsWith(".html")) return
40
+ const full = resolve(this.docsDir, filename)
41
+ if (!isWithin(this.docsDir, full)) return
42
+ this.queueBroadcast(normalizedRelative(this.docsDir, full))
43
+ }).on("error", () => {}),
44
+ )
45
+ }
46
+
47
+ async broadcastChanges(touched) {
48
+ let contentSetChanged = false
49
+
50
+ for (const rel of touched) {
51
+ if (basename(rel) === "index.html") continue // never a gallery card
52
+ const full = resolve(this.docsDir, rel)
53
+ const exists = existsSync(full)
54
+ const wasKnown = this.knownFiles.has(rel)
55
+
56
+ if (exists && !wasKnown) {
57
+ this.knownFiles.add(rel)
58
+ contentSetChanged = true
59
+ } else if (!exists && wasKnown) {
60
+ this.knownFiles.delete(rel)
61
+ contentSetChanged = true
62
+ }
63
+ }
64
+
65
+ this.wss.broadcast({ type: "change", pages: touched, contentSetChanged })
66
+ }
67
+
68
+ close() {
69
+ this.watchers.forEach((w) => w.close())
70
+ }
71
+ }
72
+
73
+ // Accumulate items during the debounce window and deliver them as one batch.
74
+ function debounceBatch(fn, ms) {
75
+ let timer = null
76
+ const pending = new Set()
77
+ return (item) => {
78
+ pending.add(item)
79
+ clearTimeout(timer)
80
+ timer = setTimeout(() => {
81
+ const items = [...pending]
82
+ pending.clear()
83
+ fn(items)
84
+ }, ms)
85
+ }
86
+ }
@@ -0,0 +1,81 @@
1
+ // Minimal WebSocket server (RFC 6455 subset: handshake + text frames + ping/pong,
2
+ // zero deps). Broadcast payloads are JSON-encoded text frames.
3
+ import { createHash } from "node:crypto"
4
+
5
+ export class WebSocketServer {
6
+ constructor(options = {}) {
7
+ this.path = options.path
8
+ this.clients = new Set()
9
+ if (options.server) {
10
+ this.attach(options.server)
11
+ }
12
+ }
13
+
14
+ attach(server) {
15
+ server.on("upgrade", (req, socket) => this.handleUpgrade(req, socket))
16
+ }
17
+
18
+ handleUpgrade(req, socket) {
19
+ const url = new URL(req.url, "http://localhost")
20
+ if (this.path && url.pathname !== this.path) {
21
+ socket.destroy()
22
+ return
23
+ }
24
+ const key = req.headers["sec-websocket-key"]
25
+ if (!key) {
26
+ socket.destroy()
27
+ return
28
+ }
29
+ socket.write(
30
+ "HTTP/1.1 101 Switching Protocols\r\n" +
31
+ "Upgrade: websocket\r\n" +
32
+ "Connection: Upgrade\r\n" +
33
+ `Sec-WebSocket-Accept: ${acceptKey(key)}\r\n\r\n`,
34
+ )
35
+ socket.setNoDelay(true)
36
+ this.clients.add(socket)
37
+ socket.on("close", () => this.clients.delete(socket))
38
+ socket.on("error", () => this.clients.delete(socket))
39
+ // Answer pings to keep the connection alive
40
+ socket.on("data", (buf) => {
41
+ if (buf.length > 0 && (buf[0] & 0x0f) === 0x9) {
42
+ socket.write(Buffer.from([0x8a, 0x00])) // pong
43
+ }
44
+ })
45
+ }
46
+
47
+ broadcast(payload) {
48
+ const frame = encodeFrame(typeof payload === "string" ? payload : JSON.stringify(payload))
49
+ for (const socket of this.clients) {
50
+ try {
51
+ socket.write(frame)
52
+ } catch {
53
+ this.clients.delete(socket)
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ function acceptKey(key) {
60
+ return createHash("sha1").update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64")
61
+ }
62
+
63
+ function encodeFrame(text) {
64
+ const payload = Buffer.from(text)
65
+ const len = payload.length
66
+ let header
67
+ if (len < 126) {
68
+ header = Buffer.from([0x81, len])
69
+ } else if (len < 65536) {
70
+ header = Buffer.alloc(4)
71
+ header[0] = 0x81
72
+ header[1] = 126
73
+ header.writeUInt16BE(len, 2)
74
+ } else {
75
+ header = Buffer.alloc(10)
76
+ header[0] = 0x81
77
+ header[1] = 127
78
+ header.writeBigUInt64BE(BigInt(len), 2)
79
+ }
80
+ return Buffer.concat([header, payload])
81
+ }