@myrialabs/clopen 0.2.9 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +61 -27
  2. package/backend/chat/stream-manager.ts +11 -7
  3. package/backend/engine/adapters/opencode/stream.ts +37 -19
  4. package/backend/index.ts +17 -0
  5. package/backend/mcp/servers/browser-automation/browser.ts +2 -0
  6. package/backend/preview/browser/browser-mcp-control.ts +16 -0
  7. package/backend/preview/browser/browser-navigation-tracker.ts +219 -34
  8. package/backend/preview/browser/browser-pool.ts +1 -1
  9. package/backend/preview/browser/browser-preview-service.ts +23 -34
  10. package/backend/preview/browser/browser-tab-manager.ts +16 -1
  11. package/backend/preview/browser/browser-video-capture.ts +15 -3
  12. package/backend/preview/browser/scripts/audio-stream.ts +5 -0
  13. package/backend/preview/browser/scripts/video-stream.ts +39 -4
  14. package/backend/preview/browser/types.ts +7 -6
  15. package/backend/ws/preview/browser/interact.ts +46 -50
  16. package/backend/ws/preview/browser/webcodecs.ts +35 -15
  17. package/backend/ws/preview/index.ts +8 -0
  18. package/frontend/components/chat/input/ChatInput.svelte +3 -3
  19. package/frontend/components/common/feedback/NotificationToast.svelte +26 -11
  20. package/frontend/components/files/FileNode.svelte +16 -58
  21. package/frontend/components/git/CommitForm.svelte +1 -1
  22. package/frontend/components/preview/browser/BrowserPreview.svelte +10 -3
  23. package/frontend/components/preview/browser/components/Canvas.svelte +158 -64
  24. package/frontend/components/preview/browser/components/Container.svelte +26 -8
  25. package/frontend/components/preview/browser/components/Toolbar.svelte +35 -18
  26. package/frontend/components/preview/browser/core/coordinator.svelte.ts +26 -1
  27. package/frontend/components/preview/browser/core/stream-handler.svelte.ts +66 -9
  28. package/frontend/components/preview/browser/core/tab-operations.svelte.ts +5 -4
  29. package/frontend/components/workspace/PanelHeader.svelte +8 -6
  30. package/frontend/components/workspace/panels/PreviewPanel.svelte +1 -0
  31. package/frontend/services/chat/chat.service.ts +25 -3
  32. package/frontend/services/notification/push.service.ts +2 -2
  33. package/frontend/services/preview/browser/browser-webcodecs.service.ts +277 -61
  34. package/package.json +2 -2
package/README.md CHANGED
@@ -1,26 +1,64 @@
1
- # Clopen
1
+ <p align="center">
2
+ <img src="https://clopen.myrialabs.dev/favicon.svg" alt="Clopen" width="72" height="72" />
3
+ </p>
2
4
 
