diff --git a/src/gateway/transport.ts b/src/gateway/transport.ts index 6b6e88d..3339f9c 100644 --- a/src/gateway/transport.ts +++ b/src/gateway/transport.ts @@ -17,6 +17,33 @@ export interface Transport { // loudly instead of leaving a gateway that 403s all inbound traffic. const TUNNEL_CONNECT_TIMEOUT_MS = 15_000; +// The tunnel server idle-caps parked intake slots on a timer, and the SDK +// reports each one via bare console.warn — on a healthy gateway that one +// line repeats forever and buries real warnings. A warn call is dropped only +// when its first argument contains all three markers. +const IDLE_CAP_WARNING_MARKERS = ["/_system/intake slot=", "status=408", "reason=intake-idle-cap"]; + +// Tags the wrapped console.warn so repeat installs (gateway restarts within +// one process) recognize it and no-op instead of stacking wrappers. +const WARN_FILTER_TAG = Symbol.for("inkbox.tunnelWarnFilter"); + +// Replace console.warn with a filter that drops the expected idle-cap line +// and forwards everything else — 401s, disconnects, non-string args — to the +// original warn unchanged (fail-open). Idempotent. +export function installTunnelWarnFilter(): void { + const original = console.warn as typeof console.warn & { [WARN_FILTER_TAG]?: true }; + if (original[WARN_FILTER_TAG]) return; + const filtered = ((...args: unknown[]) => { + const first = args[0]; + if (typeof first === "string" && IDLE_CAP_WARNING_MARKERS.every((m) => first.includes(m))) { + return; + } + original(...args); + }) as typeof console.warn & { [WARN_FILTER_TAG]?: true }; + filtered[WARN_FILTER_TAG] = true; + console.warn = filtered; +} + // Bring up the inbound transport: either the Inkbox tunnel (forwarding to the // local webhook server) or a caller-provided public URL. `ownsProcess` is // false when running inside a host we don't control (in-plugin mode), which @@ -43,6 +70,10 @@ export async function openTransport(opts: { ); } + // Must be in place before connect(): the SDK's runtime starts warning as + // soon as the data plane parks its intake slots. + installTunnelWarnFilter(); + const client = await opts.inkbox.getClient(); let onConnected: () => void = () => {}; const connected = new Promise((resolve) => { diff --git a/tests/gateway/transport.test.ts b/tests/gateway/transport.test.ts index 37b34c9..2944b3b 100644 --- a/tests/gateway/transport.test.ts +++ b/tests/gateway/transport.test.ts @@ -1,6 +1,6 @@ // The tunnel listener returned by connect() is inert until wait() drives its // data plane — openTransport must start it and gate on "connected". -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const connectMock = vi.fn(); vi.mock("@inkbox/sdk/tunnels/connect", () => ({ @@ -8,7 +8,7 @@ vi.mock("@inkbox/sdk/tunnels/connect", () => ({ })); import { defaultGatewayConfig } from "../../src/config.js"; -import { openTransport } from "../../src/gateway/transport.js"; +import { installTunnelWarnFilter, openTransport } from "../../src/gateway/transport.js"; const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; @@ -142,3 +142,71 @@ describe("openTransport tunnel driving", () => { expect(connectMock).not.toHaveBeenCalled(); }); }); + +describe("installTunnelWarnFilter", () => { + const savedWarn = console.warn; + let sink: ReturnType; + + beforeEach(() => { + // A fresh, untagged spy stands in as the "original" console.warn, so the + // filter wraps it and every passthrough is observable. + sink = vi.fn(); + console.warn = sink; + installTunnelWarnFilter(); + }); + + afterEach(() => { + console.warn = savedWarn; + }); + + it("suppresses the expected intake idle-cap warning", () => { + console.warn("/_system/intake slot=2 -> status=408 reason=intake-idle-cap"); + expect(sink).not.toHaveBeenCalled(); + }); + + it("keeps 401 warnings visible", () => { + const line = "/_system/intake slot=2 -> status=401 reason=owner-token-invalid"; + console.warn(line); + expect(sink).toHaveBeenCalledWith(line); + }); + + it("keeps status=408 with a different reason visible", () => { + console.warn("/_system/intake slot=2 -> status=408 reason=intake-superseded"); + expect(sink).toHaveBeenCalledTimes(1); + }); + + it("keeps unrelated warnings, extra args included, visible", () => { + const err = new Error("boom"); + console.warn("tunnel runtime: h2 session error", err); + expect(sink).toHaveBeenCalledWith("tunnel runtime: h2 session error", err); + }); + + it("passes a non-string first argument through even if it mentions the markers", () => { + const err = new Error("/_system/intake slot=2 -> status=408 reason=intake-idle-cap"); + console.warn(err); + expect(sink).toHaveBeenCalledWith(err); + }); + + it("is idempotent: a second install keeps the same wrapper and a single layer", () => { + const wrapped = console.warn; + installTunnelWarnFilter(); + expect(console.warn).toBe(wrapped); + console.warn("still one layer"); + expect(sink).toHaveBeenCalledTimes(1); + }); + + it("is installed by openTransport before the tunnel connects", async () => { + const listener = makeListener(); + const opts = makeOpts(listener); + // Undo the beforeEach install: openTransport must wrap the bare spy itself. + console.warn = sink; + + const pending = openTransport(opts); + await new Promise((r) => setTimeout(r, 0)); + (connectMock.mock.calls[0][1] as { onStatus: (s: string) => void }).onStatus("connected"); + await pending; + + console.warn("/_system/intake slot=0 -> status=408 reason=intake-idle-cap"); + expect(sink).not.toHaveBeenCalled(); + }); +});