src/cdp/target-factory.ts (view raw)
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 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 |
// `window.open` with userGesture is the only CDP-reachable way into a non-default browser context: createTarget rejects a foreign browserContextId and `openerId` does not inherit the opener's context.
import { randomUUID } from "node:crypto";
import type { BrowserClient } from "../client";
import { type Result, err, ok } from "../util/result";
import { type CdpError, cdpError } from "./errors";
const SPAWN_DEADLINE_MS = 8_000;
const SPAWN_POLL_MS = 100;
export type HarnessWindow = {
readonly targetId: string;
readonly freshlyCreated: boolean;
};
type PageTarget = {
readonly targetId: string;
readonly type: string;
readonly url: string;
readonly browserContextId?: string;
};
const listPageTargets = async (client: BrowserClient): Promise<Result<ReadonlyArray<PageTarget>, CdpError>> => {
const r = await client.session().callBrowser("Target.getTargets");
if (!r.success) return r;
return ok(r.data.targetInfos.filter((t) => t.type === "page"));
};
const sessionForTarget = async (client: BrowserClient, targetId: string): Promise<Result<string, CdpError>> => {
const current = client.current();
if (current?.targetId === targetId) return ok(current.sessionId);
return client.session().attach(targetId);
};
const spawnTabViaOpener = async (
client: BrowserClient,
openerTargetId: string,
): Promise<Result<string, CdpError>> => {
const session = await sessionForTarget(client, openerTargetId);
if (!session.success) return session;
const token = `pi-${randomUUID()}`;
// userGesture: true is what gets this past the popup blocker.
const opened = await client.session().callOnTarget(
"Runtime.evaluate",
{
expression: `!!window.open('about:blank#${token}', '_blank')`,
returnByValue: true,
userGesture: true,
},
session.data,
);
if (!opened.success) return opened;
if (opened.data.result.value !== true) {
return err(cdpError(
"invalid_response",
"the browser blocked window.open in the pinned profile's window — the page may have navigated away; retry, or run /browser-profile to re-seed",
"Runtime.evaluate",
));
}
const deadline = Date.now() + SPAWN_DEADLINE_MS;
while (Date.now() < deadline) {
const targets = await listPageTargets(client);
if (targets.success) {
const spawned = targets.data.find((t) => t.url.includes(token));
if (spawned) return ok(spawned.targetId);
}
await new Promise((r) => setTimeout(r, SPAWN_POLL_MS));
}
return err(cdpError("timeout", `the tab opened in the pinned profile did not report back within ${SPAWN_DEADLINE_MS}ms`));
};
const inFlight = new WeakMap<BrowserClient, Promise<Result<HarnessWindow, CdpError>>>();
export const ensureHarnessWindow = async (client: BrowserClient): Promise<Result<HarnessWindow, CdpError>> => {
const pending = inFlight.get(client);
if (pending) {
const shared = await pending;
// `freshlyCreated` is a claim on the seed tab that only the caller which started the creation may hold, or two callers would drive the same tab.
return shared.success ? ok({ targetId: shared.data.targetId, freshlyCreated: false }) : shared;
}
const attempt = ensureHarnessWindowUncoordinated(client).finally(() => inFlight.delete(client));
inFlight.set(client, attempt);
return attempt;
};
const ensureHarnessWindowUncoordinated = async (
client: BrowserClient,
): Promise<Result<HarnessWindow, CdpError>> => {
const targets = await listPageTargets(client);
if (!targets.success) return targets;
const live = new Set(targets.data.map((t) => t.targetId));
const ownership = client.ownership();
const pinnedCtx = client.profileContextId();
// With a pin set but no context resolved yet, a recorded tab's profile cannot be verified, so seed a new window rather than risk adopting one from the wrong profile.
const canReuse = client.profilePin() === null || pinnedCtx !== undefined;
const inPinnedProfile = (targetId: string): boolean => {
if (!pinnedCtx) return true;
const info = targets.data.find((t) => t.targetId === targetId);
return info?.browserContextId === pinnedCtx;
};
if (canReuse) {
const harnessWindow = ownership.harnessWindow();
if (harnessWindow && live.has(harnessWindow) && inPinnedProfile(harnessWindow)) {
return ok({ targetId: harnessWindow, freshlyCreated: false });
}
const survivor = ownership.list().find((id) => live.has(id) && inPinnedProfile(id));
if (survivor) {
ownership.setHarnessWindow(survivor);
return ok({ targetId: survivor, freshlyCreated: false });
}
}
const seeded = client.profilePin()
? await client.seedPinnedProfileWindow()
: await createDedicatedWindow(client);
if (!seeded.success) return seeded;
return ok({ targetId: seeded.data, freshlyCreated: true });
};
const createDedicatedWindow = async (client: BrowserClient): Promise<Result<string, CdpError>> => {
const created = await client.session().callBrowser("Target.createTarget", { url: "about:blank", newWindow: true });
if (!created.success) return created;
const { targetId } = created.data;
const ownership = client.ownership();
ownership.setHarnessWindow(targetId);
ownership.add(targetId);
const win = await client.session().windowId(targetId);
if (win.success) ownership.setHarnessWindowId(win.data);
return ok(targetId);
};
export const openHarnessTab = async (
client: BrowserClient,
openerTargetId: string,
): Promise<Result<string, CdpError>> => {
const spawned = client.profileContextId()
? await spawnTabViaOpener(client, openerTargetId)
: await (async (): Promise<Result<string, CdpError>> => {
const created = await client.session().callBrowser("Target.createTarget", {
url: "about:blank",
openerId: openerTargetId,
});
if (!created.success) return created;
return ok(created.data.targetId);
})();
if (!spawned.success) return spawned;
client.ownership().add(spawned.data);
return ok(spawned.data);
};
|