← back to Kickbacks Ai Upstream

src/loopback.ts

349 lines

import * as vscode from "vscode";
import { createServer, Server } from "node:http";
import { randomBytes } from "node:crypto";
import { dlog, dlogRaw } from "./log";
import { type AdSurface, parseAdSurface } from "./types/surface";

/** The base URL the WEBVIEW must use to reach this loopback. On VS Code
 *  Remote/Server the webview runs on the *client*, so raw 127.0.0.1 (the
 *  remote host's loopback) is unreachable; vscode.env.asExternalUri returns a
 *  tunneled URL the client can reach (identity no-op on local desktop).
 *  Always includes the /vibe-ads/<token> path so the block uses it verbatim.
 *  Falls back to 127.0.0.1 if the API is unavailable/throws. */
export async function resolveLoopbackBase(
  port: number, token: string): Promise<string> {
  const local = `http://127.0.0.1:${port}`;
  try {
    const ext = await vscode.env.asExternalUri(vscode.Uri.parse(local));
    return ext.toString().replace(/\/+$/, "") + "/vibe-ads/" + token;
  } catch {
    return local + "/vibe-ads/" + token;
  }
}

export type LoopbackMetricKind =
  | "impression_rendered"
  | "impression_viewable"
  | "view_tick"
  | "view_threshold_met"
  | "error_impression";

export interface LoopbackMetricPayload {
  surface?: AdSurface;
  visibleMs?: number;
  sessionNonce?: string;
  eventUuid?: string;
  viewable?: boolean;
  viewPct?: number;
  viewMs?: number;
  /** The ad the webview CLAIMS this event belongs to (`ad=` query param on
   *  the block's metric GETs). For up to one 10s /ad poll after a rotation
   *  the webview is still running the OLD ad's view sessions, so the host
   *  must not stamp attribution from its current activeAd (audit #17). The
   *  deployed block sends the ad TEXT here (its view sessions key on AD);
   *  hosts resolve either form against their recently-served registry.
   *  Deliberately NOT named adId: downstream senders spread this payload
   *  over already-resolved billing fields. */
  claimedAdId?: string;
}

export interface LoopbackAdPayload {
  adText: string;
  clickUrl: string;
  iconUrl: string;
  adId: string;
  campaignId: string;
}

export interface LoopbackHandlers {
  onEvent: (kind: LoopbackMetricKind, payload: LoopbackMetricPayload) => void;
  onClick: (clickToken: string,
    surface?: AdSurface,
    visibleMs?: number,
    eventUuid?: string,
    /** `ad=` query param — see LoopbackMetricPayload.claimedAdId. */
    claimedAdId?: string) => void;
  getActivity: () => Record<string, unknown>;
  getCurrentAd: () => LoopbackAdPayload | null;
  /** Optional: every webview-relayed /log POST is mirrored here in addition
   *  to being appended to ~/.vibe-ads/debug.log via dlogRaw. Lets extension-
   *  scope code observe block lifecycle events (e.g., `block.start`) so we
   *  can detect "patch applied but webview never picked it up" desync
   *  without having to re-parse the debug.log file. Safe to leave unset. */
  onWebviewLog?: (raw: string) => void;
  /** Optional E2E driver surface. When set, the loopback exposes
   *  `GET /test/<name>?…` routes that delegate to this handler. Used by the
   *  kickbacks.test.* hooks so external scripts (curl, CI runners) can fire
   *  impressions / clicks / views and read the result without needing a
   *  running VS Code command host. Token-gated by the same loopback prefix.
   *  Self-gated at the handler level (testHooksEnabled() inside TestHooks),
   *  so removing the sentinel mid-session disables the routes on next call. */
  onTestRoute?: (name: string, params: URLSearchParams) =>
    Promise<{ status: number; body: unknown }>;
}