3
- [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
4
- [![Built with Bun](https://img.shields.io/badge/Built%20with-Bun-black)](https://bun.sh)
5
+ <h1 align="center">Clopen</h1>
5
6
 
6
- **Clopen** provides a modern web interface for AI-assisted development, supporting both **Claude Code** and **OpenCode** as AI engines. It runs as a standalone web application — manage multiple Claude Code accounts, use built-in git source control, preview your app in a real browser, edit files, collaborate in real-time, and never lose progress with git-like checkpoints.
7
+ <p align="center">
8
+ <strong>Build more. Switch less.</strong><br />
9
+ All-in-one workspace for Claude Code & OpenCode
10
+ </p>
11
+
12
+ <p align="center">
13
+ <a href="https://clopen.myrialabs.dev">Website</a> ·
14
+ <a href="https://github.com/myrialabs/clopen/issues">Issues</a> ·
15
+ <a href="https://www.npmjs.com/package/@myrialabs/clopen">npm</a>
16
+ </p>
17
+
18
+ <p align="center">
19
+ <a href="https://www.npmjs.com/package/@myrialabs/clopen"><img src="https://img.shields.io/npm/v/@myrialabs/clopen" alt="npm version" /></a>
20
+ <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT" /></a>
21
+ <a href="https://github.com/myrialabs/clopen/pulls"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs Welcome" /></a>
22
+ <a href="https://bun.sh"><img src="https://img.shields.io/badge/Built%20with-Bun-black" alt="Built with Bun" /></a>
23
+ </p>
24
+
25
+ <p align="center">
26
+ <img src="https://clopen.myrialabs.dev/images/workspace-overview.webp" alt="Clopen workspace overview" />
27
+ </p>
28
+
29
+ All-in-one workspace for Claude Code & OpenCode. Chat, terminal, git, browser preview, and real-time collaboration, built for multi-project and multi-session workflows.
30
+
31
+ ---
32
+
33
+ ## Screenshots
34
+
35
+ ![AI chat interface](https://clopen.myrialabs.dev/images/ai-chat-interface.webp)
36
+
37
+ ![Multi-account manager](https://clopen.myrialabs.dev/images/multi-account-claude-code.webp)
38
+
39
+ ![Browser preview panel](https://clopen.myrialabs.dev/images/browser-preview-panel.webp)
40
+
41
+ ![Checkpoint restore](https://clopen.myrialabs.dev/images/checkpoint-restore.webp)
7
42
 
8
43
  ---
9
44
 
10
45
  ## Features
11
46
 
12
- - **Multi-Account Claude Code** - Manage multiple accounts (personal, work, team) and switch instantly per session, isolated under `~/.clopen/claude/user/` (or `~/.clopen-dev/` in development) without touching system-level Claude config
13
- - **Multi-Engine Support** - Switch between Claude Code and OpenCode
14
- - **AI Chat Interface** - Streaming responses with tool use visualization
15
- - **Background Processing** - Chat, terminal, and other processes continue running even when you close the browser — come back later and pick up where you left off
16
- - **Git-like Checkpoints** - Multi-branch undo/redo system with file and folder snapshots
17
- - **Real Browser Preview** - Puppeteer-based Chromium rendering with WebCodecs streaming (80-90% bandwidth reduction), full click/type/scroll/drag interaction
18
- - **Integrated Terminal** - Multi-tab terminal with full PTY control
19
- - **File Management** - Directory browsing, live editing, and real-time file watching
20
- - **Git Management** - Full source control: staging, commits, branches, push/pull, stash, log, conflict resolution
21
- - **Flexible Authentication** - No Login or With Login mode with admin/member roles, invite links, rate-limited login, CLI token recovery configurable during setup and in Settings
22
- - **Real-time Collaboration** - Multiple users can work on the same project simultaneously
23
- - **Built-in Cloudflare Tunnel** - Expose local projects publicly for testing and sharing
47
+ A complete development environment designed around AI-assisted workflows, built to disappear into the background and just work.
48
+
49
+ - **Multi-Account Claude Code** Manage multiple Claude Code accounts (personal, work, or team) and switch between them instantly per chat session
50
+ - **Multi-Engine Support** Switch between Claude Code and OpenCode as your AI engine, per session
51
+ - **Integrated Terminal** Full PTY emulation with xterm.js UI. Multi-tab terminal sessions with complete ANSI/VT sequence support and full keyboard control
52
+ - **Full Git Management** Stage, commit, branch, push, pull, stash, log, and resolve conflicts, all from a clean UI. Powered by native git CLI for accuracy
53
+ - **Real Browser Preview** A live browser preview streams directly into your workspace. Interact with your app manually, or let the AI drive: clicking, typing, and scrolling for autonomous visual testing
54
+ - **Git-Like Checkpoints** — Multi-branch undo/redo with full file snapshots. Roll back to any point in your AI conversation without touching your actual git history
55
+ - **Real-Time Collaboration** — WebSocket-based presence tracking per project. Multiple users can work on the same codebase simultaneously with live awareness
56
+ - **Monaco File Editor** VS Code's editor embedded in the browser. Full syntax highlighting, autocomplete, and live file watching, right beside your AI chat
57
+ - **Cloudflare Tunnel** — One-click public HTTPS URL for your local dev server. Built-in QR code for instant mobile access. Share your work without deploying
58
+ - **MCP Support** Full Model Context Protocol integration. Connect AI tools, external APIs, and custom capabilities to your AI agents with zero friction
59
+ - **Flexible Authentication** — No Login or With Login mode with admin/member roles, invite links, rate-limited login, and CLI token recovery
60
+ - **Background Processing** — Chat, terminal, and other processes continue running even when you close the browser — come back later and pick up where you left off
61
+ - **Database Management** — Browse tables, run queries, and inspect your database directly from the workspace *(coming soon)*
24
62
 
25
63
  ---
26
64
 
@@ -28,8 +66,8 @@
28
66
 
29
67
  ### Prerequisites
30
68
 
31
- - [Bun](https://bun.sh/) v1.2.12+
32
- - [Claude Code](https://github.com/anthropics/claude-code) and/or [OpenCode](https://github.com/anomalyco/opencode) — required for AI chat functionality
69
+ - [Bun.js](https://bun.sh/) v1.2.12+
70
+ - [Claude Code](https://github.com/anthropics/claude-code) or [OpenCode](https://opencode.ai) — required for AI functionality
33
71
 
34
72
  ### Installation
35
73
 
@@ -82,7 +120,9 @@ This regenerates and displays a new admin PAT.
82
120
 
83
121
  ---
84
122
 
85
- ## Development
123
+ ## Contributing
124
+
125
+ Clopen is open source and contributions are welcome! Whether it's a bug fix, new feature, or improvement to docs — feel free to open an issue or submit a pull request.
86
126
 
87
127
  ```bash
88
128
  git clone https://github.com/myrialabs/clopen.git
@@ -94,6 +134,8 @@ bun run check # Type checking
94
134
 
95
135
  When running in development mode, Clopen uses `~/.clopen-dev` instead of `~/.clopen`, keeping dev data separate from any production instance.
96
136
 
137
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines and [DECISIONS.md](DECISIONS.md) for architectural decisions.
138
+
97
139
  ---
98
140
 
99
141
  ## Architecture
@@ -112,14 +154,6 @@ Clopen uses an engine-agnostic adapter pattern — both engines normalize output
112
154
 
113
155
  ---
114
156
 
115
- ## Documentation
116
-
117
- - [Technical Decisions](DECISIONS.md) - Architectural and technical decision log
118
- - [Contributing](CONTRIBUTING.md) - How to contribute to this project
119
- - [Development Guidelines](CLAUDE.md) - Guidelines for working with Claude Code on this project
120
-
121
- ---
122
-
123
157
  ## Troubleshooting
124
158
 
125
159
  ### Port 9141 Already in Use
@@ -1141,16 +1141,19 @@ class StreamManager extends EventEmitter {
1141
1141
  }
1142
1142
  }
1143
1143
 
1144
- // Cancel the per-project engine this sends an interrupt to the
1145
- // still-alive SDK subprocess, then aborts the controller. If we abort
1146
- // the controller first, the subprocess dies and the SDK's subsequent
1147
- // interrupt write fails with "Operation aborted" (unhandled rejection
1148
- // that crashes Bun).
1144
+ // Cancel the per-project engine with a bounded timeout.
1145
+ // engine.cancel() stops the SDK process (Claude Code: close() kills subprocess,
1146
+ // OpenCode: aborts controller + HTTP abort to server). If cancel() hangs
1147
+ // (e.g. unresponsive SDK), the timeout ensures we always proceed to emit
1148
+ // events and update presence — preventing infinite loader on the frontend.
1149
1149
  const projectId = streamState.projectId || 'default';
1150
1150
  try {
1151
1151
  const engine = getProjectEngine(projectId, streamState.engine);
1152
1152
  if (engine.isActive) {
1153
- await engine.cancel();
1153
+ await Promise.race([
1154
+ engine.cancel(),
1155
+ new Promise<void>(resolve => setTimeout(resolve, 5000))
1156
+ ]);
1154
1157
  }
1155
1158
  } catch (error) {
1156
1159
  debug.error('chat', 'Error cancelling engine (non-fatal):', error);
@@ -1158,7 +1161,8 @@ class StreamManager extends EventEmitter {
1158
1161
 
1159
1162
  // Abort the stream-manager's controller as a fallback.
1160
1163
  // engine.cancel() already aborts the same controller, so this is
1161
- // typically a no-op but ensures cleanup if the engine wasn't active.
1164
+ // typically a no-op but ensures cleanup if the engine timed out
1165
+ // or wasn't active.
1162
1166
  if (!streamState.abortController?.signal.aborted) {
1163
1167
  streamState.abortController?.abort();
1164
1168
  }
@@ -770,20 +770,15 @@ export class OpenCodeEngine implements AIEngine {
770
770
  }
771
771
 
772
772
  async cancel(): Promise<void> {
773
- // Abort the OpenCode session on the server so it stops processing
774
- const client = getClient();
775
- if (client && this.activeSessionId) {
776
- try {
777
- await client.session.abort({
778
- path: { id: this.activeSessionId },
779
- ...(this.activeProjectPath && { query: { directory: this.activeProjectPath } }),
780
- });
781
- debug.log('engine', 'Open Code session aborted:', this.activeSessionId);
782
- } catch (error) {
783
- debug.warn('engine', 'Failed to abort Open Code session:', error);
784
- }
785
- }
786
-
773
+ // Capture refs before clearing needed for server-side abort below
774
+ const sessionId = this.activeSessionId;
775
+ const projectPath = this.activeProjectPath;
776
+
777
+ // 1. FIRST: Abort local stream processing immediately.
778
+ // This breaks the SSE event stream and causes the for-await loop
779
+ // in processStream() to throw AbortError, stopping all local processing.
780
+ // Must happen BEFORE the HTTP call because client.session.abort() can
781
+ // hang indefinitely if the OpenCode server is busy/unresponsive.
787
782
  if (this.activeAbortController) {
788
783
  this.activeAbortController.abort();
789
784
  this.activeAbortController = null;
@@ -792,6 +787,26 @@ export class OpenCodeEngine implements AIEngine {
792
787
  this.activeSessionId = null;
793
788
  this.activeProjectPath = null;
794
789
  this.pendingQuestions.clear();
790
+
791
+ // 2. THEN: Tell the OpenCode server to stop processing (with timeout).
792
+ // This is a courtesy cleanup — local processing is already stopped.
793
+ // The server-side session would otherwise keep running (consuming
794
+ // LLM API calls and compute resources) until it naturally completes.
795
+ const client = getClient();
796
+ if (client && sessionId) {
797
+ try {
798
+ await Promise.race([
799
+ client.session.abort({
800
+ path: { id: sessionId },
801
+ ...(projectPath && { query: { directory: projectPath } }),
802
+ }),
803
+ new Promise<void>(resolve => setTimeout(resolve, 5000))
804
+ ]);
805
+ debug.log('engine', 'Open Code session aborted:', sessionId);
806
+ } catch (error) {
807
+ debug.warn('engine', 'Failed to abort Open Code session (non-fatal):', error);
808
+ }
809
+ }
795
810
  }
796
811
 
797
812
  /**
@@ -802,13 +817,16 @@ export class OpenCodeEngine implements AIEngine {
802
817
  const client = getClient();
803
818
  if (!client || !sessionId) return;
804
819
  try {
805
- await client.session.abort({
806
- path: { id: sessionId },
807
- ...(projectPath && { query: { directory: projectPath } }),
808
- });
820
+ await Promise.race([
821
+ client.session.abort({
822
+ path: { id: sessionId },
823
+ ...(projectPath && { query: { directory: projectPath } }),
824
+ }),
825
+ new Promise<void>(resolve => setTimeout(resolve, 5000))
826
+ ]);
809
827
  debug.log('engine', 'Open Code session aborted (per-stream):', sessionId);
810
828
  } catch (error) {
811
- debug.warn('engine', 'Failed to abort Open Code session:', error);
829
+ debug.warn('engine', 'Failed to abort Open Code session (non-fatal):', error);
812
830
  }
813
831
  }
814
832
 
package/backend/index.ts CHANGED
@@ -27,6 +27,9 @@ import { statSync } from 'node:fs';
27
27
  // Import WebSocket router
28
28
  import { wsRouter } from './ws';
29
29
 
30
+ // Import browser preview manager for graceful shutdown
31
+ import { browserPreviewServiceManager } from './preview';
32
+
30
33
  // MCP remote server for Open Code custom tools
31
34
  import { handleMcpRequest, closeMcpServer } from './mcp/remote-server';
32
35
 
@@ -156,11 +159,18 @@ startServer().catch((error) => {
156
159
  });
157
160
 
158
161
  // Graceful shutdown - properly close server and database
162
+ let isShuttingDown = false;
163
+
159
164
  async function gracefulShutdown() {
165
+ if (isShuttingDown) return;
166
+ isShuttingDown = true;
167
+
160
168
  console.log('\n🛑 Shutting down server...');
161
169
  try {
162
170
  // Close MCP remote server (before engines, as they may still reference it)
163
171
  await closeMcpServer();
172
+ // Cleanup browser preview sessions
173
+ await browserPreviewServiceManager.cleanup();
164
174
  // Dispose all AI engines
165
175
  await disposeAllEngines();
166
176
  // Stop accepting new connections
@@ -177,6 +187,13 @@ async function gracefulShutdown() {
177
187
  process.on('SIGINT', gracefulShutdown);
178
188
  process.on('SIGTERM', gracefulShutdown);
179
189
 
190
+ // Ignore SIGHUP — sent when the controlling terminal closes or an SSH session
191
+ // disconnects. Without a handler Bun exits immediately; we want the server to
192
+ // keep running (e.g. started in a background tab or remote shell).
193
+ process.on('SIGHUP', () => {
194
+ debug.log('server', 'Received SIGHUP — ignoring (server stays running)');
195
+ });
196
+
180
197
  // Safety net: prevent server crash from unhandled errors.
181
198
  // These can occur when AI engine SDKs emit asynchronous errors that bypass
182
199
  // the normal try/catch flow (e.g., subprocess killed during initialization).
@@ -177,6 +177,8 @@ export async function switchTabHandler(args: { tabId: string; projectId?: string
177
177
  isError: true
178
178
  };
179
179
  }
180
+ // Promote tab to end of session's set so getActiveTabSession() targets it next
181
+ browserMcpControl.promoteSessionTab(tab.id, chatSessionId);
180
182
  }
181
183
 
182
184
  return {
@@ -206,6 +206,22 @@ export class BrowserMcpControl extends EventEmitter {
206
206
  // Control Acquisition
207
207
  // ============================================================================
208
208
 
209
+ /**
210
+ * Promote a tab to the end of the session's controlled set.
211
+ * This ensures getSessionTabs()[last] returns the most recently activated tab,
212
+ * which is used by getActiveTabSession to determine which tab MCP operates on.
213
+ *
214
+ * Must be called after switch_tab to reflect the new active tab.
215
+ */
216
+ promoteSessionTab(browserTabId: string, chatSessionId: string): void {
217
+ const sessionSet = this.sessionTabs.get(chatSessionId);
218
+ if (sessionSet && sessionSet.has(browserTabId)) {
219
+ sessionSet.delete(browserTabId);
220
+ sessionSet.add(browserTabId);
221
+ debug.log('mcp', `🔀 Promoted tab ${browserTabId} to end of session ${chatSessionId.slice(0, 8)} set`);
222
+ }
223
+ }
224
+
209
225
  /**
210
226
  * Acquire control of a browser tab for a chat session.
211
227
  *
@@ -1,22 +1,99 @@
1
1
  import { EventEmitter } from 'events';
2
- import type { Page, HTTPRequest, Frame } from 'puppeteer';
2
+ import type { Page, HTTPRequest, Frame, CDPSession } from 'puppeteer';
3
3
  import type { BrowserTab } from './types';
4
+ import { debug } from '$shared/utils/logger';
4
5
 
5
6
  export class BrowserNavigationTracker extends EventEmitter {
7
+ private cdpSessions = new Map<string, CDPSession>();
8
+
9
+ // Deduplication: track the last emitted navigation URL+timestamp per session.
10
+ // framenavigated and load often fire for the same navigation;
11
+ // this prevents emitting duplicate 'navigation' events that would cause
12
+ // parallel handleNavigation calls and double streaming restarts.
13
+ private lastNavigationEmit = new Map<string, { url: string; time: number }>();
14
+ private readonly DEDUP_WINDOW_MS = 500; // Ignore duplicate within 500ms
15
+
6
16
  constructor() {
7
17
  super();
8
18
  }
9
19
 
20
+ /**
21
+ * Emit a navigation event with deduplication.
22
+ * Returns true if the event was emitted, false if it was a duplicate.
23
+ */
24
+ private emitNavigationDeduped(event: string, sessionId: string, url: string, data: any): boolean {
25
+ const now = Date.now();
26
+ const last = this.lastNavigationEmit.get(sessionId);
27
+
28
+ if (last && last.url === url && (now - last.time) < this.DEDUP_WINDOW_MS) {
29
+ debug.log('preview', `⏭️ Deduped ${event} for ${url} (${now - last.time}ms since last emit)`);
30
+ return false;
31
+ }
32
+
33
+ this.lastNavigationEmit.set(sessionId, { url, time: now });
34
+ this.emit(event, data);
35
+ return true;
36
+ }
37
+
38
+ /**
39
+ * Check if two URLs differ only by hash/fragment.
40
+ * Hash-only changes are same-document navigations and should NOT trigger
41
+ * full page reload or streaming restart.
42
+ */
43
+ private isHashOnlyChange(oldUrl: string, newUrl: string): boolean {
44
+ try {
45
+ const oldParsed = new URL(oldUrl);
46
+ const newParsed = new URL(newUrl);
47
+ // Compare URLs without hash — if identical, it's a hash-only change
48
+ oldParsed.hash = '';
49
+ newParsed.hash = '';
50
+ return oldParsed.href === newParsed.href;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Check if two URLs share the same origin (protocol + host + port).
58
+ * Same-origin navigations are likely SPA internal navigations and should
59
+ * NOT show a progress bar — the streaming restart happens silently while
60
+ * the last rendered frame stays visible.
61
+ */
62
+ private isSameOrigin(oldUrl: string, newUrl: string): boolean {
63
+ try {
64
+ return new URL(oldUrl).origin === new URL(newUrl).origin;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
10
70
  async setupNavigationTracking(sessionId: string, page: Page, session: BrowserTab) {
11
71
 
12
- // Track navigation start (loading begins)
72
+ // Track navigation start (loading begins) — only for cross-origin document navigations
13
73
  page.on('request', (request: HTTPRequest) => {
14
74
  // Only track main frame document requests (not resources like images, CSS, etc.)
15
75
  // Puppeteer uses resourceType() instead of isNavigationRequest()
16
76
  if (request.resourceType() === 'document' && request.frame() === page.mainFrame()) {
17
77
  const targetUrl = request.url();
18
78
 
19
- // Emit navigation loading event to frontend
79
+ // Skip hash-only changes they are same-document navigations
80
+ // that don't need loading states or streaming restart
81
+ if (this.isHashOnlyChange(session.url, targetUrl)) {
82
+ debug.log('preview', `⏭️ Skipping navigation-loading for hash-only change: ${session.url} → ${targetUrl}`);
83
+ return;
84
+ }
85
+
86
+ // Skip same-origin navigations — they are likely SPA internal navigations.
87
+ // No progress bar is shown; the last rendered frame stays visible while
88
+ // streaming restarts silently in the background. This makes SPA navigation
89
+ // feel instant, similar to how a real browser shows the old page until
90
+ // the new one is ready.
91
+ if (this.isSameOrigin(session.url, targetUrl)) {
92
+ debug.log('preview', `⏭️ Skipping navigation-loading for same-origin navigation: ${session.url} → ${targetUrl}`);
93
+ return;
94
+ }
95
+
96
+ // Emit navigation loading event to frontend (cross-origin navigations only)
20
97
  this.emit('navigation-loading', {
21
98
  sessionId,
22
99
  type: 'navigation-loading',
@@ -26,17 +103,65 @@ export class BrowserNavigationTracker extends EventEmitter {
26
103
  }
27
104
  });
28
105
 
29
- // Track all navigation events - including redirects, link clicks, and hash changes
30
- page.on('framenavigated', (frame: Frame) => {
106
+ // Track full page navigations (actual page loads, not SPA)
107
+ page.on('framenavigated', async (frame: Frame) => {
31
108
  // Only track main frame navigation (not iframes)
32
109
  if (frame === page.mainFrame()) {
33
110
  const newUrl = frame.url();
34
111
 
112
+ // Skip internal Chrome error/system pages — they indicate a failed navigation
113
+ // and should not be surfaced to the frontend as a real URL change.
114
+ if (newUrl.startsWith('chrome-error://') || newUrl.startsWith('chrome://')) return;
115
+
116
+ // Skip if URL hasn't changed (already handled by navigatedWithinDocument)
117
+ if (newUrl === session.url) return;
118
+
119
+ // Hash-only changes should be treated as SPA navigations
120
+ // (no streaming restart needed, page context is unchanged)
121
+ if (this.isHashOnlyChange(session.url, newUrl)) {
122
+ debug.log('preview', `🔄 Hash-only change detected, treating as SPA navigation: ${session.url} → ${newUrl}`);
123
+ session.url = newUrl;
124
+ this.emit('navigation-spa', {
125
+ sessionId,
126
+ type: 'navigation-spa',
127
+ url: newUrl,
128
+ timestamp: Date.now()
129
+ });
130
+ return;
131
+ }
132
+
133
+ // Same-origin navigation: check if the video encoder script survived.
134
+ // SPA frameworks (SvelteKit, Next.js, etc.) often trigger framenavigated
135
+ // for client-side routing even though the page context is NOT replaced.
136
+ // If __webCodecsPeer still exists, the scripts are alive → SPA navigation.
137
+ // If it's gone, the page was truly replaced → full navigation + stream restart.
138
+ if (this.isSameOrigin(session.url, newUrl)) {
139
+ try {
140
+ const scriptAlive = await page.evaluate(() => !!(window as any).__webCodecsPeer);
141
+ if (scriptAlive) {
142
+ debug.log('preview', `🔄 Same-origin navigation with script alive (SPA): ${session.url} → ${newUrl}`);
143
+ session.url = newUrl;
144
+ this.emit('navigation-spa', {
145
+ sessionId,
146
+ type: 'navigation-spa',
147
+ url: newUrl,
148
+ timestamp: Date.now()
149
+ });
150
+ return;
151
+ }
152
+ debug.log('preview', `📄 Same-origin navigation with script dead (full reload): ${session.url} → ${newUrl}`);
153
+ } catch {
154
+ // page.evaluate failed — page context was replaced, fall through to full navigation
155
+ debug.log('preview', `📄 Same-origin navigation evaluate failed (full reload): ${session.url} → ${newUrl}`);
156
+ }
157
+ }
158
+
35
159
  // Update session URL
36
160
  session.url = newUrl;
37
161
 
38
- // Emit navigation completed event to frontend
39
- this.emit('navigation', {
162
+ // Emit navigation completed event (deduplicated to prevent double events
163
+ // from framenavigated + load firing for the same navigation)
164
+ this.emitNavigationDeduped('navigation', sessionId, newUrl, {
40
165
  sessionId,
41
166
  type: 'navigation',
42
167
  url: newUrl,
@@ -48,11 +173,45 @@ export class BrowserNavigationTracker extends EventEmitter {
48
173
  // Also track URL changes via JavaScript (for single page applications)
49
174
  page.on('load', async () => {
50
175
  const currentUrl = page.url();
176
+ // Skip internal Chrome error/system pages
177
+ if (currentUrl.startsWith('chrome-error://') || currentUrl.startsWith('chrome://')) return;
51
178
  if (currentUrl !== session.url) {
52
-
179
+
180
+ // Hash-only changes on load — treat as SPA navigation
181
+ if (this.isHashOnlyChange(session.url, currentUrl)) {
182
+ session.url = currentUrl;
183
+ this.emit('navigation-spa', {
184
+ sessionId,
185
+ type: 'navigation-spa',
186
+ url: currentUrl,
187
+ timestamp: Date.now()
188
+ });
189
+ return;
190
+ }
191
+
192
+ // Same-origin: check if video encoder script survived
193
+ if (this.isSameOrigin(session.url, currentUrl)) {
194
+ try {
195
+ const scriptAlive = await page.evaluate(() => !!(window as any).__webCodecsPeer);
196
+ if (scriptAlive) {
197
+ session.url = currentUrl;
198
+ this.emit('navigation-spa', {
199
+ sessionId,
200
+ type: 'navigation-spa',
201
+ url: currentUrl,
202
+ timestamp: Date.now()
203
+ });
204
+ return;
205
+ }
206
+ } catch {
207
+ // Fall through to full navigation
208
+ }
209
+ }
210
+
53
211
  session.url = currentUrl;
54
-
55
- this.emit('navigation', {
212
+
213
+ // Deduplicated: framenavigated already emitted for this URL
214
+ this.emitNavigationDeduped('navigation', sessionId, currentUrl, {
56
215
  sessionId,
57
216
  type: 'navigation',
58
217
  url: currentUrl,
@@ -61,34 +220,60 @@ export class BrowserNavigationTracker extends EventEmitter {
61
220
  }
62
221
  });
63
222
 
64
- // Track hash changes (fragment identifier changes like #contact-us)
65
- // Temporarily disabled URL tracking injection to test CloudFlare evasion
66
- /*
67
- await page.evaluateOnNewDocument(() => {
68
- let lastUrl = window.location.href;
223
+ // Track SPA navigations (pushState/replaceState) via CDP
224
+ // Uses Page.navigatedWithinDocument which fires for same-document navigations
225
+ // This is purely CDP-level — no script injection, safe from CloudFlare detection
226
+ try {
227
+ const cdp = await page.createCDPSession();
228
+ this.cdpSessions.set(sessionId, cdp);
69
229
 
70
- // Monitor for hash changes and other URL changes
71
- const checkUrlChange = () => {
72
- const currentUrl = window.location.href;
73
- if (currentUrl !== lastUrl) {
74
- lastUrl = currentUrl;
230
+ await cdp.send('Page.enable');
75
231
 
76
- // Store the new URL for the backend to detect
77
- (window as any).__urlChanged = {
78
- url: currentUrl,
79
- timestamp: Date.now()
80
- };
81
- }
82
- };
232
+ // Get main frame ID via CDP (reliable across Puppeteer versions)
233
+ const frameTree = await cdp.send('Page.getFrameTree');
234
+ const mainFrameId = frameTree.frameTree.frame.id;
83
235
 
84
- // Listen to various events that might change URL
85
- window.addEventListener('hashchange', checkUrlChange);
86
- window.addEventListener('popstate', checkUrlChange);
236
+ cdp.on('Page.navigatedWithinDocument', (params: { frameId: string; url: string }) => {
237
+ // Only track main frame SPA navigations (ignore iframe pushState)
238
+ if (params.frameId !== mainFrameId) return;
87
239
 
88
- // Periodically check for URL changes (for SPA navigation)
89
- setInterval(checkUrlChange, 500);
90
- });
91
- */
240
+ const newUrl = params.url;
241
+ if (newUrl === session.url) return;
242
+
243
+ debug.log('preview', `🔄 SPA navigation detected: ${session.url} → ${newUrl}`);
244
+
245
+ // Update session URL
246
+ session.url = newUrl;
247
+
248
+ // Emit SPA navigation event — no loading state, no stream restart
249
+ this.emit('navigation-spa', {
250
+ sessionId,
251
+ type: 'navigation-spa',
252
+ url: newUrl,
253
+ timestamp: Date.now()
254
+ });
255
+ });
256
+
257
+ debug.log('preview', `✅ CDP SPA navigation tracking setup for session: ${sessionId}`);
258
+ } catch (error) {
259
+ debug.warn('preview', `⚠️ Failed to setup CDP SPA tracking for ${sessionId}:`, error);
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Cleanup CDP session for a tab
265
+ */
266
+ async cleanupSession(sessionId: string) {
267
+ const cdp = this.cdpSessions.get(sessionId);
268
+ if (cdp) {
269
+ try {
270
+ await cdp.detach();
271
+ } catch {
272
+ // Ignore detach errors
273
+ }
274
+ this.cdpSessions.delete(sessionId);
275
+ }
276
+ this.lastNavigationEmit.delete(sessionId);
92
277
  }
93
278
 
94
279
  async navigateSession(sessionId: string, session: BrowserTab, url: string): Promise<string> {
@@ -51,7 +51,7 @@ const CHROMIUM_ARGS = [
51
51
  '--disable-blink-features=AutomationControlled',
52
52
  '--window-size=1366,768',
53
53
  '--autoplay-policy=no-user-gesture-required',
54
- '--disable-features=AudioServiceOutOfProcess'
54
+ '--disable-features=AudioServiceOutOfProcess,WebRtcHideLocalIpsWithMdns'
55
55
  ];
56
56
 
57
57
  class BrowserPool {