Skip to content

Commit ea5a40c

Browse files
authored
fix(session): two-phase tab lifecycle + worktree test fixes (#136)
* feat(cf): add fresh_owned/fresh_cross_tab span attributes to TabDetector The TabDetector structural filter removes cross-tab OOPIFs but the ownership breakdown was only visible in replay markers, not trace spans. Add cf.detect.fresh_owned and cf.detect.fresh_cross_tab annotations inside the TabDetector.detect implementation so Tempo traces show unambiguous cross-tab filtering status. * fix(session): two-phase tab lifecycle to eliminate ghost about:blank resource waste Ghost about:blank keepalive tabs were running CF detection loops for 60+ minutes, generating 300+ trace spans, 3600+ screencast frames, and holding WebSocket connections per session. Every session has one ghost tab. Split handleAttachedToTargetEffect into Phase 1 (lightweight attach) and Phase 2 (activate on first real navigation). Keepalive tabs never navigate away from about:blank so they never activate — zero heavy resource allocation. Also fixes worktree test failures: track src/generated/ in git, use Node-native module resolution (createRequire + dirname) instead of hardcoded node_modules paths, and add worktree-aware BROWSERLESS_DIR fallback in vitest setup.
1 parent 9d6fa0c commit ea5a40c

9 files changed

Lines changed: 214 additions & 118 deletions

File tree

.gitignore

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@ build/
44
metrics.json
55
extensions/ublocklite
66

7-
# Generated rrweb bundles (created by scripts/bundle-rrweb*.js)
8-
src/generated/
7+
# Generated rrweb extension (rebuilt by extensions/replay/build.js)
98
extensions/replay/rrweb-recorder.js
109

1110
# npm pack

extensions/replay/build.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ import { build, transform } from 'esbuild';
1818
import fs from 'fs/promises';
1919
import { join, dirname } from 'path';
2020
import { fileURLToPath } from 'url';
21+
import { createRequire } from 'node:module';
22+
23+
// Node-native resolution — walks up directories to find node_modules.
24+
// Works in git worktrees (finds main repo's node_modules automatically).
25+
const require = createRequire(import.meta.url);
2126

2227
const __dirname = dirname(fileURLToPath(import.meta.url));
2328
const rootDir = join(__dirname, '..', '..');
@@ -44,7 +49,7 @@ async function compileContentScript(filename) {
4449

4550
// 1. Bundle @divmode/rrweb as IIFE
4651
await build({
47-
entryPoints: [join(rootDir, 'node_modules/@divmode/rrweb/dist/rrweb.js')],
52+
entryPoints: [join(dirname(require.resolve('@divmode/rrweb')), 'rrweb.js')],
4853
bundle: true,
4954
format: 'iife',
5055
globalName: 'rrweb',
@@ -56,7 +61,7 @@ async function compileContentScript(filename) {
5661

5762
// 2. Bundle @divmode/rrweb-plugin-console-record as IIFE
5863
await build({
59-
entryPoints: [join(rootDir, 'node_modules/@divmode/rrweb-plugin-console-record/dist/rrweb-plugin-console-record.js')],
64+
entryPoints: [join(dirname(require.resolve('@divmode/rrweb-plugin-console-record')), 'rrweb-plugin-console-record.js')],
6065
bundle: true,
6166
format: 'iife',
6267
globalName: 'rrwebConsolePlugin',

scripts/bundle-rrweb-player.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ import { build } from 'esbuild';
77
import fs from 'fs/promises';
88
import { join, dirname } from 'path';
99
import { fileURLToPath } from 'url';
10+
import { createRequire } from 'node:module';
11+
12+
// Node-native resolution — walks up directories to find node_modules.
13+
// Works in git worktrees (finds main repo's node_modules automatically).
14+
const require = createRequire(import.meta.url);
1015

1116
const __dirname = dirname(fileURLToPath(import.meta.url));
1217
const rootDir = join(__dirname, '..');
@@ -21,7 +26,7 @@ const generatedFile = join(generatedDir, 'rrweb-player.ts');
2126

2227
// Bundle @posthog/rrweb-player as IIFE for browser
2328
await build({
24-
entryPoints: [join(rootDir, 'node_modules/@posthog/rrweb-player/dist/rrweb-player.js')],
29+
entryPoints: [join(dirname(require.resolve('@posthog/rrweb-player')), 'rrweb-player.js')],
2530
bundle: true,
2631
format: 'iife',
2732
globalName: 'rrwebPlayer',
@@ -34,7 +39,7 @@ const generatedFile = join(generatedDir, 'rrweb-player.ts');
3439
const playerJS = await fs.readFile(outfile, 'utf-8');
3540

3641
// Read the CSS file
37-
const cssPath = join(rootDir, 'node_modules/@posthog/rrweb-player/dist/style.css');
42+
const cssPath = join(dirname(require.resolve('@posthog/rrweb-player')), 'style.css');
3843
const playerCSS = await fs.readFile(cssPath, 'utf-8');
3944

4045
// Generate TypeScript file with the bundled code

src/generated/antibot-detect.ts

Lines changed: 4 additions & 0 deletions
Large diffs are not rendered by default.

src/generated/cf-bridge.ts

Lines changed: 4 additions & 0 deletions
Large diffs are not rendered by default.

src/generated/rrweb-script.ts

Lines changed: 6 additions & 0 deletions
Large diffs are not rendered by default.

src/session/cdp-session.ts

Lines changed: 149 additions & 108 deletions
Large diffs are not rendered by default.

src/session/cf/cloudflare-detector.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -967,12 +967,26 @@ export class CloudflareDetector {
967967
// Resolve page's root frameId once — used by TabDetector for parent frame filtering.
968968
// Page.getFrameTree is a read-only CDP command — no V8 evaluation, safe for all page types.
969969
const cdp = yield* CdpSender;
970-
const pageFrameId: string | null = yield* cdp.send(
970+
const frameTreeResult: { id: string | null; url: string | null } = yield* cdp.send(
971971
'Page.getFrameTree', {}, cdpSessionId,
972972
).pipe(
973-
Effect.map((r: any) => (r?.frameTree?.frame?.id as string) ?? null),
974-
Effect.orElseSucceed(() => null as string | null),
973+
Effect.map((r: any) => ({
974+
id: (r?.frameTree?.frame?.id as string) ?? null,
975+
url: (r?.frameTree?.frame?.url as string) ?? null,
976+
})),
977+
Effect.orElseSucceed(() => ({ id: null as string | null, url: null as string | null })),
975978
);
979+
const pageFrameId = frameTreeResult.id;
980+
981+
// Defense-in-depth: skip detection on about:blank tabs (keepalive tabs).
982+
// Primary guard is the two-phase lifecycle in cdp-session.ts (Phase 2 never activates
983+
// for about:blank tabs). This catches the enable() path which iterates knownPages
984+
// and could start detection on unactivated tabs.
985+
if (!frameTreeResult.url || frameTreeResult.url.startsWith('about:')) {
986+
yield* Effect.annotateCurrentSpan({ 'cf.detect.skipped': 'about_blank' });
987+
return;
988+
}
989+
976990
// Set on tab runtime so TabDetector's baked-in filter uses the correct pageFrameId
977991
tabCtx.setPageFrameId(pageFrameId);
978992
yield* Effect.annotateCurrentSpan({

vitest.integration.setup.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,25 @@ const REPLAY_HTTP = process.env.REPLAY_INGEST_URL;
9090
if (!REPLAY_HTTP) {
9191
throw new Error('REPLAY_INGEST_URL env var required — set in .env.dev or .env.prod');
9292
}
93-
const BROWSERLESS_DIR = path.resolve(import.meta.dirname);
93+
// Worktree-aware: if real npm dependencies don't exist here (git worktree),
94+
// resolve to the main repo root. Check for a real package (effect) instead of
95+
// just node_modules/ — vitest creates node_modules/.vite cache in the CWD,
96+
// which would fool a bare existsSync('node_modules') check.
97+
function resolveBrowserlessDir(): string {
98+
const dir = path.resolve(import.meta.dirname);
99+
if (existsSync(path.join(dir, 'node_modules', 'effect'))) return dir;
100+
try {
101+
const output = execFileSync('git', ['worktree', 'list', '--porcelain'],
102+
{ cwd: dir, encoding: 'utf8' });
103+
const match = output.match(/^worktree (.+)$/m);
104+
if (match && existsSync(path.join(match[1], 'node_modules', 'effect'))) {
105+
console.log(`[globalSetup] Worktree detected — using main repo: ${match[1]}`);
106+
return match[1];
107+
}
108+
} catch { /* not a git repo */ }
109+
return dir;
110+
}
111+
const BROWSERLESS_DIR = resolveBrowserlessDir();
94112
const MAX_WAIT_MS = 30_000;
95113
const POLL_INTERVAL_MS = 500;
96114
const RESULTS_FILE = path.join(tmpdir(), 'cf-integration-results.jsonl');

0 commit comments

Comments
 (0)