Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/browser-pay-intercept-diagnostics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sapiom/browser-pay": patch
---

Better debug logging for browser-pay: an optional logger now receives browser step failures, and CDP errors name the method.
4 changes: 3 additions & 1 deletion packages/browser-pay/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Refusal, nothing forwarded:

The `refused` event and logger pass through only these known reasons: `unknown_injection`, `injection_expired`, `payment_released`, `key_rotated`, `already_relayed`, `relay_in_progress`, `card_removed`, `payment_not_authorized`, `total_changed`, `destination_not_allowed`, `card_fields_invalid`, `merchant_mismatch`, and `provider_unavailable`. Any other reason becomes `unrecognized`. `relay_in_progress` is not terminal. The paused request stays open and the same idempotency key is posted again until the outcome is terminal or the deadline passes.

Event `url` is origin-only (`scheme://host[:port]`), with any held placeholder masked. An opaque origin uses its scheme (for example, `about:`); an unparseable URL is `""`. Paths, queries, fragments, and userinfo are never included. Attach errors use fixed messages and do not include transport details. CDP command failures and timeouts use the generic messages `CDP command failed` and `CDP command timed out`.
Event `url` is origin-only (`scheme://host[:port]`), with any held placeholder masked. An opaque origin uses its scheme (for example, `about:`); an unparseable URL is `""`. Paths, queries, fragments, and userinfo are never included. Attach errors use fixed messages and do not include transport details. CDP command failures and timeouts name the method that was sent (`CDP command failed: Target.setAutoAttach`, `CDP command timed out: Target.setAutoAttach`) and do not include the browser's error text.

No answer after the send:

