chat-glass 1.0.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.
@@ -0,0 +1,132 @@
1
+ import { createServer } from "node:http";
2
+ import { WebSocketServer } from "ws";
3
+ import { findFreePort } from "../utils/port.js";
4
+ import { ensureDirs, writeConfig, readConfig } from "../utils/config.js";
5
+ import { configPath } from "../utils/paths.js";
6
+ import { createRouter } from "./routes.js";
7
+ import { createWatcher } from "./watcher.js";
8
+ import { unlink } from "node:fs/promises";
9
+
10
+ const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
11
+ const IDLE_CHECK_INTERVAL_MS = 60 * 1000; // 60 seconds
12
+
13
+ export async function startServer(projectDir) {
14
+ await ensureDirs(projectDir);
15
+
16
+ const port = await findFreePort();
17
+ const startedAt = Date.now();
18
+ let lastActivity = Date.now();
19
+
20
+ function touchActivity() {
21
+ lastActivity = Date.now();
22
+ }
23
+
24
+ // -- WebSocket clients --
25
+ const clients = new Set();
26
+
27
+ function broadcast(message) {
28
+ for (const ws of clients) {
29
+ if (ws.readyState === ws.OPEN) {
30
+ ws.send(message);
31
+ }
32
+ }
33
+ }
34
+
35
+ // -- Render-complete signal --
36
+ let renderCompleteResolve = null;
37
+
38
+ function waitForRenderComplete(timeoutMs = 5000) {
39
+ return new Promise((resolve) => {
40
+ const timer = setTimeout(() => {
41
+ renderCompleteResolve = null;
42
+ resolve();
43
+ }, timeoutMs);
44
+ renderCompleteResolve = () => {
45
+ clearTimeout(timer);
46
+ renderCompleteResolve = null;
47
+ resolve();
48
+ };
49
+ });
50
+ }
51
+
52
+ // -- HTTP server --
53
+ const router = createRouter(projectDir, {
54
+ broadcast,
55
+ touchActivity,
56
+ serverInfo: () => ({ port, startedAt }),
57
+ });
58
+
59
+ const httpServer = createServer(router);
60
+
61
+ // -- WebSocket server --
62
+ const wss = new WebSocketServer({ server: httpServer, path: "/ws" });
63
+
64
+ wss.on("connection", (ws) => {
65
+ clients.add(ws);
66
+ touchActivity();
67
+ ws.on("message", (data) => {
68
+ try {
69
+ const msg = JSON.parse(data.toString());
70
+ if (msg.type === "render-complete" && renderCompleteResolve) {
71
+ renderCompleteResolve();
72
+ }
73
+ } catch {
74
+ // ignore malformed messages
75
+ }
76
+ });
77
+ ws.on("close", () => clients.delete(ws));
78
+ ws.on("error", () => clients.delete(ws));
79
+ });
80
+
81
+ // -- File watcher --
82
+ const watcher = createWatcher(projectDir, () => {
83
+ touchActivity();
84
+ broadcast(JSON.stringify({ type: "reload" }));
85
+ });
86
+
87
+ // -- Auto-shutdown --
88
+ const idleTimer = setInterval(async () => {
89
+ if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) {
90
+ await close();
91
+ }
92
+ }, IDLE_CHECK_INTERVAL_MS);
93
+ idleTimer.unref();
94
+
95
+ // -- Write config --
96
+ await writeConfig(projectDir, {
97
+ port,
98
+ pid: process.pid,
99
+ lastActivity: new Date(lastActivity).toISOString(),
100
+ createdAt: new Date(startedAt).toISOString(),
101
+ });
102
+
103
+ // -- Start listening --
104
+ await new Promise((resolve, reject) => {
105
+ httpServer.listen(port, "127.0.0.1", resolve);
106
+ httpServer.on("error", reject);
107
+ });
108
+
109
+ // -- Clean shutdown --
110
+ async function close() {
111
+ clearInterval(idleTimer);
112
+ watcher.close();
113
+
114
+ for (const ws of clients) {
115
+ ws.close();
116
+ }
117
+ clients.clear();
118
+
119
+ wss.close();
120
+
121
+ await new Promise((resolve) => httpServer.close(resolve));
122
+
123
+ // Remove config file best-effort
124
+ try {
125
+ await unlink(configPath(projectDir));
126
+ } catch {
127
+ // ignore
128
+ }
129
+ }
130
+
131
+ return { port, close, waitForRenderComplete };
132
+ }
@@ -0,0 +1,147 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join, extname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname } from "node:path";
5
+ import { pagesDir } from "../utils/paths.js";
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = dirname(__filename);
9
+ const uiDir = join(__dirname, "..", "ui");
10
+
11
+ function sendJSON(res, statusCode, data) {
12
+ const body = JSON.stringify(data);
13
+ res.writeHead(statusCode, {
14
+ "Content-Type": "application/json",
15
+ "Content-Length": Buffer.byteLength(body),
16
+ });
17
+ res.end(body);
18
+ }
19
+
20
+ function sendHTML(res, statusCode, html) {
21
+ res.writeHead(statusCode, {
22
+ "Content-Type": "text/html; charset=utf-8",
23
+ "Content-Length": Buffer.byteLength(html),
24
+ });
25
+ res.end(html);
26
+ }
27
+
28
+ function send404(res) {
29
+ sendHTML(res, 404, "<h1>404 Not Found</h1>");
30
+ }
31
+
32
+ async function serveUIFile(res, filename) {
33
+ try {
34
+ const html = await readFile(join(uiDir, filename), "utf8");
35
+ sendHTML(res, 200, html);
36
+ } catch {
37
+ sendHTML(res, 200, `<html><body><h1>chat-glass</h1><p>UI not built yet.</p></body></html>`);
38
+ }
39
+ }
40
+
41
+ function extractTitle(html) {
42
+ const match = html.match(/<title>(.*?)<\/title>/i);
43
+ return match ? match[1] : null;
44
+ }
45
+
46
+ function extractTimestamp(filename) {
47
+ const match = filename.match(/^(\d{4}-\d{2}-\d{2}T[\d-]+)/);
48
+ if (match) return match[1];
49
+ const dateMatch = filename.match(/^(\d{4}-\d{2}-\d{2})/);
50
+ return dateMatch ? dateMatch[1] : null;
51
+ }
52
+
53
+ export function createRouter(projectDir, { broadcast, touchActivity, serverInfo }) {
54
+ const pages = pagesDir(projectDir);
55
+
56
+ return async function handleRequest(req, res) {
57
+ const url = new URL(req.url, `http://${req.headers.host}`);
58
+ const path = url.pathname;
59
+
60
+ if (req.method !== "GET") {
61
+ res.writeHead(405, { "Content-Type": "text/plain" });
62
+ res.end("Method Not Allowed");
63
+ return;
64
+ }
65
+
66
+ // GET /
67
+ if (path === "/") {
68
+ await serveUIFile(res, "main.html");
69
+ return;
70
+ }
71
+
72
+ // GET /gallery
73
+ if (path === "/gallery") {
74
+ await serveUIFile(res, "gallery.html");
75
+ return;
76
+ }
77
+
78
+ // GET /reload
79
+ if (path === "/reload") {
80
+ touchActivity();
81
+ broadcast(JSON.stringify({ type: "reload" }));
82
+ sendJSON(res, 200, { ok: true });
83
+ return;
84
+ }
85
+
86
+ // GET /health
87
+ if (path === "/health") {
88
+ const info = serverInfo();
89
+ sendJSON(res, 200, {
90
+ port: info.port,
91
+ pid: process.pid,
92
+ uptime: Math.floor((Date.now() - info.startedAt) / 1000),
93
+ });
94
+ return;
95
+ }
96
+
97
+ // GET /api/pages
98
+ if (path === "/api/pages") {
99
+ try {
100
+ const files = await readdir(pages);
101
+ const htmlFiles = files.filter(
102
+ (f) => extname(f) === ".html" && f !== "latest.html"
103
+ );
104
+
105
+ const results = await Promise.all(
106
+ htmlFiles.map(async (filename) => {
107
+ let title = null;
108
+ try {
109
+ const content = await readFile(join(pages, filename), "utf8");
110
+ title = extractTitle(content);
111
+ } catch {
112
+ // ignore unreadable files
113
+ }
114
+ const timestamp = extractTimestamp(filename);
115
+ return { filename, title, timestamp };
116
+ })
117
+ );
118
+
119
+ // Sort by filename descending (newest first, assuming timestamp-based names)
120
+ results.sort((a, b) => b.filename.localeCompare(a.filename));
121
+ sendJSON(res, 200, results);
122
+ } catch {
123
+ sendJSON(res, 200, []);
124
+ }
125
+ return;
126
+ }
127
+
128
+ // GET /pages/:filename
129
+ if (path.startsWith("/pages/")) {
130
+ const filename = path.slice("/pages/".length);
131
+ // Prevent path traversal
132
+ if (filename.includes("..") || filename.includes("/")) {
133
+ send404(res);
134
+ return;
135
+ }
136
+ try {
137
+ const html = await readFile(join(pages, filename), "utf8");
138
+ sendHTML(res, 200, html);
139
+ } catch {
140
+ send404(res);
141
+ }
142
+ return;
143
+ }
144
+
145
+ send404(res);
146
+ };
147
+ }
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Standalone entry point for running the server as a detached background process.
4
+ // Invoked by the show command via child_process.spawn.
5
+
6
+ import { startServer } from "./index.js";
7
+
8
+ const projectDir = process.argv[2] || process.cwd();
9
+
10
+ try {
11
+ const { port } = await startServer(projectDir);
12
+ // Write port to stdout so the parent process can read it
13
+ process.stdout.write(String(port));
14
+ } catch (err) {
15
+ process.stderr.write(err.message);
16
+ process.exit(1);
17
+ }
@@ -0,0 +1,51 @@
1
+ import { watch } from "node:fs";
2
+ import { pagesDir } from "../utils/paths.js";
3
+
4
+ export function createWatcher(projectDir, onChange) {
5
+ const dir = pagesDir(projectDir);
6
+ let debounceTimer = null;
7
+ let watcher = null;
8
+
9
+ function start() {
10
+ try {
11
+ watcher = watch(dir, { persistent: false }, (_event, filename) => {
12
+ // Only react to HTML file changes — ignore screenshots, temp files, etc.
13
+ if (filename && !filename.endsWith(".html")) return;
14
+ if (debounceTimer) clearTimeout(debounceTimer);
15
+ debounceTimer = setTimeout(() => {
16
+ debounceTimer = null;
17
+ onChange();
18
+ }, 100);
19
+ });
20
+
21
+ watcher.on("error", (err) => {
22
+ // Directory may be deleted and recreated — restart watcher
23
+ close();
24
+ setTimeout(() => {
25
+ try {
26
+ start();
27
+ } catch {
28
+ // directory doesn't exist yet, will retry on next change
29
+ }
30
+ }, 1000);
31
+ });
32
+ } catch {
33
+ // directory doesn't exist yet — that's fine
34
+ }
35
+ }
36
+
37
+ function close() {
38
+ if (debounceTimer) {
39
+ clearTimeout(debounceTimer);
40
+ debounceTimer = null;
41
+ }
42
+ if (watcher) {
43
+ watcher.close();
44
+ watcher = null;
45
+ }
46
+ }
47
+
48
+ start();
49
+
50
+ return { close };
51
+ }
@@ -0,0 +1,274 @@
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">
6
+ <title>chat-glass - gallery</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+
10
+ html, body {
11
+ min-height: 100%;
12
+ background: #1a1a2e;
13
+ color: #e0e0e0;
14
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
15
+ }
16
+
17
+ /* Header */
18
+ #header {
19
+ display: flex;
20
+ align-items: center;
21
+ gap: 12px;
22
+ padding: 12px 24px;
23
+ background: #0f0f23;
24
+ border-bottom: 1px solid #2a2a4a;
25
+ position: sticky;
26
+ top: 0;
27
+ z-index: 10;
28
+ }
29
+
30
+ #header .brand {
31
+ font-size: 14px;
32
+ font-weight: 700;
33
+ color: #00d4ff;
34
+ letter-spacing: 0.5px;
35
+ text-decoration: none;
36
+ }
37
+
38
+ #header .brand:hover {
39
+ color: #33dfff;
40
+ }
41
+
42
+ #header .separator {
43
+ width: 1px;
44
+ height: 16px;
45
+ background: #2a2a4a;
46
+ }
47
+
48
+ #header .view-title {
49
+ font-size: 14px;
50
+ color: #888;
51
+ }
52
+
53
+ #header .count {
54
+ font-size: 12px;
55
+ color: #555;
56
+ margin-left: auto;
57
+ }
58
+
59
+ #ws-indicator {
60
+ width: 8px;
61
+ height: 8px;
62
+ border-radius: 50%;
63
+ background: #00d4ff;
64
+ flex-shrink: 0;
65
+ transition: background 0.3s;
66
+ }
67
+
68
+ #ws-indicator.disconnected {
69
+ background: #ff4444;
70
+ }
71
+
72
+ /* Grid */
73
+ #grid {
74
+ display: grid;
75
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
76
+ gap: 16px;
77
+ padding: 24px;
78
+ }
79
+
80
+ .card {
81
+ background: #16213e;
82
+ border: 1px solid #2a2a4a;
83
+ border-radius: 8px;
84
+ padding: 20px;
85
+ cursor: pointer;
86
+ transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s;
87
+ text-decoration: none;
88
+ display: flex;
89
+ flex-direction: column;
90
+ gap: 8px;
91
+ }
92
+
93
+ .card:hover {
94
+ border-color: #00d4ff;
95
+ transform: translateY(-2px);
96
+ box-shadow: 0 4px 12px rgba(0, 212, 255, 0.1);
97
+ }
98
+
99
+ .card .card-title {
100
+ font-size: 14px;
101
+ color: #e0e0e0;
102
+ line-height: 1.4;
103
+ word-break: break-word;
104
+ }
105
+
106
+ .card:hover .card-title {
107
+ color: #00d4ff;
108
+ }
109
+
110
+ .card .card-time {
111
+ font-size: 11px;
112
+ color: #666;
113
+ }
114
+
115
+ .card .card-filename {
116
+ font-size: 10px;
117
+ color: #444;
118
+ font-family: 'SF Mono', 'Fira Code', monospace;
119
+ overflow: hidden;
120
+ text-overflow: ellipsis;
121
+ white-space: nowrap;
122
+ }
123
+
124
+ /* Empty state */
125
+ #empty-state {
126
+ display: flex;
127
+ flex-direction: column;
128
+ align-items: center;
129
+ justify-content: center;
130
+ padding: 80px 24px;
131
+ gap: 16px;
132
+ color: #888;
133
+ }
134
+
135
+ #empty-state .icon {
136
+ font-size: 48px;
137
+ opacity: 0.3;
138
+ }
139
+
140
+ #empty-state .message {
141
+ font-size: 16px;
142
+ }
143
+
144
+ #empty-state .hint {
145
+ font-size: 12px;
146
+ color: #555;
147
+ }
148
+
149
+ .hidden { display: none !important; }
150
+ </style>
151
+ </head>
152
+ <body>
153
+ <div id="header">
154
+ <a class="brand" href="/">chat-glass</a>
155
+ <span class="separator"></span>
156
+ <span class="view-title">Gallery</span>
157
+ <span class="count" id="page-count"></span>
158
+ <div id="ws-indicator" title="WebSocket connected"></div>
159
+ </div>
160
+
161
+ <div id="grid"></div>
162
+
163
+ <div id="empty-state" class="hidden">
164
+ <div class="icon">&#9672;</div>
165
+ <div class="message">Waiting for Claude to create a visualization...</div>
166
+ <div class="hint">Use chat-glass show &lt;file.html&gt; to display content</div>
167
+ </div>
168
+
169
+ <script>
170
+ const grid = document.getElementById('grid');
171
+ const emptyState = document.getElementById('empty-state');
172
+ const pageCount = document.getElementById('page-count');
173
+ const wsIndicator = document.getElementById('ws-indicator');
174
+
175
+ function formatTimestamp(ts) {
176
+ if (!ts) return '';
177
+ const cleaned = ts.replace(/T/, ' ').replace(/-(\d{2})-(\d{2})-(\d+)$/, ':$1:$2');
178
+ return cleaned;
179
+ }
180
+
181
+ function renderGrid(pages) {
182
+ if (pages.length === 0) {
183
+ grid.innerHTML = '';
184
+ emptyState.classList.remove('hidden');
185
+ pageCount.textContent = '';
186
+ return;
187
+ }
188
+
189
+ emptyState.classList.add('hidden');
190
+ pageCount.textContent = pages.length + ' visualization' + (pages.length !== 1 ? 's' : '');
191
+
192
+ grid.innerHTML = pages.map(page => {
193
+ const title = page.title || page.filename;
194
+ const time = formatTimestamp(page.timestamp);
195
+ const href = '/?page=' + encodeURIComponent(page.filename);
196
+ return `<a class="card" href="${href}">
197
+ <div class="card-title">${escapeHtml(title)}</div>
198
+ <div class="card-time">${escapeHtml(time)}</div>
199
+ <div class="card-filename">${escapeHtml(page.filename)}</div>
200
+ </a>`;
201
+ }).join('');
202
+ }
203
+
204
+ function escapeHtml(str) {
205
+ const div = document.createElement('div');
206
+ div.textContent = str;
207
+ return div.innerHTML;
208
+ }
209
+
210
+ async function loadPages() {
211
+ try {
212
+ const res = await fetch('/api/pages');
213
+ if (!res.ok) throw new Error('Failed to fetch');
214
+ const pages = await res.json();
215
+ renderGrid(pages);
216
+ } catch {
217
+ renderGrid([]);
218
+ }
219
+ }
220
+
221
+ // -- Keyboard shortcut --
222
+ document.addEventListener('keydown', (e) => {
223
+ if (e.key === 'g' || e.key === 'G') {
224
+ window.location.href = '/';
225
+ }
226
+ if (e.key === 'Escape') {
227
+ window.location.href = '/';
228
+ }
229
+ });
230
+
231
+ // -- WebSocket with auto-reconnect --
232
+ let ws = null;
233
+ let wsReconnectDelay = 500;
234
+ const WS_MAX_DELAY = 10000;
235
+
236
+ function connectWebSocket() {
237
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
238
+ ws = new WebSocket(`${protocol}//${location.host}/ws`);
239
+
240
+ ws.onopen = () => {
241
+ wsReconnectDelay = 500;
242
+ wsIndicator.classList.remove('disconnected');
243
+ wsIndicator.title = 'WebSocket connected';
244
+ };
245
+
246
+ ws.onmessage = (e) => {
247
+ try {
248
+ const msg = JSON.parse(e.data);
249
+ if (msg.type === 'reload') loadPages();
250
+ } catch {}
251
+ };
252
+
253
+ ws.onclose = () => {
254
+ wsIndicator.classList.add('disconnected');
255
+ wsIndicator.title = 'WebSocket disconnected - reconnecting...';
256
+ scheduleReconnect();
257
+ };
258
+
259
+ ws.onerror = () => {};
260
+ }
261
+
262
+ function scheduleReconnect() {
263
+ setTimeout(() => {
264
+ connectWebSocket();
265
+ wsReconnectDelay = Math.min(wsReconnectDelay * 2, WS_MAX_DELAY);
266
+ }, wsReconnectDelay);
267
+ }
268
+
269
+ // -- Boot --
270
+ connectWebSocket();
271
+ loadPages();
272
+ </script>
273
+ </body>
274
+ </html>