@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.
- package/README.md +61 -27
- package/backend/chat/stream-manager.ts +11 -7
- package/backend/engine/adapters/opencode/stream.ts +37 -19
- package/backend/index.ts +17 -0
- package/backend/mcp/servers/browser-automation/browser.ts +2 -0
- package/backend/preview/browser/browser-mcp-control.ts +16 -0
- package/backend/preview/browser/browser-navigation-tracker.ts +219 -34
- package/backend/preview/browser/browser-pool.ts +1 -1
- package/backend/preview/browser/browser-preview-service.ts +23 -34
- package/backend/preview/browser/browser-tab-manager.ts +16 -1
- package/backend/preview/browser/browser-video-capture.ts +15 -3
- package/backend/preview/browser/scripts/audio-stream.ts +5 -0
- package/backend/preview/browser/scripts/video-stream.ts +39 -4
- package/backend/preview/browser/types.ts +7 -6
- package/backend/ws/preview/browser/interact.ts +46 -50
- package/backend/ws/preview/browser/webcodecs.ts +35 -15
- package/backend/ws/preview/index.ts +8 -0
- package/frontend/components/chat/input/ChatInput.svelte +3 -3
- package/frontend/components/common/feedback/NotificationToast.svelte +26 -11
- package/frontend/components/files/FileNode.svelte +16 -58
- package/frontend/components/git/CommitForm.svelte +1 -1
- package/frontend/components/preview/browser/BrowserPreview.svelte +10 -3
- package/frontend/components/preview/browser/components/Canvas.svelte +158 -64
- package/frontend/components/preview/browser/components/Container.svelte +26 -8
- package/frontend/components/preview/browser/components/Toolbar.svelte +35 -18
- package/frontend/components/preview/browser/core/coordinator.svelte.ts +26 -1
- package/frontend/components/preview/browser/core/stream-handler.svelte.ts +66 -9
- package/frontend/components/preview/browser/core/tab-operations.svelte.ts +5 -4
- package/frontend/components/workspace/PanelHeader.svelte +8 -6
- package/frontend/components/workspace/panels/PreviewPanel.svelte +1 -0
- package/frontend/services/chat/chat.service.ts +25 -3
- package/frontend/services/notification/push.service.ts +2 -2
- package/frontend/services/preview/browser/browser-webcodecs.service.ts +277 -61
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,26 +1,64 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://clopen.myrialabs.dev/favicon.svg" alt="Clopen" width="72" height="72" />
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
[](https://bun.sh)
|
|
5
|
+
<h1 align="center">Clopen</h1>
|
|
5
6
|
|
|
6
|
-
|
|
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
|
+

|
|
36
|
+
|
|
37
|
+

|
|
38
|
+
|
|
39
|
+

|
|
40
|
+
|
|
41
|
+

|
|
7
42
|
|
|
8
43
|
---
|
|
9
44
|
|
|
10
45
|
## Features
|
|
11
46
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
- **
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
20
|
-
- **
|
|
21
|
-
- **
|
|
22
|
-
- **
|
|
23
|
-
- **
|
|
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)
|
|
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
|
-
##
|
|
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
|
|
1145
|
-
//
|
|
1146
|
-
//
|
|
1147
|
-
//
|
|
1148
|
-
//
|
|
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
|
|
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
|
|
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
|
-
//
|
|
774
|
-
const
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
|
806
|
-
|
|
807
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
39
|
-
|
|
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
|
|
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
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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 {
|