Expand Down Expand Up @@ -87,6 +87,8 @@ const handle = await attachInterceptor(cdpUrl, {

Call `handle.on` after `attachInterceptor` resolves. Events emitted while attach was running, including a target that could not arm Fetch, are delivered to that first subscriber.

An optional `logger` receives `browser step failed` when arming `Fetch.enable` fails, and when a nested auto-attach, resume, `Target.getTargets`, existing-frame attach, or close fails.

A request is relayed when the held placeholder number appears in the URL or body. An exact match counts, and so do digits split by spaces or dashes, including the URL-encoded and JSON-escaped forms of those separators. `placeholder` is `{ number, injectionRef }`. `injectionRef` is a UUID. The backend's `checkPlaceholderUse` decides whether the number is valid. A paused request that does not hold the placeholder is continued.

A relayed response fails the paused request only when that response contains the held placeholder number.
Expand Down
21 changes: 20 additions & 1 deletion packages/browser-pay/src/cdp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import crypto from "node:crypto";
import http from "node:http";
import type { AddressInfo } from "node:net";
import type { Duplex } from "node:stream";
import { CdpConnection } from "./cdp";
import { CdpCommandError, CdpConnection } from "./cdp";

function acceptKey(key: string): string {
return crypto
Expand Down Expand Up @@ -109,6 +109,25 @@ describe("CdpConnection", () => {
}
});

it("rejects Fetch.enable with timedOut when the browser never replies", async () => {
const stub = await listen();
try {
const connection = await CdpConnection.connect(stub.url);
await stub.onceSocket;
const pending = connection.send("Fetch.enable", {}, undefined, 20);
await expect(pending).rejects.toBeInstanceOf(CdpCommandError);
await expect(pending).rejects.toHaveProperty("timedOut", true);
await expect(pending).rejects.toHaveProperty("method", "Fetch.enable");
await expect(pending).rejects.toHaveProperty(
"message",
"CDP command timed out: Fetch.enable",
);
connection.close();
} finally {
await stub.close();
}
});

it("rejects pending calls when a frame claims to exceed the size cap", async () => {
const stub = await listen();
try {
Expand Down
38 changes: 35 additions & 3 deletions packages/browser-pay/src/cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,33 @@ type Pending = {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
method: string;
};

const PLAIN_CDP_METHOD = /^[A-Za-z]+\.[A-Za-z]+$/;

export class CdpCommandError extends Error {
readonly method: string | undefined;
readonly code: number | undefined;
readonly timedOut: boolean;

constructor(method: string, timedOut: boolean, code?: number) {
const safe = PLAIN_CDP_METHOD.test(method) ? method : undefined;
const label = timedOut ? "CDP command timed out" : "CDP command failed";
super(safe === undefined ? label : `${label}: ${safe}`);
this.name = "CdpCommandError";
this.method = safe;
this.timedOut = timedOut;
this.code =
typeof code === "number" && Number.isInteger(code) ? code : undefined;
}
}

function readErrorCode(error: unknown): number | undefined {
if (!isRecord(error) || typeof error.code !== "number") return undefined;
return Number.isInteger(error.code) ? error.code : undefined;
}

function parseCdpMessage(value: unknown):
| {
id?: number;
Expand All @@ -31,6 +56,7 @@ function parseCdpMessage(value: unknown):
sessionId?: string;
result?: unknown;
failed?: true;
code?: number;
}
| undefined {
if (!isRecord(value)) return undefined;
Expand All @@ -40,14 +66,17 @@ function parseCdpMessage(value: unknown):
const params = isRecord(value.params) ? value.params : {};
const sessionId =
typeof value.sessionId === "string" ? value.sessionId : undefined;
const code = value.error ? readErrorCode(value.error) : undefined;
// The remote error text is dropped here so it can never reach a caller.
return {
...(id !== undefined ? { id } : {}),
...(method !== undefined ? { method } : {}),
params,
...(sessionId ? { sessionId } : {}),
result: value.result,
...(value.error ? { failed: true as const } : {}),
...(value.error
? { failed: true as const, ...(code !== undefined ? { code } : {}) }
: {}),
};
}

Expand Down Expand Up @@ -339,7 +368,9 @@ export class CdpConnection {
this.pending.delete(message.id);
clearTimeout(pending.timer);
if (message.failed) {
pending.reject(new Error("CDP command failed"));
pending.reject(
new CdpCommandError(pending.method, false, message.code),
);
} else {
pending.resolve(message.result);
}
Expand Down Expand Up @@ -390,12 +421,13 @@ export class CdpConnection {
}
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error("CDP command timed out"));
reject(new CdpCommandError(method, true));
}, timeoutMs);
this.pending.set(id, {
resolve,
reject,
timer,
method,
});
try {
this.socket.send(
Expand Down
12 changes: 10 additions & 2 deletions packages/browser-pay/src/interceptor.event-redaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -597,7 +597,7 @@ describe("interceptor event redaction", () => {
attachOptions(stubPaymentRelay(async () => success())),
);
handle.on((event) => events.push(event));
expect(events).toEqual([
expect(events).toStrictEqual([
{
type: "failed",
url: ORIGIN,
Expand Down Expand Up @@ -680,7 +680,9 @@ describe("interceptor event redaction", () => {
attachOptions(stubPaymentRelay(async () => success())),
),
);
expect(protocolError.message).toBe("CDP command failed");
expect(protocolError.message).toBe(
"CDP command failed: Target.setAutoAttach",
);
assertSafeError(protocolError);
} finally {
await stub.close();
Expand All @@ -702,6 +704,9 @@ describe("interceptor event redaction", () => {
);
expect(error.message).toBe("CDP command failed");
expect(error.message).not.toContain("SYNTHETIC_METHOD_SECRET");
expect(
JSON.stringify(error, Object.getOwnPropertyNames(error)),
).not.toContain("SYNTHETIC_METHOD_SECRET");
} finally {
errorConnection.close();
await errorStub.close();
Expand All @@ -715,6 +720,9 @@ describe("interceptor event redaction", () => {
);
expect(error.message).toBe("CDP command timed out");
expect(error.message).not.toContain("SYNTHETIC_METHOD_SECRET");
expect(
JSON.stringify(error, Object.getOwnPropertyNames(error)),
).not.toContain("SYNTHETIC_METHOD_SECRET");
} finally {
timeoutConnection.close();
await timeoutStub.close();
Expand Down
Loading
Loading