Skip to main content

src/http_server_mock/internal/worker.mjs

// HTTP server and request-matching logic that runs inside the Worker thread.
// The main thread communicates with it via MessagePort; SharedArrayBuffer +
// Atomics are used so the main thread can block synchronously until a reply
// arrives without polling.

import http from "node:http";
import { workerData } from "node:worker_threads";

const { signalBuffer, requestedPort, workerPort } = workerData;
const signal = new Int32Array(signalBuffer);

const state = {
  stubs: [],
  requests: [],
  scenarios: new Map(),
};

const server = http.createServer((req, res) => {
  const chunks = [];
  req.on("data", (chunk) => chunks.push(chunk));
  req.on("end", () => {
    const body = Buffer.concat(chunks).toString("utf8");
    const url = new URL(req.url, "http://localhost");
    const path = url.pathname;
    const query = url.search ? url.search.slice(1) : null;
    const headers = normalizeHeaders(req.headers);

    if (path.startsWith("/__admin")) {
      handleAdmin(req.method.toUpperCase(), path, body, (status, data) => {
        res.statusCode = status;
        res.setHeader("content-type", "application/json");
        res.end(JSON.stringify(data));
      });
    } else {
      handleStub(
        req.method.toUpperCase(),
        path,
        query,
        headers,
        body,
        (status, hdrs, content, delayMs) => {
          const send = () => {
            res.statusCode = status;
            for (const [k, v] of Object.entries(hdrs)) res.setHeader(k, v);
            res.end(content);
          };
          delayMs > 0 ? setTimeout(send, delayMs) : send();
        },
      );
    }
  });
});

server.listen(requestedPort, "0.0.0.0", () => {
  const actualPort = server.address().port;
  workerPort.postMessage({ ok: true, port: actualPort });
  signalReady();
});

server.on("error", (err) => {
  workerPort.postMessage({ ok: false, error: err.message });
  signalReady();
});

workerPort.on("message", ({ cmd, data }) => {
  let result;
  switch (cmd) {
    case "addStub":
      result = addStub(data);
      break;
    case "removeStub":
      removeStub(data);
      result = null;
      break;
    case "clearStubs":
      state.stubs = [];
      result = null;
      break;
    case "getStubs":
      result = JSON.stringify(state.stubs);
      break;
    case "getRequests":
      result = JSON.stringify(state.requests);
      break;
    case "clearRequests":
      state.requests = [];
      result = null;
      break;
    case "stop":
      server.close();
      result = null;
      break;
    default:
      result = null;
  }
  workerPort.postMessage({ result });
  signalReady();
});

function signalReady() {
  Atomics.store(signal, 0, 1);
  Atomics.notify(signal, 0, 1);
}

function handleAdmin(method, path, body, send) {
  if (method === "GET" && path === "/__admin/health")
    return send(200, { status: "ok" });
  if (method === "GET" && path === "/__admin/stubs")
    return send(200, state.stubs);
  if (method === "POST" && path === "/__admin/stubs") {
    const id = addStub(body);
    return typeof id === "string"
      ? send(201, { id })
      : send(400, { error: id.error });
  }
  if (method === "DELETE" && path === "/__admin/stubs") {
    state.stubs = [];
    return send(200, { status: "ok" });
  }
  if (method === "GET" && path === "/__admin/requests")
    return send(200, state.requests);
  if (method === "DELETE" && path === "/__admin/requests") {
    state.requests = [];
    return send(200, { status: "ok" });
  }
  send(404, { error: "Unknown admin endpoint" });
}

function addStub(stubJson) {
  try {
    const stub = JSON.parse(stubJson);
    const idx = state.stubs.findIndex((existing) => existing.id === stub.id);
    if (idx >= 0) state.stubs[idx] = stub;
    else state.stubs.push(stub);
    state.stubs.sort((left, right) => left.priority - right.priority);
    return stub.id;
  } catch (err) {
    return { error: "Invalid stub JSON: " + err.message };
  }
}

function removeStub(stubId) {
  state.stubs = state.stubs.filter((stub) => stub.id !== stubId);
}

