htmlboard 0.1.0 → 0.2.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/src/server.js CHANGED
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url';
7
7
  const __filename = fileURLToPath(import.meta.url);
8
8
  const __dirname = path.dirname(__filename);
9
9
  const CLIENT_DIR = path.join(__dirname, 'client');
10
+ const { version: VERSION } = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
10
11
 
11
12
  // Supported MIME types
12
13
  const MIME_TYPES = {
@@ -234,6 +235,89 @@ export function loadDesignsManifest(baseDir, rootDir = baseDir) {
234
235
  return normalizeManifest(discoverDesigns(baseDir), prefix);
235
236
  }
236
237
 
238
+ /** Folders the watcher leaves alone: hidden ones (.git, .vscode, …) and dependencies */
239
+ const skipWatching = (name) => name.startsWith('.') || name === 'node_modules';
240
+
241
+ /**
242
+ * Watch a folder and everything under it, calling onChange(path, isFolder) with the path (relative,
243
+ * with forward slashes) of every file or folder created, changed or removed.
244
+ * macOS and Windows watch a whole tree natively. On Linux Node emulates that with one watcher per
245
+ * file, which loses a file for good once it is saved by replacing it (writing a copy and renaming
246
+ * it over, as many editors do), so there every folder gets its own watcher instead.
247
+ * Returns a function that stops watching.
248
+ */
249
+ export function watchTree(rootDir, onChange) {
250
+ const relative = (full) => path.relative(rootDir, full).split(path.sep).join('/');
251
+ const skipped = (rel) => rel.split('/').some(skipWatching);
252
+
253
+ if (process.platform !== 'linux') {
254
+ const watcher = fs.watch(rootDir, { recursive: true }, (eventType, filename) => {
255
+ if (!filename) return;
256
+ const rel = String(filename).split(path.sep).join('/');
257
+ if (skipped(rel)) return;
258
+ let isFolder;
259
+ try {
260
+ isFolder = fs.statSync(path.join(rootDir, rel)).isDirectory();
261
+ } catch {
262
+ isFolder = path.extname(rel) === ''; // gone: a name without an extension was likely a folder
263
+ }
264
+ onChange(rel, isFolder);
265
+ });
266
+ return () => watcher.close();
267
+ }
268
+
269
+ const watchers = new Map(); // folder → its watcher
270
+ const unwatch = (dir) => {
271
+ for (const [watched, watcher] of watchers) {
272
+ if (watched === dir || watched.startsWith(dir + path.sep)) {
273
+ watcher.close();
274
+ watchers.delete(watched);
275
+ }
276
+ }
277
+ };
278
+ const watch = (dir) => {
279
+ if (watchers.has(dir)) return;
280
+ let watcher;
281
+ try {
282
+ watcher = fs.watch(dir, (eventType, name) => {
283
+ if (!name) return;
284
+ const full = path.join(dir, String(name));
285
+ const rel = relative(full);
286
+ if (!rel || rel.startsWith('..') || skipped(rel)) return;
287
+ let isDir = false;
288
+ try {
289
+ isDir = fs.statSync(full).isDirectory();
290
+ } catch {
291
+ // Removed
292
+ }
293
+ const wasDir = watchers.has(full);
294
+ if (isDir) watch(full);
295
+ else if (wasDir) unwatch(full);
296
+ onChange(rel, isDir || wasDir);
297
+ });
298
+ } catch {
299
+ return; // gone already, or unreadable
300
+ }
301
+ watcher.on('error', () => unwatch(dir));
302
+ watchers.set(dir, watcher);
303
+ let entries = [];
304
+ try {
305
+ entries = fs.readdirSync(dir, { withFileTypes: true });
306
+ } catch {
307
+ // Unreadable: its own watcher still reports what happens in it
308
+ }
309
+ for (const entry of entries) {
310
+ if (entry.isDirectory() && !skipWatching(entry.name)) watch(path.join(dir, entry.name));
311
+ }
312
+ };
313
+
314
+ watch(rootDir);
315
+ return () => {
316
+ for (const watcher of watchers.values()) watcher.close();
317
+ watchers.clear();
318
+ };
319
+ }
320
+
237
321
  /**
238
322
  * Find the next available port using net.createServer
239
323
  */
@@ -309,7 +393,7 @@ export function createServer(options = {}) {
309
393
  // API: Health status
310
394
  if (pathname === '/api/health') {
311
395
  res.writeHead(200, { 'Content-Type': 'application/json' });
312
- res.end(JSON.stringify({ status: 'ok', dir: targetDir, root: rootDir }));
396
+ res.end(JSON.stringify({ status: 'ok', version: VERSION, dir: targetDir, root: rootDir }));
313
397
  return;
314
398
  }
315
399
 
@@ -369,37 +453,30 @@ export function createServer(options = {}) {
369
453
  }
370
454
  }
371
455
 
372
- // File watcher for live reload with debouncing
456
+ // Live reload. Saves come in bursts (an editor writing a file several times, an agent saving a
457
+ // page and its stylesheet together), so changes are collected for 100 ms and then sent once each.
458
+ const changed = new Map(); // path → is a folder
373
459
  let watchDebounceTimer = null;
374
- let watcher = null;
460
+ let stopWatching = null;
375
461
 
376
462
  try {
377
- watcher = fs.watch(rootDir, { recursive: true }, (eventType, filename) => {
378
- if (!filename) return;
379
- const normalized = filename.replace(/\\/g, '/');
380
-
381
- // Ignore noise: hidden files and folders (.git, .vscode, …), dependencies, editor temp files
382
- if (
383
- normalized.split('/').some((part) => part.startsWith('.') || part === 'node_modules') ||
384
- normalized.endsWith('~') ||
385
- normalized.endsWith('.tmp')
386
- ) {
387
- return;
388
- }
389
-
463
+ stopWatching = watchTree(rootDir, (file, isFolder) => {
464
+ if (file.endsWith('~') || file.endsWith('.tmp')) return; // editor scratch files
465
+ changed.set(file, isFolder);
390
466
  clearTimeout(watchDebounceTimer);
391
467
  watchDebounceTimer = setTimeout(() => {
392
- const isManifest = normalized === manifestFile;
393
- const isHtml = normalized.endsWith('.html');
394
- const eventData = JSON.stringify({
395
- type: isManifest ? 'manifest' : 'file',
396
- file: normalized,
397
- isHtml,
398
- timestamp: Date.now()
399
- });
400
-
401
- for (const client of clients) {
402
- client.write(`event: change\ndata: ${eventData}\n\n`);
468
+ const files = [...changed];
469
+ changed.clear();
470
+ for (const [file, isFolder] of files) {
471
+ const eventData = JSON.stringify({
472
+ type: file === manifestFile ? 'manifest' : isFolder ? 'folder' : 'file',
473
+ file,
474
+ isHtml: !isFolder && /\.html?$/i.test(file),
475
+ timestamp: Date.now()
476
+ });
477
+ for (const client of clients) {
478
+ client.write(`event: change\ndata: ${eventData}\n\n`);
479
+ }
403
480
  }
404
481
  }, 100);
405
482
  });
@@ -412,7 +489,8 @@ export function createServer(options = {}) {
412
489
  targetDir,
413
490
  rootDir,
414
491
  close: () => {
415
- if (watcher) watcher.close();
492
+ clearTimeout(watchDebounceTimer);
493
+ if (stopWatching) stopWatching();
416
494
  for (const client of clients) {
417
495
  client.end();
418
496
  }