All files / cli/src/dev-server hmr-server.js

77.77% Statements 21/27
76.92% Branches 10/13
75% Functions 6/8
83.33% Lines 20/24

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106                                                                                      19x 19x     12x 9x   3x         19x         9x 9x 7x 7x                 19x       19x   5x 5x 4x 4x 4x                     20x 9x   20x                  
/**
 * HMR WebSocket Server
 *
 * Attaches a WebSocket server to an existing HTTP server (sharing the
 * same port), tracks connected dev clients, and exposes a broadcast()
 * helper that serializes a message once and fan-outs to every live
 * client. Used by the Coherent dev server to push hot-update events
 * to browser-side HMR clients.
 *
 * Wire protocol matches packages/client/src/hmr/client.js — server
 * sends `{type, filePath?, webPath?, error?, updateType?}` objects;
 * client switches on `type` and handles updates, reloads, errors.
 *
 * @module @coherent.js/cli/dev-server/hmr-server
 */
 
import { WebSocketServer } from 'ws';
import { isHostAllowed, isOriginAllowed } from './access.js';
 
/**
 * @typedef {Object} HmrServer
 * @property {(message: object) => void} broadcast - Serialize and send a JSON message to every live client.
 * @property {() => void} close - Close the WebSocket server and drop all clients.
 * @property {() => number} clientCount - Current number of live clients (for tests / diagnostics).
 */
 
/**
 * Create and attach an HMR WebSocket server to an existing HTTP server.
 *
 * The WS server shares the HTTP server's port — clients connect to
 * `ws://host:port` (no separate port to manage). New clients receive
 * a `{type: 'connected'}` ack on open. Dead clients are pruned on
 * the next broadcast.
 *
 * Upgrades are refused (403) when the Host header does not name this
 * server or when a browser Origin belongs to another site: without that,
 * any page the developer visits could subscribe to the change feed.
 *
 * @param {import('node:http').Server} httpServer - HTTP server to attach to.
 * @param {import('./access.js').HostOptions} [options] - Accepted hosts (see access.js).
 * @returns {HmrServer}
 */
export function createHmrServer(httpServer, options = {}) {
  const hostOptions = { host: options.host, allowedHosts: options.allowedHosts ?? [] };
  const wss = new WebSocketServer({
    server: httpServer,
    verifyClient: ({ origin, req }, done) => {
      if (isHostAllowed(req.headers.host, hostOptions) && isOriginAllowed(origin, hostOptions)) {
        done(true);
      } else {
        done(false, 403, 'Forbidden');
      }
    },
  });
 
  wss.on('connection', (socket) => {
    // Defer the initial ack by one event-loop turn so the client-side
    // 'open' event has time to resolve before the message arrives.
    // Without this, the 'message' event fires synchronously during the
    // WS handshake — before the client can attach its first listener.
    setTimeout(() => {
      if (socket.readyState === socket.OPEN) {
        try {
          socket.send(JSON.stringify({ type: 'connected' }));
        } catch {
          // Client may have disconnected mid-handshake; ignore.
        }
      }
    }, 0);
  });
 
  // Surface listener errors instead of crashing the dev server.
  wss.on('error', (err) => {
    console.warn('[coherent dev] HMR server error:', err.message);
  });
 
  return {
    broadcast(message) {
      const frame = JSON.stringify(message); // throws on circular — surfaces caller bug loudly
      for (const client of wss.clients) {
        Eif (client.readyState === client.OPEN) {
          try {
            client.send(frame);
          } catch {
            // Dead socket — let `ws` clean it up on its own close event.
          }
        }
      }
    },
    close() {
      // terminate(), not close(): close() waits up to 30s for the browser to
      // answer the closing handshake, and a tab that doesn't (busy, or
      // already reconnecting) held the dev server's shutdown that long.
      for (const client of wss.clients) {
        try { client.terminate(); } catch { /* ignore */ }
      }
      wss.close();
    },
    clientCount() {
      let n = 0;
      for (const c of wss.clients) if (c.readyState === c.OPEN) n++;
      return n;
    },
  };
}