function handleStub(method, path, query, headers, body, send) {
  const recorded = {
    id: "req_" + Date.now() + "_" + Math.random().toString(36).slice(2),
    method,
    path,
    query,
    headers,
    body,
    timestamp_ms: Date.now(),
    matched_stub_id: null,
  };

  const match = findMatch(recorded);
  if (!match) {
    state.requests.push(recorded);
    return send(
      404,
      { "content-type": "application/json" },
      JSON.stringify({ status: 404, message: "No stub matched", path }),
      0,
    );
  }

  recorded.matched_stub_id = match.stub.id;
  state.requests.push(recorded);
  advanceScenario(match.stub);

  const def = match.stub.response;
  const responseHeaders = {};
  (def.headers || []).forEach(({ key, value }) => {
    responseHeaders[key] = value;
  });
  send(def.status, responseHeaders, getResponseBodyContent(def.body), def.delay_ms ?? 0);
}

function findMatch(recorded) {
  const candidates = state.stubs
    .filter((stub) => scenarioMatches(stub))
    .filter((stub) => stubMatches(stub, recorded))
    .sort((left, right) => {
      if (left.priority !== right.priority) return left.priority - right.priority;
      return scoreStub(right, recorded) - scoreStub(left, recorded);
    });
  return candidates.length === 0 ? null : { stub: candidates[0] };
}

function scenarioMatches(stub) {
  if (!stub.scenario) return true;
  const currentState = state.scenarios.get(stub.scenario.name);
  if (stub.scenario.required_state == null) return currentState === undefined;
  return currentState === stub.scenario.required_state;
}

function stubMatches(stub, recorded) {
  const matcher = stub.request;
  if (matcher.method && matcher.method !== recorded.method) return false;
  if (matcher.path && !matchString(matcher.path, recorded.path)) return false;
  const queryParams = parseQuery(recorded.query || "");
  for (const { key, matcher: sm } of matcher.query_params || []) {
    const value = queryParams.get(key);
    if (value === undefined) {
      if (sm.type !== "any") return false;
    } else if (!matchString(sm, value)) return false;
  }
  for (const { key, matcher: sm } of matcher.headers || []) {
    const value = recorded.headers[key.toLowerCase()];
    if (value === undefined) {
      if (sm.type !== "any") return false;
    } else if (!matchString(sm, value)) return false;
  }
  if (matcher.body && !matchBody(matcher.body, recorded.body)) return false;
  return true;
}

function matchString(stringMatcher, value) {
  switch (stringMatcher.type) {
    case "exact":
      return value === stringMatcher.value;
    case "contains":
      return value.includes(stringMatcher.value);
    case "prefix":
      return value.startsWith(stringMatcher.value);
    case "suffix":
      return value.endsWith(stringMatcher.value);
    case "any":
      return true;
    default:
      return false;
  }
}

function matchBody(bodyMatcher, actual) {
  switch (bodyMatcher.type) {
    case "any":
      return true;
    case "exact":
      return actual === bodyMatcher.value;
    case "contains":
      return actual.includes(bodyMatcher.value);
    case "json": {
      try {
        return (
          JSON.stringify(JSON.parse(actual)) ===
          JSON.stringify(JSON.parse(bodyMatcher.value))
        );
      } catch {
        return false;
      }
    }
    default:
      return false;
  }
}

function scoreStub(stub, _recorded) {
  const matcher = stub.request;
  let score = 0;
  if (matcher.method) score += 100;
  if (matcher.path) score += matcher.path.type === "exact" ? 80 : 40;
  score += (matcher.query_params || []).length * 20;
  score += (matcher.headers || []).length * 10;
  if (matcher.body) score += matcher.body.type === "exact" ? 50 : 30;
  return score;
}

function advanceScenario(stub) {
  if (!stub.scenario || !stub.scenario.new_state) return;
  state.scenarios.set(stub.scenario.name, stub.scenario.new_state);
}

function parseQuery(queryString) {
  const map = new Map();
  if (!queryString) return map;
  for (const part of queryString.split("&")) {
    const idx = part.indexOf("=");
    if (idx >= 0) map.set(part.slice(0, idx), part.slice(idx + 1));
    else map.set(part, "");
  }
  return map;
}

function getResponseBodyContent(body) {
  if (!body || body.type === "none") return "";
  return body.value || "";
}

function normalizeHeaders(headers) {
  const normalized = {};
  for (const [key, value] of Object.entries(headers || {})) {
    normalized[key.toLowerCase()] = Array.isArray(value)
      ? value.join(", ")
      : value;
  }
  return normalized;
}