Skip to content

Commit 5e46a2f

Browse files
authored
refactor(cf): per-tab state isolation with structural cross-tab filtering (#132)
Decompose CloudflareStateTracker (618 lines) into SessionSolverState (shared Maps/Sets) + TabSolverState (per-tab scalar fields) + TabRuntime (per-tab container). Replace the manual 3-step cross-tab OOPIF filter chain with TabDetector service where filtering is baked in — impossible to bypass. Key changes: - Extract startActivityLoop, takePendingRechallengeCount, bindPendingOOPIF helpers - Split DetectorR into BaseDetectorR (no tab services) and full DetectorR - Move deriveSolveAttribution/deriveFailLabel to cf-summary.ts - Add TabSolverContext, TabDetector, SessionSolverContext services - Per-tab services provided via Effect.provideService per detection fiber
1 parent 9204b32 commit 5e46a2f

9 files changed

Lines changed: 471 additions & 407 deletions

src/session/cf/cf-services.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,61 @@ export const OOPIFChecker = ServiceMap.Service<{
121121
// ═══════════════════════════════════════════════════════════════════════
122122

123123
export const SolverConfig = ServiceMap.Service<Required<CloudflareConfig>>('SolverConfig');
124+
125+
// ═══════════════════════════════════════════════════════════════════════
126+
// SessionSolverContext — session-level shared state (provided once)
127+
//
128+
// Exposes the session-level Maps/Sets that detectors need for cross-tab
129+
// guards, OOPIF ownership tracking, and compound label accumulation.
130+
// ═══════════════════════════════════════════════════════════════════════
131+
132+
export const SessionSolverContext = ServiceMap.Service<{
133+
readonly iframeToPage: ReadonlyMap<TargetId, TargetId>;
134+
readonly solvedCFTargetIds: ReadonlySet<string>;
135+
readonly solvedPages: ReadonlySet<TargetId>;
136+
readonly knownPages: ReadonlyMap<TargetId, CdpSessionId>;
137+
readonly config: Required<CloudflareConfig>;
138+
readonly destroyed: boolean;
139+
readonly registerPage: (targetId: TargetId, cdpSessionId: CdpSessionId) => void;
140+
readonly addSolvedCFTarget: (oopifId: string, pageTargetId: TargetId) => Effect.Effect<void>;
141+
readonly addSolvedCFTargetSync: (oopifId: string, pageTargetId: TargetId) => void;
142+
readonly pushPhase: (targetId: TargetId, type: string, label: string) => void;
143+
readonly buildCompoundLabel: (targetId: TargetId) => string;
144+
}>('SessionSolverContext');
145+
146+
// ═══════════════════════════════════════════════════════════════════════
147+
// TabSolverContext — per-tab isolated state (provided per tab runtime)
148+
//
149+
// Scalar fields, NOT Maps. Cross-tab contamination is structurally
150+
// impossible because there are no targetId keys to mix up.
151+
// ═══════════════════════════════════════════════════════════════════════
152+
153+
import type { TabSolverState } from './cf-tab-state.js';
154+
155+
export const TabSolverContext = ServiceMap.Service<{
156+
readonly targetId: TargetId;
157+
readonly cdpSessionId: CdpSessionId;
158+
readonly state: TabSolverState;
159+
/** Set the resolved pageFrameId — called once at detection start. */
160+
readonly setPageFrameId: (id: string | null) => void;
161+
}>('TabSolverContext');
162+
163+
// ═══════════════════════════════════════════════════════════════════════
164+
// TabDetector — per-tab filtered detection (structural cross-tab guard)
165+
//
166+
// The filtering is baked into the service implementation — impossible to
167+
// bypass. Replaces the 3-step manual filter chain:
168+
// 1. detectTurnstileViaCDP(cdpSessionId, solvedCFTargetIds)
169+
// 2. filterOwnedTargets(detection.targets, targetId, iframeToPage)
170+
// 3. pageFrameId ? filter by parentFrameId : keep all
171+
// with a single yield* call.
172+
// ═══════════════════════════════════════════════════════════════════════
173+
174+
import type { CFDetected } from './cloudflare-solve-strategies.js';
175+
176+
export const TabDetector = ServiceMap.Service<{
177+
/** Returns ONLY OOPIFs belonging to this tab. Cross-tab OOPIFs are filtered by construction. */
178+
readonly detect: (excludeTargetIds?: ReadonlySet<string>) => Effect.Effect<
179+
CFDetected | { _tag: 'not_detected' }
180+
>;
181+
}>('TabDetector');

src/session/cf/cf-session-state.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/**
2+
* Session-level shared state — extracted from CloudflareStateTracker.
3+
*
4+
* Contains Maps and Sets that are shared across all tabs in a session.
5+
* Individual tabs reference this state through their tab runtimes.
6+
* The session-level state outlives any individual tab.
7+
*/
8+
import { Effect, Exit, Scope } from 'effect';
9+
10+
import { runForkInServer } from '../../otel-runtime.js';
11+
import type { CdpSessionId, TargetId, CloudflareConfig, CloudflareType } from '../../shared/cloudflare-detection.js';
12+
import { isInterstitialType } from '../../shared/cloudflare-detection.js';
13+
import type { CFEvents } from './cloudflare-event-emitter.js';
14+
import { DetectionRegistry } from './cf-detection-registry.js';
15+
16+
export class SessionSolverState {
17+
readonly registry: DetectionRegistry;
18+
readonly iframeToPage = new Map<TargetId, TargetId>();
19+
readonly knownPages = new Map<TargetId, CdpSessionId>();
20+
readonly bindingSolvedTargets = new Set<TargetId>();
21+
/** CF OOPIF targetIds from completed solves — filtered out of future detection polls. */
22+
readonly solvedCFTargetIds = new Set<string>();
23+
/**
24+
* Page targetIds that have successfully solved an EMBEDDED TURNSTILE.
25+
* See CloudflareStateTracker.solvedPages JSDoc for full rationale.
26+
* DO NOT ADD INTERSTITIAL SOLVES — multi-phase (Int→Emb) flows will break.
27+
*/
28+
readonly solvedPages = new Set<TargetId>();
29+
readonly pendingIframes = new Map<TargetId, { iframeCdpSessionId: CdpSessionId; iframeTargetId: TargetId }>();
30+
readonly pendingRechallengeCount = new Map<TargetId, number>();
31+
/** Per-page reload count for widget-not-rendered recovery. Reset on solve. */
32+
readonly widgetReloadCount = new Map<TargetId, number>();
33+
/** Per-page cleanup scopes — finalizers remove solvedCFTargetIds entries when page is destroyed. */
34+
private readonly pageCleanupScopes = new Map<TargetId, Scope.Closeable>();
35+
config: Required<CloudflareConfig> = { maxAttempts: 3, attemptTimeout: 30000, recordingMarkers: true };
36+
destroyed = false;
37+
/** Per-page accumulator of solved/failed phases for compound summary labels. */
38+
private readonly summaryPhases = new Map<TargetId, { type: string; label: string }[]>();
39+
40+
constructor(protected events: CFEvents) {
41+
this.registry = new DetectionRegistry((active, signal) => {
42+
const duration = Date.now() - active.startTime;
43+
44+
if (signal === 'verified_session_close') {
45+
const phaseLabel = '⊘';
46+
runForkInServer(Effect.logInfo(`Scope finalizer fallback: verified_session_close for ${active.pageTargetId}`));
47+
this.pushPhase(active.pageTargetId, active.info.type, phaseLabel);
48+
const compoundLabel = this.buildCompoundLabel(active.pageTargetId);
49+
this.events.emitFailed(active, 'verified_session_close', duration, phaseLabel, compoundLabel,
50+
{ cf_verified: true });
51+
return;
52+
}
53+
54+
const failLabel = `✗ ${signal}`;
55+
runForkInServer(Effect.logInfo(`Scope finalizer fallback: emitting failed for orphaned detection on ${active.pageTargetId}`));
56+
this.pushPhase(active.pageTargetId, active.info.type, failLabel);
57+
const compoundLabel = this.buildCompoundLabel(active.pageTargetId);
58+
this.events.emitFailed(active, signal, duration, failLabel, compoundLabel);
59+
});
60+
}
61+
62+
/** Register a page target → CDP session mapping. Creates cleanup scope. */
63+
registerPage(targetId: TargetId, cdpSessionId: CdpSessionId): void {
64+
this.knownPages.set(targetId, cdpSessionId);
65+
if (!this.pageCleanupScopes.has(targetId)) {
66+
this.pageCleanupScopes.set(targetId, Scope.makeUnsafe());
67+
}
68+
}
69+
70+
/** Add OOPIF target ID to solvedCFTargetIds with scope-bound cleanup. */
71+
addSolvedCFTarget(oopifId: string, pageTargetId: TargetId): Effect.Effect<void> {
72+
return Effect.suspend(() => {
73+
const scope = this.pageCleanupScopes.get(pageTargetId);
74+
if (!scope) return Effect.void;
75+
this.solvedCFTargetIds.add(oopifId);
76+
return Scope.addFinalizer(scope, Effect.sync(() => {
77+
this.solvedCFTargetIds.delete(oopifId);
78+
}));
79+
});
80+
}
81+
82+
/** Synchronous variant — used where we're outside an Effect generator. */
83+
addSolvedCFTargetSync(oopifId: string, pageTargetId: TargetId): void {
84+
const scope = this.pageCleanupScopes.get(pageTargetId);
85+
if (!scope) return;
86+
this.solvedCFTargetIds.add(oopifId);
87+
Effect.runSync(Scope.addFinalizer(scope, Effect.sync(() => {
88+
this.solvedCFTargetIds.delete(oopifId);
89+
})));
90+
}
91+
92+
/** Look up page target by iframe CDP session. */
93+
findPageByIframeSession(iframeCdpSessionId: CdpSessionId): TargetId | undefined {
94+
return this.registry.findByIframeSession(iframeCdpSessionId);
95+
}
96+
97+
pushPhase(targetId: TargetId, type: string, label: string): void {
98+
if (!this.summaryPhases.has(targetId)) this.summaryPhases.set(targetId, []);
99+
this.summaryPhases.get(targetId)!.push({ type, label });
100+
}
101+
102+
/**
103+
* Build compound summary label from accumulated phases.
104+
* Interstitial phases: Int✓Int→ (no space). Embedded: Emb→.
105+
* Space between groups: Int✓Int→ Emb→
106+
*/
107+
buildCompoundLabel(targetId: TargetId): string {
108+
const phases = this.summaryPhases.get(targetId) || [];
109+
const intParts = phases
110+
.filter(p => isInterstitialType(p.type as CloudflareType))
111+
.map(p => `Int${p.label}`);
112+
const embParts = phases
113+
.filter(p => !isInterstitialType(p.type as CloudflareType))
114+
.map(p => `Emb${p.label}`);
115+
const parts: string[] = [];
116+
if (intParts.length) parts.push(intParts.join(''));
117+
if (embParts.length) parts.push(embParts.join(''));
118+
return parts.join(' ');
119+
}
120+
121+
/**
122+
* Clean up all state for a destroyed page target.
123+
* Registry.unregister() closes the detection's scope, whose finalizer
124+
* emits session_close fallback if unresolved.
125+
*/
126+
unregisterPage(targetId: TargetId): Effect.Effect<void> {
127+
const self = this;
128+
return Effect.fn('cf.state.unregisterPage')(function*() {
129+
yield* Effect.annotateCurrentSpan({ 'cf.target_id': targetId });
130+
yield* self.registry.unregister(targetId);
131+
132+
self.knownPages.delete(targetId);
133+
self.iframeToPage.delete(targetId);
134+
for (const [iframeId, pageId] of self.iframeToPage) {
135+
if (pageId === targetId) self.iframeToPage.delete(iframeId);
136+
}
137+
self.bindingSolvedTargets.delete(targetId);
138+
self.solvedPages.delete(targetId);
139+
self.pendingIframes.delete(targetId);
140+
self.pendingRechallengeCount.delete(targetId);
141+
self.widgetReloadCount.delete(targetId);
142+
self.summaryPhases.delete(targetId);
143+
144+
const cleanupScope = self.pageCleanupScopes.get(targetId);
145+
if (cleanupScope) {
146+
yield* Scope.close(cleanupScope, Exit.void);
147+
self.pageCleanupScopes.delete(targetId);
148+
}
149+
})();
150+
}
151+
152+
/** Emit cf.failed for orphaned detections and clean up all state. */
153+
destroy(): Effect.Effect<void> {
154+
this.destroyed = true;
155+
return Effect.gen((function*(this: SessionSolverState) {
156+
yield* this.registry.destroyAll();
157+
this.iframeToPage.clear();
158+
this.knownPages.clear();
159+
this.bindingSolvedTargets.clear();
160+
161+
for (const scope of this.pageCleanupScopes.values()) {
162+
yield* Scope.close(scope, Exit.void);
163+
}
164+
this.pageCleanupScopes.clear();
165+
166+
this.solvedCFTargetIds.clear();
167+
this.solvedPages.clear();
168+
this.pendingIframes.clear();
169+
this.widgetReloadCount.clear();
170+
this.summaryPhases.clear();
171+
}).bind(this));
172+
}
173+
}

src/session/cf/cf-summary.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,44 @@
11
/**
2-
* Pure summary functions for CF replay markers.
2+
* Pure summary functions for CF replay markers and solve attribution.
33
*
4-
* Extracted from integration-helpers.ts so unit tests can import
5-
* without triggering env var validation (REPLAY_INGEST_URL, etc.).
4+
* Extracted from integration-helpers.ts and cloudflare-state-tracker.ts
5+
* so unit tests can import without triggering env var validation.
66
*/
77

88
import { isInterstitialType } from '../../shared/cloudflare-detection.js';
99
import type { CloudflareType } from '../../shared/cloudflare-detection.js';
1010

11+
// ── Solve Attribution (moved from cloudflare-state-tracker.ts) ─────
12+
13+
export type SolveSignal = 'page_navigated' | 'beacon_push' | 'token_poll' | 'activity_poll'
14+
| 'bridge_solved' | 'state_change' | 'callback_binding' | 'session_close' | 'cdp_dom_walk'
15+
| 'verified_session_close';
16+
17+
// ┌──────────────┬──────────────────┬──────────────┬───────┐
18+
// │ Signal │ clickDelivered? │ Method │ Label │
19+
// ├──────────────┼──────────────────┼──────────────┼───────┤
20+
// │ page_nav │ true │ click_nav │ ✓ │
21+
// │ page_nav │ false │ auto_nav │ → │
22+
// │ any other │ true │ click_solve │ ✓ │
23+
// │ any other │ false │ auto_solve │ → │
24+
// └──────────────┴──────────────────┴──────────────┴───────┘
25+
26+
export function deriveSolveAttribution(signal: SolveSignal, clickDelivered: boolean) {
27+
if (signal === 'page_navigated') {
28+
return clickDelivered
29+
? { method: 'click_navigation' as const, autoResolved: false, label: '✓' }
30+
: { method: 'auto_navigation' as const, autoResolved: true, label: '→' };
31+
}
32+
return clickDelivered
33+
? { method: 'click_solve' as const, autoResolved: false, label: '✓' }
34+
: { method: 'auto_solve' as const, autoResolved: true, label: '→' };
35+
}
36+
37+
export function deriveFailLabel(reason: string) {
38+
if (reason === 'verified_session_close') return { label: '⊘' };
39+
return { label: `✗ ${reason}` };
40+
}
41+
1142
// ── CF Markers Reference ─────────────────────────────────────────────
1243

1344
export interface ReplayMarker {

src/session/cf/cf-tab-runtime.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Per-tab runtime state container.
3+
*
4+
* Each tab gets its own TabRuntime containing TabSolverState with scalar
5+
* fields (not Maps). Cross-tab contamination is structurally impossible
6+
* because there are no targetId keys to mix up.
7+
*
8+
* State is GC'd when the TabRuntime entry is deleted from the map —
9+
* no manual per-field cleanup needed.
10+
*/
11+
import type { CdpSessionId, TargetId } from '../../shared/cloudflare-detection.js';
12+
import { TabSolverState } from './cf-tab-state.js';
13+
14+
/** Per-tab state container. */
15+
export interface TabRuntime {
16+
readonly state: TabSolverState;
17+
readonly targetId: TargetId;
18+
readonly cdpSessionId: CdpSessionId;
19+
/** Resolved by detectTurnstileWidgetEffect at detection start. */
20+
pageFrameId: string | null;
21+
}
22+
23+
/** Create a per-tab state container. */
24+
export function makeTabRuntime(opts: {
25+
targetId: TargetId;
26+
cdpSessionId: CdpSessionId;
27+
pageFrameId: string | null;
28+
}): TabRuntime {
29+
return {
30+
state: new TabSolverState(),
31+
targetId: opts.targetId,
32+
cdpSessionId: opts.cdpSessionId,
33+
pageFrameId: opts.pageFrameId,
34+
};
35+
}

src/session/cf/cf-tab-state.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* Per-tab mutable state. Scalar fields, NOT Maps.
3+
* Created per tab, GC'd with tab runtime disposal.
4+
*
5+
* By using scalar fields instead of Map<TargetId, X> entries,
6+
* cross-tab contamination is structurally impossible — there's no
7+
* targetId key to mix up.
8+
*/
9+
import type { CdpSessionId, TargetId } from '../../shared/cloudflare-detection.js';
10+
11+
export class TabSolverState {
12+
/** Whether the CF bridge push has already fired a solved event for this tab. */
13+
bindingSolved = false;
14+
/** Pending OOPIF that arrived before detection registered (race condition). */
15+
pendingIframe: { iframeCdpSessionId: CdpSessionId; iframeTargetId: TargetId } | null = null;
16+
/** Number of CF rechallenges on this tab so far. */
17+
rechallengeCount = 0;
18+
/** Per-tab reload count for widget-not-rendered recovery. Reset on solve. */
19+
widgetReloadCount = 0;
20+
/** Per-tab accumulator of solved/failed phases for compound summary labels. */
21+
summaryPhases: { type: string; label: string }[] = [];
22+
}

0 commit comments

Comments
 (0)