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

77.77% Statements 21/27
76.92% Branches 10/13
75% Functions 6/8
84% Lines 21/25

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 107 108 109 110 111 112 113 114 115 116                                                                                      19x       19x     12x       9x   3x         19x         9x 9x 7x 7x                 19x       19x   5x 5x 4x 4x 4x                     20x 9x 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;
    },
  };
}