/** Loopback startup options. When `token` and/or `preferredPort` are
 *  passed, the loopback reuses them — the URL baked into the webview
 *  block stays stable across activations, so a webview that loaded a
 *  prior session's patched index.js can still reach the current
 *  ext-host. Without this, every ext-host restart produced a new token
 *  + port, the running webview's anchors pointed at a torn-down
 *  loopback, and clicks + telemetry vanished silently (the "patched
 *  but webview cached" desync). Both default to per-call random + OS-
 *  assigned, preserving the old behavior for callers that don't opt in. */
export interface LoopbackStartOpts {
  /** Reuse a stable token instead of minting a fresh random one each
   *  call. Caller is responsible for persistence (typically
   *  globalState). When omitted, a per-call random token is used. */
  token?: string;
  /** Try this port first; if `EADDRINUSE`, retry the next N ports
   *  (`preferredPortRange`, default 4) and finally fall back to OS-
   *  assigned (port=0). When omitted, OS-assigned is used directly. */
  preferredPort?: number;
  /** How many sequential ports to try starting at `preferredPort`
   *  before giving up and falling back to OS-assigned. */
  preferredPortRange?: number;
}

export class Loopback {
  private server: Server | null = null;
  private token = "";
  constructor(private h: LoopbackHandlers) {}

  /** True while the underlying HTTP server is bound and accepting
   *  connections. Lets bootLoopback() detect a stale shared server (owner
   *  already stopped) and fall through to a fresh bind. */
  isRunning(): boolean { return !!this.server && this.server.listening; }

  /** The handler set this instance was constructed with. Read by
   *  bootLoopback() to lift a later caller's wiring onto the ONE shared
   *  server (audit #7). */
  handlers(): LoopbackHandlers { return this.h; }

  /** Live handler swap (audit #7): the request closure dispatches through
   *  `this.h` at request time, so replacing it re-routes every route —
   *  /ad, /activity, the metric relays, /click, /log, /test — without
   *  rebinding the port. Used by bootLoopback() so the production webview
   *  wiring can take over ad-serving from the debug stub on the single
   *  shared server instead of EADDRINUSE-ing onto stablePort+1. */
  setHandlers(h: LoopbackHandlers): void { this.h = h; }

  start(opts: LoopbackStartOpts = {}): Promise<{ port: number; token: string }> {
    this.token = opts.token && /^[0-9a-f]{16,}$/i.test(opts.token)
      ? opts.token
      : randomBytes(16).toString("hex");
    const prefix = `/vibe-ads/${this.token}/`;
    this.server = createServer((req, res) => {
      // 2A-M02: bound per-connection lifetime. A misbehaving webview that
      // opens a request and never sends data (or sends headers and stalls)
      // would otherwise park a connection until process exit, leaking fds.
      // Loopback is 127.0.0.1-only and token-gated; values are picked to
      // tolerate slow disks under /activity (LogTail.current readSync).
      try {
        if (typeof req.setTimeout === "function") req.setTimeout(10000);
      } catch { /* never block the request loop on timeout config */ }
      try {
        // CORS: in VS Code 1.120+ the chat webview runs from a
        // `vscode-webview://` origin and Chromium enforces CORS on its fetch
        // to this http loopback. Without ACAO every relay (impressions,
        // clicks, activity, debug log) is browser-blocked → billing telemetry
        // silently dies. `dlog`'s `content-type: application/json` POST is a
        // non-simple request, so the webview fires an OPTIONS preflight first;
        // it must succeed independently of the token path. Loopback is bound
        // to 127.0.0.1 and still token-gated for real routes, so `*` here only
        // widens who may *read the response*, not who can reach the port.
        res.setHeader("Access-Control-Allow-Origin", "*");
        if (req.method === "OPTIONS") {
          res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
          res.setHeader("Access-Control-Allow-Headers", "content-type");
          res.setHeader("Access-Control-Max-Age", "86400");
          res.statusCode = 204; res.end(); return;
        }
        const url = new URL(req.url || "/", "http://127.0.0.1");
        if (!url.pathname.startsWith(prefix)) { res.statusCode = 404; res.end(); return; }
        const route = url.pathname.slice(prefix.length);
        if (route === "activity") {
          res.setHeader("content-type", "application/json");
          res.end(JSON.stringify(this.h.getActivity())); return;
        }
        if (route === "ad") {
          res.setHeader("content-type", "application/json");
          res.end(JSON.stringify(this.h.getCurrentAd() ?? {})); return;
        }
        if (route === "impression_rendered" || route === "impression_viewable"
            || route === "view_tick" || route === "view_threshold_met"
            || route === "error_impression") {
          // Mirror the loopback.click trace: every impression / view-
          // tick that reaches the extension leaves a log entry BEFORE
          // dedupe + onEvent dispatch, so an operator grepping the
          // debug log can see what the webview actually pinged
          // (separate from what the dedupe layer let through).
          const payload = metricPayload(route, url);
          dlog("ext", "loopback.event",
            { route, surface: payload.surface || "",
              visibleMs: payload.visibleMs, eventUuid: payload.eventUuid },
            { corr: url.searchParams.get("corr") || "" });
          this.h.onEvent(route, payload);
          res.statusCode = 204; res.end(); return;
        }
        if (route === "click") {
          const sRaw = url.searchParams.get("surface") || "";
          const s = parseAdSurface(sRaw);
          // visible_ms is included by the webview block so the extension
          // can apply the click-threshold floor (anti-misclick / anti-bot).
          // Parse defensively: NaN / negative => treat as 0 (unknown).
          const vmsRaw = Number(url.searchParams.get("visible_ms") || "");
          const visibleMs = Number.isFinite(vmsRaw) && vmsRaw >= 0
            ? Math.floor(vmsRaw) : 0;
          const eventUuid = parseEventUuid(url.searchParams.get("event_uuid"));
          // Audit #17: lift the claimed ad (when the block sends it) so the
          // host can bill a click on the OLD ad's anchor — emitted during the
          // 10s /ad poll lag after a rotation — to the OLD campaign.
          const claimedAdId = url.searchParams.get("ad") || undefined;
          dlog("ext", "loopback.click", { ct: url.searchParams.get("ct") || "",
            surface: s || "", visibleMs, eventUuid },
            { corr: url.searchParams.get("corr") || "" });
          this.h.onClick(url.searchParams.get("ct") || "", s, visibleMs,
            eventUuid, claimedAdId);
          res.statusCode = 204;
          res.end(); return;
        }
        if (route === "log") {
          // Webview block relays its lifecycle here (the only channel out of
          // the sandbox). Proves the loopback is reachable when lines appear.
          // 2A-M01: cap BEFORE concatenation, not after. The prior
          // body += c; if (body.length > 16000) req.destroy() let a single
          // chunk push body up to chunk-size + 16000 before destroying.
          let body = "";
          let overflowed = false;
          req.on("data", (c) => {
            if (overflowed) return;
            const cs = String(c);
            if (body.length + cs.length > 16000) {
              overflowed = true;
              req.destroy();
              return;
            }
            body += cs;
          });
          req.on("end", () => {
            if (overflowed) return;
            dlogRaw(body);
            try { this.h.onWebviewLog?.(body); }
            catch { /* observer best-effort; never affects the log relay */ }
          });
          res.statusCode = 204; res.end(); return;
        }
        // E2E test-hook driver routes: GET /test/<name>?…. Only handled when
        // a host wired an onTestRoute (see LoopbackHandlers). The handler is
        // itself responsible for the testHooksEnabled() gate — returns 403
        // with ok:false when off, so a removed sentinel mid-session is
        // observed on the very next call without restarting the server.
        if (route.startsWith("test/") && this.h.onTestRoute) {
          const name = route.slice("test/".length);
          this.h.onTestRoute(name, url.searchParams).then((r) => {
            res.statusCode = r.status;
            res.setHeader("content-type", "application/json");
            res.end(JSON.stringify(r.body));
          }).catch((e) => {
            dlog("ext", "loopback.test_route_error",
              { name, msg: e instanceof Error ? e.message : String(e) });
            try { res.statusCode = 500; res.end(); } catch { /* ignore */ }
          });
          return;
        }
        res.statusCode = 404; res.end();
      } catch (e) {
        dlog("ext", "loopback.handler_error",
          { msg: e instanceof Error ? e.message : String(e) });
        try { res.statusCode = 500; res.end(); } catch { /* ignore */ }
      }
    });
    // Build the candidate port list: preferredPort (if any) + N-1
    // sequential fallbacks, then 0 (OS-assigned) as the final fallback.
    // Each EADDRINUSE moves to the next candidate; any other listen
    // error resolves with port=-1 (same fail-safe shape as before).
    const candidates: number[] = [];
    if (typeof opts.preferredPort === "number" && opts.preferredPort > 0) {
      const range = Math.max(1, opts.preferredPortRange ?? 4);
      for (let i = 0; i < range; i++)
        candidates.push(opts.preferredPort + i);
    }
    candidates.push(0);

    return new Promise((resolveP) => {
      const tryNext = (i: number): void => {
        if (!this.server) return resolveP({ port: -1, token: this.token });
        const port = candidates[i];
        const onErr = (e: NodeJS.ErrnoException): void => {
          // EADDRINUSE on a preferred port → try the next candidate.
          // Any other error → give up (same fail-safe shape as the
          // pre-refactor handler).
          if (e && (e.code === "EADDRINUSE" || e.code === "EACCES")
              && i + 1 < candidates.length) {
            try { this.server!.removeListener("error", onErr); }
            catch { /* ignore */ }
            // The server is already closed by the failed listen; we can
            // re-issue listen() on the same server instance.
            tryNext(i + 1);
          } else {
            resolveP({ port: -1, token: this.token });
          }
        };
        this.server!.once("error", onErr);
        this.server!.listen(port, "127.0.0.1", () => {
          try { this.server!.removeListener("error", onErr); }
          catch { /* ignore */ }
          const addr = this.server!.address();
          const bound = typeof addr === "object" && addr ? addr.port : -1;
          resolveP({ port: bound, token: this.token });
        });
      };
      tryNext(0);
    });
  }

  stop(): Promise<void> {
    return new Promise((resolveP) => {
      if (!this.server) return resolveP();
      this.server.close(() => resolveP());
      this.server = null;
    });
  }
}

const EVENT_UUID_RE =
  /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

function parseEventUuid(raw: string | null): string | undefined {
  return raw && EVENT_UUID_RE.test(raw) ? raw : undefined;
}

function metricPayload(route: string, url: URL): LoopbackMetricPayload {
  const surfaceRaw = url.searchParams.get("surface") || "";
  const surface = parseAdSurface(surfaceRaw);
  const visible = Number(url.searchParams.get("visible_ms") || "");
  const visibleMs = Number.isFinite(visible) && visible >= 0
    ? Math.floor(visible)
    : undefined;
  const sessionNonce = url.searchParams.get("session") || undefined;
  const eventUuid = parseEventUuid(url.searchParams.get("event_uuid"));
  const claimedAdId = url.searchParams.get("ad") || undefined;
  const payload: LoopbackMetricPayload = {};
  if (surface) payload.surface = surface;
  if (typeof visibleMs === "number") payload.visibleMs = visibleMs;
  if (sessionNonce) payload.sessionNonce = sessionNonce;
  if (eventUuid) payload.eventUuid = eventUuid;
  if (claimedAdId) payload.claimedAdId = claimedAdId;
  if (route === "view_threshold_met" || route === "error_impression") {
    // error_impression is the 5 s safety-net cap from block.asset.js
    // (MAX_SESSION_MS). It represents a session that DID stay visible —
    // the bill should reflect a fully-viewable impression, same shape
    // as view_threshold_met.
    payload.viewable = true;
    payload.viewPct = 100;
    if (typeof visibleMs === "number") payload.viewMs = visibleMs;
  }
  return payload;
}