@openbuilder/cli 0.31.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 +1053 -0
- package/bin/openbuilder.js +31 -0
- package/dist/chunks/Banner-D4tqKfzA.js +113 -0
- package/dist/chunks/Banner-D4tqKfzA.js.map +1 -0
- package/dist/chunks/auto-update-Dj3lWPWO.js +350 -0
- package/dist/chunks/auto-update-Dj3lWPWO.js.map +1 -0
- package/dist/chunks/build-D0qYqIq0.js +116 -0
- package/dist/chunks/build-D0qYqIq0.js.map +1 -0
- package/dist/chunks/cleanup-qVTsA3tk.js +141 -0
- package/dist/chunks/cleanup-qVTsA3tk.js.map +1 -0
- package/dist/chunks/cli-error-BjQwvWtK.js +140 -0
- package/dist/chunks/cli-error-BjQwvWtK.js.map +1 -0
- package/dist/chunks/config-BGP1jZJ4.js +167 -0
- package/dist/chunks/config-BGP1jZJ4.js.map +1 -0
- package/dist/chunks/config-manager-BkbjtN-H.js +133 -0
- package/dist/chunks/config-manager-BkbjtN-H.js.map +1 -0
- package/dist/chunks/database-BvAbD4sP.js +68 -0
- package/dist/chunks/database-BvAbD4sP.js.map +1 -0
- package/dist/chunks/database-setup-BYjIRAmT.js +253 -0
- package/dist/chunks/database-setup-BYjIRAmT.js.map +1 -0
- package/dist/chunks/exports-ij9sv4UM.js +7793 -0
- package/dist/chunks/exports-ij9sv4UM.js.map +1 -0
- package/dist/chunks/init-CZoN6soU.js +468 -0
- package/dist/chunks/init-CZoN6soU.js.map +1 -0
- package/dist/chunks/init-tui-BNzk_7Yx.js +1127 -0
- package/dist/chunks/init-tui-BNzk_7Yx.js.map +1 -0
- package/dist/chunks/logger-ZpJi7chw.js +38 -0
- package/dist/chunks/logger-ZpJi7chw.js.map +1 -0
- package/dist/chunks/main-tui-Cq1hLCx-.js +644 -0
- package/dist/chunks/main-tui-Cq1hLCx-.js.map +1 -0
- package/dist/chunks/manager-CvGX9qqe.js +1161 -0
- package/dist/chunks/manager-CvGX9qqe.js.map +1 -0
- package/dist/chunks/port-allocator-BRFzgH9b.js +749 -0
- package/dist/chunks/port-allocator-BRFzgH9b.js.map +1 -0
- package/dist/chunks/process-killer-CaUL7Kpl.js +87 -0
- package/dist/chunks/process-killer-CaUL7Kpl.js.map +1 -0
- package/dist/chunks/prompts-1QbE_bRr.js +128 -0
- package/dist/chunks/prompts-1QbE_bRr.js.map +1 -0
- package/dist/chunks/repo-cloner-CpOQjFSo.js +219 -0
- package/dist/chunks/repo-cloner-CpOQjFSo.js.map +1 -0
- package/dist/chunks/repo-detector-B_oj696o.js +66 -0
- package/dist/chunks/repo-detector-B_oj696o.js.map +1 -0
- package/dist/chunks/run-D23hg4xy.js +630 -0
- package/dist/chunks/run-D23hg4xy.js.map +1 -0
- package/dist/chunks/runner-logger-instance-nDWv2h2T.js +899 -0
- package/dist/chunks/runner-logger-instance-nDWv2h2T.js.map +1 -0
- package/dist/chunks/spinner-BJL9zWAJ.js +53 -0
- package/dist/chunks/spinner-BJL9zWAJ.js.map +1 -0
- package/dist/chunks/start-BygPCbvw.js +1708 -0
- package/dist/chunks/start-BygPCbvw.js.map +1 -0
- package/dist/chunks/start-traditional-uoLZXdxm.js +255 -0
- package/dist/chunks/start-traditional-uoLZXdxm.js.map +1 -0
- package/dist/chunks/status-cS8YwtUx.js +97 -0
- package/dist/chunks/status-cS8YwtUx.js.map +1 -0
- package/dist/chunks/theme-DhorI2Hb.js +44 -0
- package/dist/chunks/theme-DhorI2Hb.js.map +1 -0
- package/dist/chunks/upgrade-CT6w0lKp.js +323 -0
- package/dist/chunks/upgrade-CT6w0lKp.js.map +1 -0
- package/dist/chunks/useBuildState-CdBSu9y_.js +331 -0
- package/dist/chunks/useBuildState-CdBSu9y_.js.map +1 -0
- package/dist/cli/index.js +694 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/index.js +14358 -0
- package/dist/index.js.map +1 -0
- package/dist/instrument.js +64226 -0
- package/dist/instrument.js.map +1 -0
- package/dist/templates.json +295 -0
- package/package.json +98 -0
- package/scripts/install-vendor-deps.js +34 -0
- package/scripts/install-vendor.js +167 -0
- package/scripts/prepare-release.js +71 -0
- package/templates/config.template.json +18 -0
- package/templates.json +295 -0
- package/vendor/ai-sdk-provider-claude-code-LOCAL.tgz +0 -0
- package/vendor/sentry-core-LOCAL.tgz +0 -0
- package/vendor/sentry-nextjs-LOCAL.tgz +0 -0
- package/vendor/sentry-node-LOCAL.tgz +0 -0
- package/vendor/sentry-node-core-LOCAL.tgz +0 -0
|
@@ -0,0 +1,1161 @@
|
|
|
1
|
+
// OpenBuilder CLI - Built with Rollup
|
|
2
|
+
import { execSync, spawn } from 'node:child_process';
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import { platform, arch } from 'node:os';
|
|
5
|
+
import { existsSync, mkdirSync, chmodSync } from 'node:fs';
|
|
6
|
+
import { resolve } from 'node:path';
|
|
7
|
+
import http from 'http';
|
|
8
|
+
import httpProxy from 'http-proxy';
|
|
9
|
+
import zlib from 'zlib';
|
|
10
|
+
|
|
11
|
+
const CLOUDFLARED_GITHUB = 'https://github.com/cloudflare/cloudflared/releases/latest/download';
|
|
12
|
+
/**
|
|
13
|
+
* Get the local bin directory for cloudflared installation
|
|
14
|
+
*/
|
|
15
|
+
function getBinDir() {
|
|
16
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp';
|
|
17
|
+
const binDir = resolve(homeDir, '.openbuilder', 'bin');
|
|
18
|
+
if (!existsSync(binDir)) {
|
|
19
|
+
mkdirSync(binDir, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
return binDir;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Detect the appropriate cloudflared binary name for this platform
|
|
25
|
+
*/
|
|
26
|
+
function getCloudflaredBinaryName() {
|
|
27
|
+
const plat = platform();
|
|
28
|
+
const architecture = arch();
|
|
29
|
+
if (plat === 'darwin') {
|
|
30
|
+
if (architecture === 'arm64') {
|
|
31
|
+
return 'cloudflared-darwin-arm64.tgz';
|
|
32
|
+
}
|
|
33
|
+
return 'cloudflared-darwin-amd64.tgz';
|
|
34
|
+
}
|
|
35
|
+
else if (plat === 'linux') {
|
|
36
|
+
if (architecture === 'arm64') {
|
|
37
|
+
return 'cloudflared-linux-arm64';
|
|
38
|
+
}
|
|
39
|
+
return 'cloudflared-linux-amd64';
|
|
40
|
+
}
|
|
41
|
+
else if (plat === 'win32') {
|
|
42
|
+
return 'cloudflared-windows-amd64.exe';
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`Unsupported platform: ${plat} ${architecture}`);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Check if cloudflared is already installed (globally or locally)
|
|
48
|
+
*/
|
|
49
|
+
function checkExistingInstallation() {
|
|
50
|
+
// Check global installation
|
|
51
|
+
try {
|
|
52
|
+
execSync('cloudflared --version', { stdio: 'ignore' });
|
|
53
|
+
return 'cloudflared';
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Not installed globally
|
|
57
|
+
}
|
|
58
|
+
// Check local installation
|
|
59
|
+
const binDir = getBinDir();
|
|
60
|
+
const localPath = resolve(binDir, 'cloudflared');
|
|
61
|
+
if (existsSync(localPath)) {
|
|
62
|
+
return localPath;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Download and install cloudflared binary
|
|
68
|
+
*/
|
|
69
|
+
async function downloadCloudflared() {
|
|
70
|
+
const binDir = getBinDir();
|
|
71
|
+
const binaryName = getCloudflaredBinaryName();
|
|
72
|
+
const downloadUrl = `${CLOUDFLARED_GITHUB}/${binaryName}`;
|
|
73
|
+
const plat = platform();
|
|
74
|
+
console.log(`📦 Downloading cloudflared from ${downloadUrl}...`);
|
|
75
|
+
if (plat === 'darwin') {
|
|
76
|
+
// macOS - download and extract tarball
|
|
77
|
+
const tarPath = resolve(binDir, 'cloudflared.tgz');
|
|
78
|
+
const extractDir = resolve(binDir, 'cloudflared-extract');
|
|
79
|
+
execSync(`curl -L "${downloadUrl}" -o "${tarPath}"`, { stdio: 'inherit' });
|
|
80
|
+
// Create extraction directory
|
|
81
|
+
if (!existsSync(extractDir)) {
|
|
82
|
+
mkdirSync(extractDir, { recursive: true });
|
|
83
|
+
}
|
|
84
|
+
// Extract
|
|
85
|
+
execSync(`tar -xzf "${tarPath}" -C "${extractDir}"`, { stdio: 'inherit' });
|
|
86
|
+
// Find the cloudflared binary in extracted files
|
|
87
|
+
const extractedBinary = resolve(extractDir, 'cloudflared');
|
|
88
|
+
const targetPath = resolve(binDir, 'cloudflared');
|
|
89
|
+
// Move to bin directory
|
|
90
|
+
execSync(`mv "${extractedBinary}" "${targetPath}"`, { stdio: 'inherit' });
|
|
91
|
+
// Cleanup
|
|
92
|
+
execSync(`rm -rf "${tarPath}" "${extractDir}"`, { stdio: 'ignore' });
|
|
93
|
+
// Make executable
|
|
94
|
+
chmodSync(targetPath, 0o755);
|
|
95
|
+
return targetPath;
|
|
96
|
+
}
|
|
97
|
+
else if (plat === 'linux') {
|
|
98
|
+
// Linux - download binary directly
|
|
99
|
+
const targetPath = resolve(binDir, 'cloudflared');
|
|
100
|
+
execSync(`curl -L "${downloadUrl}" -o "${targetPath}"`, { stdio: 'inherit' });
|
|
101
|
+
chmodSync(targetPath, 0o755);
|
|
102
|
+
return targetPath;
|
|
103
|
+
}
|
|
104
|
+
else if (plat === 'win32') {
|
|
105
|
+
// Windows - download .exe
|
|
106
|
+
const targetPath = resolve(binDir, 'cloudflared.exe');
|
|
107
|
+
execSync(`curl -L "${downloadUrl}" -o "${targetPath}"`, { stdio: 'inherit' });
|
|
108
|
+
return targetPath;
|
|
109
|
+
}
|
|
110
|
+
throw new Error(`Unsupported platform: ${plat}`);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Ensure cloudflared is installed and return the path to the binary
|
|
114
|
+
*/
|
|
115
|
+
async function ensureCloudflared(silent = false) {
|
|
116
|
+
// Check if already installed
|
|
117
|
+
const existing = checkExistingInstallation();
|
|
118
|
+
if (existing) {
|
|
119
|
+
if (!silent) {
|
|
120
|
+
console.log(`✅ cloudflared found: ${existing}`);
|
|
121
|
+
}
|
|
122
|
+
return existing;
|
|
123
|
+
}
|
|
124
|
+
if (!silent) {
|
|
125
|
+
console.log('📦 cloudflared not found, installing...');
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const path = await downloadCloudflared();
|
|
129
|
+
if (!silent) {
|
|
130
|
+
console.log(`✅ cloudflared installed to: ${path}`);
|
|
131
|
+
}
|
|
132
|
+
return path;
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
console.error('❌ Failed to install cloudflared:', error);
|
|
136
|
+
throw new Error('Failed to install cloudflared. Please install manually: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// src/lib/selection/hmr-proxy-script.ts
|
|
141
|
+
var SELECTION_SCRIPT = `
|
|
142
|
+
(function() {
|
|
143
|
+
|
|
144
|
+
// Selection state - DORMANT by default
|
|
145
|
+
let isInspectorActive = false;
|
|
146
|
+
let inspectorStyle = null;
|
|
147
|
+
let highlightedElement = null;
|
|
148
|
+
let highlightOverlay = null;
|
|
149
|
+
let mouseHandler = null;
|
|
150
|
+
let clickHandler = null;
|
|
151
|
+
|
|
152
|
+
function getProxyPrefix() {
|
|
153
|
+
try {
|
|
154
|
+
var parts = window.location.pathname.split('/').filter(Boolean);
|
|
155
|
+
var projectsIndex = parts.indexOf('projects');
|
|
156
|
+
if (projectsIndex === -1) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
var projectId = parts[projectsIndex + 1];
|
|
161
|
+
if (!projectId) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return '/api/projects/' + projectId + '/proxy?path=';
|
|
166
|
+
} catch (error) {
|
|
167
|
+
console.warn('\u26A0\uFE0F [OpenBuilder CSS] Unable to derive proxy prefix:', error);
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
var proxyPrefix = getProxyPrefix();
|
|
173
|
+
|
|
174
|
+
function rewriteStylesheetHref(link) {
|
|
175
|
+
if (!proxyPrefix || !link) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
var href = link.getAttribute('href');
|
|
180
|
+
if (!href) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
var trimmed = href.trim();
|
|
185
|
+
|
|
186
|
+
if (
|
|
187
|
+
trimmed.indexOf('proxy?path=') !== -1 ||
|
|
188
|
+
trimmed.indexOf('http://') === 0 ||
|
|
189
|
+
trimmed.indexOf('https://') === 0 ||
|
|
190
|
+
trimmed.indexOf('//') === 0 ||
|
|
191
|
+
trimmed.indexOf('data:') === 0
|
|
192
|
+
) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (trimmed.charAt(0) === '/') {
|
|
197
|
+
var proxiedHref = proxyPrefix + encodeURIComponent(trimmed);
|
|
198
|
+
if (link.getAttribute('href') !== proxiedHref) {
|
|
199
|
+
link.setAttribute('href', proxiedHref);
|
|
200
|
+
console.log('\u{1F3A8} [OpenBuilder CSS] rewrote stylesheet href to proxy:', proxiedHref);
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Debug helper: track when stylesheets are added/loaded inside the iframe
|
|
209
|
+
function monitorStylesheets() {
|
|
210
|
+
const loggedLinks = new WeakSet();
|
|
211
|
+
const loggedStyles = new WeakSet();
|
|
212
|
+
|
|
213
|
+
const logLink = (link, phase) => {
|
|
214
|
+
if (!link) return;
|
|
215
|
+
const href = link.getAttribute('href') || '(no href)';
|
|
216
|
+
console.log('\u{1F3A8} [OpenBuilder CSS]', phase + ' stylesheet:', href);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const logStyle = (style, phase) => {
|
|
220
|
+
if (!style) return;
|
|
221
|
+
const sample = (style.textContent || '')
|
|
222
|
+
.replace(/s+/g, ' ')
|
|
223
|
+
.trim()
|
|
224
|
+
.slice(0, 140);
|
|
225
|
+
console.log('\u{1F3A8} [OpenBuilder CSS]', phase + ' inline style', sample);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const attachLinkListeners = (link, phase) => {
|
|
229
|
+
if (!link || loggedLinks.has(link)) return;
|
|
230
|
+
loggedLinks.add(link);
|
|
231
|
+
const rewritten = rewriteStylesheetHref(link);
|
|
232
|
+
const phaseLabel = rewritten ? phase + ' (rewritten)' : phase;
|
|
233
|
+
logLink(link, phaseLabel);
|
|
234
|
+
|
|
235
|
+
link.addEventListener(
|
|
236
|
+
'load',
|
|
237
|
+
() => {
|
|
238
|
+
rewriteStylesheetHref(link);
|
|
239
|
+
logLink(link, 'loaded');
|
|
240
|
+
},
|
|
241
|
+
{ once: true }
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
link.addEventListener(
|
|
245
|
+
'error',
|
|
246
|
+
() => {
|
|
247
|
+
rewriteStylesheetHref(link);
|
|
248
|
+
logLink(link, 'error loading');
|
|
249
|
+
},
|
|
250
|
+
{ once: true }
|
|
251
|
+
);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const recordStyleElement = (style, phase) => {
|
|
255
|
+
if (!style || loggedStyles.has(style)) return;
|
|
256
|
+
loggedStyles.add(style);
|
|
257
|
+
logStyle(style, phase);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// Observe new link/style nodes appended to the document
|
|
261
|
+
const observer = new MutationObserver((mutations) => {
|
|
262
|
+
mutations.forEach((mutation) => {
|
|
263
|
+
mutation.addedNodes.forEach((node) => {
|
|
264
|
+
if (!(node instanceof HTMLElement)) return;
|
|
265
|
+
|
|
266
|
+
if (node.tagName === 'LINK' && (node.getAttribute('rel') || '').includes('stylesheet')) {
|
|
267
|
+
attachLinkListeners(node, 'added');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (node.tagName === 'STYLE') {
|
|
271
|
+
recordStyleElement(node, 'added');
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
const head = document.head || document.documentElement;
|
|
279
|
+
if (head) {
|
|
280
|
+
observer.observe(head, { childList: true, subtree: true });
|
|
281
|
+
}
|
|
282
|
+
} catch (e) {
|
|
283
|
+
console.warn('\u26A0\uFE0F [OpenBuilder CSS] Failed to observe stylesheet mutations:', e);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Log existing stylesheet/link elements when script runs
|
|
287
|
+
document
|
|
288
|
+
.querySelectorAll('link[rel~="stylesheet"], style')
|
|
289
|
+
.forEach((node) => {
|
|
290
|
+
if (node.tagName === 'LINK') {
|
|
291
|
+
attachLinkListeners(node, 'existing');
|
|
292
|
+
} else if (node.tagName === 'STYLE') {
|
|
293
|
+
recordStyleElement(node, 'existing');
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// Capture late load events (when link is in DOM before listener added)
|
|
298
|
+
document.addEventListener(
|
|
299
|
+
'load',
|
|
300
|
+
(event) => {
|
|
301
|
+
const target = event.target;
|
|
302
|
+
if (
|
|
303
|
+
target instanceof HTMLLinkElement &&
|
|
304
|
+
(target.getAttribute('rel') || '').includes('stylesheet')
|
|
305
|
+
) {
|
|
306
|
+
attachLinkListeners(target, 'load event');
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
true
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
monitorStylesheets();
|
|
314
|
+
|
|
315
|
+
// Create highlight overlay
|
|
316
|
+
function createHighlightOverlay() {
|
|
317
|
+
if (highlightOverlay) return highlightOverlay;
|
|
318
|
+
|
|
319
|
+
const overlay = document.createElement('div');
|
|
320
|
+
overlay.id = '__openbuilder-highlight';
|
|
321
|
+
overlay.style.cssText = \`
|
|
322
|
+
position: absolute;
|
|
323
|
+
pointer-events: none;
|
|
324
|
+
border: 2px solid #7553FF;
|
|
325
|
+
background: rgba(117, 83, 255, 0.1);
|
|
326
|
+
z-index: 999999;
|
|
327
|
+
transition: all 0.1s ease;
|
|
328
|
+
box-shadow: 0 0 0 1px rgba(117, 83, 255, 0.3), 0 0 20px rgba(117, 83, 255, 0.4);
|
|
329
|
+
\`;
|
|
330
|
+
document.body.appendChild(overlay);
|
|
331
|
+
highlightOverlay = overlay;
|
|
332
|
+
return overlay;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Remove highlight overlay
|
|
336
|
+
function removeHighlightOverlay() {
|
|
337
|
+
if (highlightOverlay) {
|
|
338
|
+
highlightOverlay.remove();
|
|
339
|
+
highlightOverlay = null;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Highlight element on hover
|
|
344
|
+
function highlightElement(element) {
|
|
345
|
+
if (!element || !isInspectorActive) {
|
|
346
|
+
removeHighlightOverlay();
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const rect = element.getBoundingClientRect();
|
|
351
|
+
const overlay = createHighlightOverlay();
|
|
352
|
+
|
|
353
|
+
overlay.style.left = rect.left + window.scrollX + 'px';
|
|
354
|
+
overlay.style.top = rect.top + window.scrollY + 'px';
|
|
355
|
+
overlay.style.width = rect.width + 'px';
|
|
356
|
+
overlay.style.height = rect.height + 'px';
|
|
357
|
+
|
|
358
|
+
highlightedElement = element;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Generate unique CSS selector for element
|
|
362
|
+
function generateSelector(element) {
|
|
363
|
+
// Strategy 1: data-testid (best)
|
|
364
|
+
const testId = element.getAttribute('data-testid');
|
|
365
|
+
if (testId) {
|
|
366
|
+
return \`[data-testid="\${testId}"]\`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Strategy 2: ID (good)
|
|
370
|
+
if (element.id) {
|
|
371
|
+
return \`#\${element.id}\`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Strategy 3: Class + tag (ok) - but skip classes with colons (Tailwind responsive)
|
|
375
|
+
const classes = Array.from(element.classList)
|
|
376
|
+
.filter(c => !c.match(/^(hover:|focus:|active:|group-|animate-|transition-)/))
|
|
377
|
+
.filter(c => !c.includes(':')) // Skip Tailwind responsive classes
|
|
378
|
+
.slice(0, 3) // Limit to first 3 classes
|
|
379
|
+
.join('.');
|
|
380
|
+
|
|
381
|
+
if (classes) {
|
|
382
|
+
const tagName = element.tagName.toLowerCase();
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
// Check if unique enough
|
|
386
|
+
const selector = \`\${tagName}.\${classes}\`;
|
|
387
|
+
const matches = document.querySelectorAll(selector);
|
|
388
|
+
|
|
389
|
+
if (matches.length === 1) {
|
|
390
|
+
return selector;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Add nth-child if multiple matches
|
|
394
|
+
const parent = element.parentElement;
|
|
395
|
+
if (parent) {
|
|
396
|
+
const siblings = Array.from(parent.children);
|
|
397
|
+
const index = siblings.indexOf(element) + 1;
|
|
398
|
+
return \`\${selector}:nth-child(\${index})\`;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return selector;
|
|
402
|
+
} catch (err) {
|
|
403
|
+
console.warn('Invalid selector, falling back to path:', err);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Strategy 4: Full path (fallback)
|
|
408
|
+
return getFullPath(element);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Get full CSS path to element
|
|
412
|
+
function getFullPath(element) {
|
|
413
|
+
const path = [];
|
|
414
|
+
let current = element;
|
|
415
|
+
|
|
416
|
+
while (current && current !== document.body) {
|
|
417
|
+
let selector = current.tagName.toLowerCase();
|
|
418
|
+
|
|
419
|
+
if (current.id) {
|
|
420
|
+
selector += \`#\${current.id}\`;
|
|
421
|
+
path.unshift(selector);
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const parent = current.parentElement;
|
|
426
|
+
if (parent) {
|
|
427
|
+
const siblings = Array.from(parent.children).filter(
|
|
428
|
+
child => child.tagName === current.tagName
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
if (siblings.length > 1) {
|
|
432
|
+
const index = siblings.indexOf(current) + 1;
|
|
433
|
+
selector += \`:nth-of-type(\${index})\`;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
path.unshift(selector);
|
|
438
|
+
current = current.parentElement;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return path.join(' > ');
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Capture element data and click position
|
|
445
|
+
function captureElementData(element, clickEvent) {
|
|
446
|
+
const rect = element.getBoundingClientRect();
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
selector: generateSelector(element),
|
|
450
|
+
tagName: element.tagName.toLowerCase(),
|
|
451
|
+
className: element.className,
|
|
452
|
+
id: element.id,
|
|
453
|
+
textContent: element.textContent?.trim().slice(0, 100),
|
|
454
|
+
innerHTML: element.innerHTML?.slice(0, 200),
|
|
455
|
+
attributes: Array.from(element.attributes).reduce((acc, attr) => {
|
|
456
|
+
acc[attr.name] = attr.value;
|
|
457
|
+
return acc;
|
|
458
|
+
}, {}),
|
|
459
|
+
boundingRect: {
|
|
460
|
+
top: rect.top,
|
|
461
|
+
left: rect.left,
|
|
462
|
+
width: rect.width,
|
|
463
|
+
height: rect.height,
|
|
464
|
+
},
|
|
465
|
+
clickPosition: {
|
|
466
|
+
x: clickEvent.clientX,
|
|
467
|
+
y: clickEvent.clientY,
|
|
468
|
+
},
|
|
469
|
+
computedStyles: {
|
|
470
|
+
backgroundColor: window.getComputedStyle(element).backgroundColor,
|
|
471
|
+
color: window.getComputedStyle(element).color,
|
|
472
|
+
fontSize: window.getComputedStyle(element).fontSize,
|
|
473
|
+
fontFamily: window.getComputedStyle(element).fontFamily,
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Mouse move handler (hover preview)
|
|
479
|
+
function handleMouseMove(e) {
|
|
480
|
+
if (!isInspectorActive) return;
|
|
481
|
+
|
|
482
|
+
const element = e.target;
|
|
483
|
+
if (element && element !== highlightedElement) {
|
|
484
|
+
highlightElement(element);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Click handler (select element)
|
|
489
|
+
function handleClick(e) {
|
|
490
|
+
console.log('\u{1F5B1}\uFE0F Click detected, selection mode:', isInspectorActive);
|
|
491
|
+
|
|
492
|
+
if (!isInspectorActive) return;
|
|
493
|
+
|
|
494
|
+
e.preventDefault();
|
|
495
|
+
e.stopPropagation();
|
|
496
|
+
|
|
497
|
+
const element = e.target;
|
|
498
|
+
const data = captureElementData(element, e);
|
|
499
|
+
|
|
500
|
+
console.log('\u{1F3AF} Element captured:', data);
|
|
501
|
+
console.log(' Click position:', data.clickPosition);
|
|
502
|
+
console.log('\u{1F4E4} Sending postMessage to parent...');
|
|
503
|
+
|
|
504
|
+
// Send to parent window
|
|
505
|
+
window.parent.postMessage({
|
|
506
|
+
type: 'openbuilder:element-selected',
|
|
507
|
+
data,
|
|
508
|
+
}, '*');
|
|
509
|
+
|
|
510
|
+
console.log('\u2705 Message sent to parent');
|
|
511
|
+
|
|
512
|
+
// Disable selection mode after selection
|
|
513
|
+
setInspectorActive(false);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Activate/deactivate inspector (DORMANT PATTERN)
|
|
517
|
+
function setInspectorActive(active) {
|
|
518
|
+
isInspectorActive = active;
|
|
519
|
+
|
|
520
|
+
if (active) {
|
|
521
|
+
// Add inspector styles ONLY when activated
|
|
522
|
+
if (!inspectorStyle) {
|
|
523
|
+
inspectorStyle = document.createElement('style');
|
|
524
|
+
inspectorStyle.textContent = \`
|
|
525
|
+
.inspector-active * {
|
|
526
|
+
cursor: crosshair !important;
|
|
527
|
+
}
|
|
528
|
+
.inspector-highlight {
|
|
529
|
+
outline: 2px solid #7553FF !important;
|
|
530
|
+
outline-offset: -2px !important;
|
|
531
|
+
background-color: rgba(117, 83, 255, 0.1) !important;
|
|
532
|
+
}
|
|
533
|
+
\`;
|
|
534
|
+
document.head.appendChild(inspectorStyle);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
document.body.classList.add('inspector-active');
|
|
538
|
+
|
|
539
|
+
// Add event listeners ONLY when activated
|
|
540
|
+
if (!mouseHandler) {
|
|
541
|
+
mouseHandler = handleMouseMove;
|
|
542
|
+
clickHandler = handleClick;
|
|
543
|
+
document.addEventListener('mousemove', mouseHandler, true);
|
|
544
|
+
document.addEventListener('click', clickHandler, true);
|
|
545
|
+
console.log('\u2705 Inspector event listeners attached');
|
|
546
|
+
}
|
|
547
|
+
} else {
|
|
548
|
+
document.body.classList.remove('inspector-active');
|
|
549
|
+
|
|
550
|
+
// Remove highlight
|
|
551
|
+
if (highlightedElement) {
|
|
552
|
+
highlightedElement = null;
|
|
553
|
+
}
|
|
554
|
+
removeHighlightOverlay();
|
|
555
|
+
|
|
556
|
+
// Remove event listeners when deactivated
|
|
557
|
+
if (mouseHandler) {
|
|
558
|
+
document.removeEventListener('mousemove', mouseHandler, true);
|
|
559
|
+
document.removeEventListener('click', clickHandler, true);
|
|
560
|
+
mouseHandler = null;
|
|
561
|
+
clickHandler = null;
|
|
562
|
+
console.log('\u{1F9F9} Inspector event listeners removed');
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Remove styles
|
|
566
|
+
if (inspectorStyle) {
|
|
567
|
+
inspectorStyle.remove();
|
|
568
|
+
inspectorStyle = null;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Listen for activation/deactivation from parent
|
|
575
|
+
window.addEventListener('message', (e) => {
|
|
576
|
+
if (e.data.type === 'openbuilder:toggle-selection-mode') {
|
|
577
|
+
setInspectorActive(e.data.enabled);
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
// Announce ready to parent
|
|
582
|
+
window.parent.postMessage({ type: 'openbuilder:ready' }, '*');
|
|
583
|
+
})();
|
|
584
|
+
`;
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Injection Proxy for Remote Runner Support
|
|
588
|
+
*
|
|
589
|
+
* This proxy sits between the dev server and the Cloudflare tunnel,
|
|
590
|
+
* injecting the element selection script into HTML responses.
|
|
591
|
+
*
|
|
592
|
+
* This enables the "select element" feature to work when:
|
|
593
|
+
* - Frontend is hosted remotely (e.g., Vercel)
|
|
594
|
+
* - Runner is running locally on user's machine
|
|
595
|
+
* - Traffic flows through Cloudflare tunnel
|
|
596
|
+
*
|
|
597
|
+
* Without this proxy, the selection script can't be injected because
|
|
598
|
+
* the iframe loads from a different origin (the tunnel URL).
|
|
599
|
+
*/
|
|
600
|
+
const DEFAULT_PROXY_PORT = 4000;
|
|
601
|
+
/**
|
|
602
|
+
* Create an injection proxy that forwards requests to a dev server
|
|
603
|
+
* while injecting the selection script into HTML responses.
|
|
604
|
+
*/
|
|
605
|
+
async function createInjectionProxy(options) {
|
|
606
|
+
const { targetPort, proxyPort = DEFAULT_PROXY_PORT, onError, log = console.log } = options;
|
|
607
|
+
const targetUrl = `http://localhost:${targetPort}`;
|
|
608
|
+
// Create proxy with WebSocket support
|
|
609
|
+
const proxy = httpProxy.createProxyServer({
|
|
610
|
+
target: targetUrl,
|
|
611
|
+
ws: true,
|
|
612
|
+
selfHandleResponse: true, // We need to modify HTML responses
|
|
613
|
+
changeOrigin: true,
|
|
614
|
+
});
|
|
615
|
+
const server = http.createServer((req, res) => {
|
|
616
|
+
// Forward the request through proxy
|
|
617
|
+
proxy.web(req, res, {}, (err) => {
|
|
618
|
+
if (err) {
|
|
619
|
+
onError?.(err);
|
|
620
|
+
// Try to send error response if headers not sent
|
|
621
|
+
if (!res.headersSent) {
|
|
622
|
+
res.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
623
|
+
res.end(`Proxy error: ${err.message}`);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
});
|
|
628
|
+
// Handle WebSocket upgrades (critical for HMR in Vite/Next.js/etc)
|
|
629
|
+
server.on('upgrade', (req, socket, head) => {
|
|
630
|
+
proxy.ws(req, socket, head, {}, (err) => {
|
|
631
|
+
if (err) {
|
|
632
|
+
onError?.(err);
|
|
633
|
+
socket.destroy();
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
// Intercept responses to inject script into HTML
|
|
638
|
+
proxy.on('proxyRes', (proxyRes, req, res) => {
|
|
639
|
+
const contentType = proxyRes.headers['content-type'] || '';
|
|
640
|
+
const isHtml = contentType.includes('text/html');
|
|
641
|
+
if (!isHtml) {
|
|
642
|
+
// Pass through non-HTML responses unchanged
|
|
643
|
+
// Copy all headers
|
|
644
|
+
res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
|
|
645
|
+
proxyRes.pipe(res);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
// Handle HTML - collect chunks and inject script
|
|
649
|
+
const chunks = [];
|
|
650
|
+
const encoding = proxyRes.headers['content-encoding'];
|
|
651
|
+
proxyRes.on('data', (chunk) => {
|
|
652
|
+
chunks.push(chunk);
|
|
653
|
+
});
|
|
654
|
+
proxyRes.on('end', () => {
|
|
655
|
+
try {
|
|
656
|
+
let body = Buffer.concat(chunks);
|
|
657
|
+
// Decompress if needed
|
|
658
|
+
if (encoding === 'gzip') {
|
|
659
|
+
try {
|
|
660
|
+
body = zlib.gunzipSync(body);
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
// If decompression fails, pass through unchanged
|
|
664
|
+
const headers = { ...proxyRes.headers };
|
|
665
|
+
res.writeHead(proxyRes.statusCode || 200, headers);
|
|
666
|
+
res.end(Buffer.concat(chunks));
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
else if (encoding === 'deflate') {
|
|
671
|
+
try {
|
|
672
|
+
body = zlib.inflateSync(body);
|
|
673
|
+
}
|
|
674
|
+
catch {
|
|
675
|
+
const headers = { ...proxyRes.headers };
|
|
676
|
+
res.writeHead(proxyRes.statusCode || 200, headers);
|
|
677
|
+
res.end(Buffer.concat(chunks));
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
else if (encoding === 'br') {
|
|
682
|
+
try {
|
|
683
|
+
body = zlib.brotliDecompressSync(body);
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
const headers = { ...proxyRes.headers };
|
|
687
|
+
res.writeHead(proxyRes.statusCode || 200, headers);
|
|
688
|
+
res.end(Buffer.concat(chunks));
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
let html = body.toString('utf-8');
|
|
693
|
+
// Inject selection script before </body> or </html> or at end
|
|
694
|
+
const scriptTag = `<script>${SELECTION_SCRIPT}</script>`;
|
|
695
|
+
if (html.toLowerCase().includes('</body>')) {
|
|
696
|
+
html = html.replace(/<\/body>/i, `${scriptTag}</body>`);
|
|
697
|
+
}
|
|
698
|
+
else if (html.toLowerCase().includes('</html>')) {
|
|
699
|
+
html = html.replace(/<\/html>/i, `${scriptTag}</html>`);
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
// Fallback: append at end
|
|
703
|
+
html += scriptTag;
|
|
704
|
+
}
|
|
705
|
+
// Prepare response
|
|
706
|
+
const responseBody = Buffer.from(html, 'utf-8');
|
|
707
|
+
// Copy headers, update content-length, remove encoding (we decompressed)
|
|
708
|
+
const headers = { ...proxyRes.headers };
|
|
709
|
+
delete headers['content-encoding'];
|
|
710
|
+
delete headers['transfer-encoding'];
|
|
711
|
+
headers['content-length'] = responseBody.length;
|
|
712
|
+
res.writeHead(proxyRes.statusCode || 200, headers);
|
|
713
|
+
res.end(responseBody);
|
|
714
|
+
}
|
|
715
|
+
catch (err) {
|
|
716
|
+
onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
717
|
+
// On error, try to pass through original response
|
|
718
|
+
if (!res.headersSent) {
|
|
719
|
+
res.writeHead(proxyRes.statusCode || 500, proxyRes.headers);
|
|
720
|
+
res.end(Buffer.concat(chunks));
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
proxyRes.on('error', (err) => {
|
|
725
|
+
onError?.(err);
|
|
726
|
+
if (!res.headersSent) {
|
|
727
|
+
res.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
728
|
+
res.end(`Proxy response error: ${err.message}`);
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
});
|
|
732
|
+
// Handle proxy errors
|
|
733
|
+
proxy.on('error', (err, req, res) => {
|
|
734
|
+
onError?.(err);
|
|
735
|
+
if (res && 'writeHead' in res && !res.headersSent) {
|
|
736
|
+
res.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
737
|
+
res.end(`Proxy error: ${err.message}`);
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
// Start server and return promise
|
|
741
|
+
return new Promise((resolve, reject) => {
|
|
742
|
+
server.on('error', (err) => {
|
|
743
|
+
if (err.code === 'EADDRINUSE') {
|
|
744
|
+
reject(new Error(`Injection proxy port ${proxyPort} is already in use`));
|
|
745
|
+
}
|
|
746
|
+
else {
|
|
747
|
+
reject(err);
|
|
748
|
+
}
|
|
749
|
+
});
|
|
750
|
+
server.listen(proxyPort, '127.0.0.1', () => {
|
|
751
|
+
log(`[injection-proxy] Started on port ${proxyPort} → localhost:${targetPort}`);
|
|
752
|
+
resolve({
|
|
753
|
+
port: proxyPort,
|
|
754
|
+
server,
|
|
755
|
+
close: () => new Promise((resolveClose) => {
|
|
756
|
+
// Add timeout to prevent hanging if connections don't close
|
|
757
|
+
const CLOSE_TIMEOUT_MS = 2000;
|
|
758
|
+
let resolved = false;
|
|
759
|
+
const timeoutId = setTimeout(() => {
|
|
760
|
+
if (!resolved) {
|
|
761
|
+
resolved = true;
|
|
762
|
+
log(`[injection-proxy] Force closing after ${CLOSE_TIMEOUT_MS}ms timeout`);
|
|
763
|
+
// Force destroy the server if it hasn't closed
|
|
764
|
+
try {
|
|
765
|
+
server.closeAllConnections?.();
|
|
766
|
+
}
|
|
767
|
+
catch {
|
|
768
|
+
// closeAllConnections may not be available in older Node versions
|
|
769
|
+
}
|
|
770
|
+
resolveClose();
|
|
771
|
+
}
|
|
772
|
+
}, CLOSE_TIMEOUT_MS);
|
|
773
|
+
proxy.close(() => {
|
|
774
|
+
server.close(() => {
|
|
775
|
+
if (!resolved) {
|
|
776
|
+
resolved = true;
|
|
777
|
+
clearTimeout(timeoutId);
|
|
778
|
+
log(`[injection-proxy] Stopped`);
|
|
779
|
+
}
|
|
780
|
+
resolveClose();
|
|
781
|
+
});
|
|
782
|
+
});
|
|
783
|
+
}),
|
|
784
|
+
});
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Check if a port is available
|
|
790
|
+
*/
|
|
791
|
+
async function isPortAvailable(port) {
|
|
792
|
+
return new Promise((resolve) => {
|
|
793
|
+
const server = http.createServer();
|
|
794
|
+
server.once('error', () => resolve(false));
|
|
795
|
+
server.once('listening', () => {
|
|
796
|
+
server.close(() => resolve(true));
|
|
797
|
+
});
|
|
798
|
+
server.listen(port, '127.0.0.1');
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Find an available port starting from the given port
|
|
803
|
+
*/
|
|
804
|
+
async function findAvailablePort(startPort, maxAttempts = 10) {
|
|
805
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
806
|
+
const port = startPort + i;
|
|
807
|
+
if (await isPortAvailable(port)) {
|
|
808
|
+
return port;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
throw new Error(`No available port found starting from ${startPort}`);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** Default port for the injection proxy */
|
|
815
|
+
const DEFAULT_INJECTION_PROXY_PORT = 4000;
|
|
816
|
+
class TunnelManager extends EventEmitter {
|
|
817
|
+
constructor() {
|
|
818
|
+
super(...arguments);
|
|
819
|
+
this.tunnels = new Map();
|
|
820
|
+
this.cloudflaredPath = null;
|
|
821
|
+
this.silent = false; // Suppress console output
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Set silent mode (for TUI)
|
|
825
|
+
*/
|
|
826
|
+
setSilent(silent) {
|
|
827
|
+
this.silent = silent;
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Conditional logging
|
|
831
|
+
*/
|
|
832
|
+
log(...args) {
|
|
833
|
+
if (!this.silent) {
|
|
834
|
+
console.log(...args);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* Create a tunnel for a specific port
|
|
839
|
+
* Returns the public tunnel URL
|
|
840
|
+
*
|
|
841
|
+
* The tunnel is created through an injection proxy that adds the element
|
|
842
|
+
* selection script to HTML responses. This enables the "select element"
|
|
843
|
+
* feature to work when the frontend is hosted remotely.
|
|
844
|
+
*/
|
|
845
|
+
async createTunnel(port, maxRetries = 5) {
|
|
846
|
+
// Check if tunnel already exists for this port
|
|
847
|
+
if (this.tunnels.has(port)) {
|
|
848
|
+
const existing = this.tunnels.get(port);
|
|
849
|
+
this.log(`🔗 Tunnel already exists for port ${port}: ${existing.url}`);
|
|
850
|
+
return existing.url;
|
|
851
|
+
}
|
|
852
|
+
// Ensure cloudflared is installed
|
|
853
|
+
if (!this.cloudflaredPath) {
|
|
854
|
+
this.cloudflaredPath = await ensureCloudflared(this.silent);
|
|
855
|
+
}
|
|
856
|
+
// Step 1: Start injection proxy
|
|
857
|
+
// This proxy injects the element selection script into HTML responses
|
|
858
|
+
let injectionProxy;
|
|
859
|
+
let proxyPort = DEFAULT_INJECTION_PROXY_PORT;
|
|
860
|
+
try {
|
|
861
|
+
// Find an available port for the proxy
|
|
862
|
+
proxyPort = await findAvailablePort(DEFAULT_INJECTION_PROXY_PORT);
|
|
863
|
+
injectionProxy = await createInjectionProxy({
|
|
864
|
+
targetPort: port,
|
|
865
|
+
proxyPort,
|
|
866
|
+
onError: (err) => this.log(`[injection-proxy] Error: ${err.message}`),
|
|
867
|
+
log: (...args) => this.log(...args),
|
|
868
|
+
});
|
|
869
|
+
this.log(`✅ Injection proxy started: localhost:${proxyPort} → localhost:${port}`);
|
|
870
|
+
}
|
|
871
|
+
catch (err) {
|
|
872
|
+
// Fallback: tunnel directly to dev server (selection won't work but preview will)
|
|
873
|
+
this.log(`⚠️ Injection proxy failed to start: ${err instanceof Error ? err.message : String(err)}`);
|
|
874
|
+
this.log(`⚠️ Falling back to direct tunnel (element selection will not work)`);
|
|
875
|
+
proxyPort = port; // Fall back to direct connection
|
|
876
|
+
}
|
|
877
|
+
// Step 2: Create tunnel to proxy port (or dev server if proxy failed)
|
|
878
|
+
const tunnelTargetPort = injectionProxy ? proxyPort : port;
|
|
879
|
+
// Try creating tunnel with smart retries
|
|
880
|
+
const errors = [];
|
|
881
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
882
|
+
try {
|
|
883
|
+
return await this._createTunnelAttempt(port, tunnelTargetPort, injectionProxy);
|
|
884
|
+
}
|
|
885
|
+
catch (error) {
|
|
886
|
+
const errorMsg = error?.message || String(error);
|
|
887
|
+
errors.push(errorMsg);
|
|
888
|
+
console.error(`Tunnel creation attempt ${attempt}/${maxRetries} failed:`, errorMsg);
|
|
889
|
+
// Check if this is a permanent error (fail fast)
|
|
890
|
+
if (this._isPermanentError(errorMsg)) {
|
|
891
|
+
// Clean up injection proxy on permanent failure
|
|
892
|
+
if (injectionProxy) {
|
|
893
|
+
await injectionProxy.close().catch(() => { });
|
|
894
|
+
}
|
|
895
|
+
throw new Error(`Permanent failure: ${errorMsg}`);
|
|
896
|
+
}
|
|
897
|
+
if (attempt === maxRetries) {
|
|
898
|
+
// Clean up injection proxy on final failure
|
|
899
|
+
if (injectionProxy) {
|
|
900
|
+
await injectionProxy.close().catch(() => { });
|
|
901
|
+
}
|
|
902
|
+
throw new Error(`Failed to create tunnel after ${maxRetries} attempts: ${errors.join('; ')}`);
|
|
903
|
+
}
|
|
904
|
+
// Exponential backoff with jitter to prevent thundering herd
|
|
905
|
+
const baseDelay = Math.pow(2, attempt) * 1000; // 2s, 4s, 8s, 16s
|
|
906
|
+
const jitter = Math.random() * 1000; // 0-1s random jitter
|
|
907
|
+
const delay = baseDelay + jitter;
|
|
908
|
+
this.log(`Retrying in ${Math.round(delay)}ms...`);
|
|
909
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
// Clean up injection proxy on failure
|
|
913
|
+
if (injectionProxy) {
|
|
914
|
+
await injectionProxy.close().catch(() => { });
|
|
915
|
+
}
|
|
916
|
+
throw new Error('Tunnel creation failed after all retries');
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* Check if an error is permanent (no point retrying)
|
|
920
|
+
*/
|
|
921
|
+
_isPermanentError(errorMsg) {
|
|
922
|
+
const permanentErrors = [
|
|
923
|
+
'port already in use',
|
|
924
|
+
'cloudflared not found',
|
|
925
|
+
'permission denied',
|
|
926
|
+
'cannot find',
|
|
927
|
+
'enoent',
|
|
928
|
+
'eacces',
|
|
929
|
+
];
|
|
930
|
+
const lowerMsg = errorMsg.toLowerCase();
|
|
931
|
+
return permanentErrors.some(err => lowerMsg.includes(err));
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Extract tunnel URL from cloudflared output
|
|
935
|
+
*/
|
|
936
|
+
_extractTunnelUrl(output) {
|
|
937
|
+
// Format: "Your quick Tunnel has been created! Visit it at: https://xxx.trycloudflare.com"
|
|
938
|
+
// Or just: "https://xxx.trycloudflare.com"
|
|
939
|
+
const match = output.match(/https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/);
|
|
940
|
+
return match ? match[0] : null;
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Verify tunnel is actually responding (async, non-blocking)
|
|
944
|
+
* This runs in background and only logs results
|
|
945
|
+
*/
|
|
946
|
+
async _verifyTunnelReady(url, maxWaitMs = 15000) {
|
|
947
|
+
const startTime = Date.now();
|
|
948
|
+
const checkInterval = 1000; // Check every 1 second (less aggressive)
|
|
949
|
+
this.log(`🔍 [Background] Verifying tunnel is ready: ${url}`);
|
|
950
|
+
while (Date.now() - startTime < maxWaitMs) {
|
|
951
|
+
try {
|
|
952
|
+
const controller = new AbortController();
|
|
953
|
+
const timeoutId = setTimeout(() => controller.abort(), 2000);
|
|
954
|
+
const response = await fetch(url, {
|
|
955
|
+
method: 'HEAD',
|
|
956
|
+
signal: controller.signal,
|
|
957
|
+
});
|
|
958
|
+
clearTimeout(timeoutId);
|
|
959
|
+
// Any response (even errors) means tunnel is connected
|
|
960
|
+
// We just need to verify it's resolving and routing
|
|
961
|
+
if (response.status < 500 || response.ok) {
|
|
962
|
+
const elapsed = Date.now() - startTime;
|
|
963
|
+
this.log(`✅ [Background] Tunnel verified in ${elapsed}ms`);
|
|
964
|
+
return true;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
catch (error) {
|
|
968
|
+
// Expected while DNS propagates or tunnel initializes
|
|
969
|
+
// Will keep retrying silently
|
|
970
|
+
}
|
|
971
|
+
await new Promise(resolve => setTimeout(resolve, checkInterval));
|
|
972
|
+
}
|
|
973
|
+
this.log(`⏱️ [Background] Verification timeout after ${maxWaitMs}ms (tunnel may still work)`);
|
|
974
|
+
return false;
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Single attempt to create a tunnel
|
|
978
|
+
* @param devServerPort - The original dev server port (used as key in tunnels map)
|
|
979
|
+
* @param tunnelTargetPort - The port to tunnel to (proxy port or dev server port if proxy failed)
|
|
980
|
+
* @param injectionProxy - Optional injection proxy instance for cleanup
|
|
981
|
+
*/
|
|
982
|
+
async _createTunnelAttempt(devServerPort, tunnelTargetPort, injectionProxy) {
|
|
983
|
+
return new Promise((resolve, reject) => {
|
|
984
|
+
const isUsingProxy = tunnelTargetPort !== devServerPort;
|
|
985
|
+
this.log(`[tunnel] Creating tunnel for port ${tunnelTargetPort}${isUsingProxy ? ` (proxy for dev server on ${devServerPort})` : ''}...`);
|
|
986
|
+
// Direct binary execution with unbuffered streams
|
|
987
|
+
const proc = spawn(this.cloudflaredPath, [
|
|
988
|
+
'tunnel',
|
|
989
|
+
'--url', `http://localhost:${tunnelTargetPort}`,
|
|
990
|
+
'--no-autoupdate',
|
|
991
|
+
], {
|
|
992
|
+
cwd: process.cwd(),
|
|
993
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
994
|
+
});
|
|
995
|
+
// Set streams to unbuffered mode immediately for responsive output
|
|
996
|
+
if (proc.stdout) {
|
|
997
|
+
proc.stdout.setEncoding('utf8');
|
|
998
|
+
proc.stdout.resume();
|
|
999
|
+
}
|
|
1000
|
+
if (proc.stderr) {
|
|
1001
|
+
proc.stderr.setEncoding('utf8');
|
|
1002
|
+
proc.stderr.resume();
|
|
1003
|
+
}
|
|
1004
|
+
this.log(`[tunnel] Cloudflared spawned with PID: ${proc.pid}`);
|
|
1005
|
+
let resolved = false;
|
|
1006
|
+
let tunnelUrl = null;
|
|
1007
|
+
let tunnelRegistered = false;
|
|
1008
|
+
const timeout = setTimeout(() => {
|
|
1009
|
+
if (!resolved) {
|
|
1010
|
+
proc.kill();
|
|
1011
|
+
reject(new Error('Tunnel creation timeout (30s)'));
|
|
1012
|
+
}
|
|
1013
|
+
}, 30000);
|
|
1014
|
+
// Shared handler for both stdout and stderr
|
|
1015
|
+
const handleOutput = async (data) => {
|
|
1016
|
+
const output = data.toString();
|
|
1017
|
+
// Step 1: Extract URL
|
|
1018
|
+
if (!tunnelUrl) {
|
|
1019
|
+
const url = this._extractTunnelUrl(output);
|
|
1020
|
+
if (url) {
|
|
1021
|
+
tunnelUrl = url;
|
|
1022
|
+
this.log(`✅ Tunnel URL received: ${url} → localhost:${tunnelTargetPort}${isUsingProxy ? ` → localhost:${devServerPort}` : ''}`);
|
|
1023
|
+
// Store with dev server port as key, but include proxy info
|
|
1024
|
+
this.tunnels.set(devServerPort, {
|
|
1025
|
+
url,
|
|
1026
|
+
port: devServerPort,
|
|
1027
|
+
proxyPort: tunnelTargetPort,
|
|
1028
|
+
process: proc,
|
|
1029
|
+
injectionProxy,
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
// Step 2: Wait for tunnel registration (edge connection established)
|
|
1034
|
+
if (tunnelUrl && !tunnelRegistered && output.includes('Registered tunnel connection')) {
|
|
1035
|
+
tunnelRegistered = true;
|
|
1036
|
+
this.log(`✅ Tunnel registered with Cloudflare edge (DNS propagating)`);
|
|
1037
|
+
// Wait 3 seconds after registration for DNS to propagate
|
|
1038
|
+
this.log(`⏳ Waiting 3 seconds for DNS to fully propagate...`);
|
|
1039
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
1040
|
+
// Step 3: Return only after registration + 3 second buffer
|
|
1041
|
+
if (!resolved) {
|
|
1042
|
+
resolved = true;
|
|
1043
|
+
clearTimeout(timeout);
|
|
1044
|
+
this.log(`✅ Tunnel ready: ${tunnelUrl}`);
|
|
1045
|
+
if (isUsingProxy) {
|
|
1046
|
+
this.log(`✅ Element selection enabled via injection proxy`);
|
|
1047
|
+
}
|
|
1048
|
+
// Note: Backend verification skipped for localhost tunnels
|
|
1049
|
+
// The tunnel connects localhost to Cloudflare - backend can't verify it
|
|
1050
|
+
// Frontend will verify DNS before loading in iframe
|
|
1051
|
+
resolve(tunnelUrl);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
proc.stdout.on('data', handleOutput);
|
|
1056
|
+
proc.stderr.on('data', (data) => {
|
|
1057
|
+
const output = data.toString();
|
|
1058
|
+
// Log errors (cloudflared uses stderr for all output)
|
|
1059
|
+
// Only show real errors, not shutdown messages
|
|
1060
|
+
const lower = output.toLowerCase();
|
|
1061
|
+
if ((lower.includes('error') || lower.includes('fatal')) &&
|
|
1062
|
+
!lower.includes('context canceled') &&
|
|
1063
|
+
!lower.includes('connection terminated') &&
|
|
1064
|
+
!lower.includes('no more connections active')) {
|
|
1065
|
+
this.log(`[cloudflared:${devServerPort}] ${output.trim()}`);
|
|
1066
|
+
}
|
|
1067
|
+
// Check for tunnel URL in stderr too
|
|
1068
|
+
handleOutput(data);
|
|
1069
|
+
});
|
|
1070
|
+
proc.on('exit', (code, signal) => {
|
|
1071
|
+
this.log(`Tunnel exited for port ${devServerPort} with code ${code} signal ${signal}`);
|
|
1072
|
+
// Clean up injection proxy when tunnel exits
|
|
1073
|
+
const tunnel = this.tunnels.get(devServerPort);
|
|
1074
|
+
if (tunnel?.injectionProxy) {
|
|
1075
|
+
tunnel.injectionProxy.close().catch(() => { });
|
|
1076
|
+
}
|
|
1077
|
+
this.tunnels.delete(devServerPort);
|
|
1078
|
+
this.emit('tunnel-closed', devServerPort);
|
|
1079
|
+
});
|
|
1080
|
+
proc.on('error', (error) => {
|
|
1081
|
+
if (!resolved) {
|
|
1082
|
+
clearTimeout(timeout);
|
|
1083
|
+
reject(error);
|
|
1084
|
+
}
|
|
1085
|
+
});
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Close tunnel for a specific port
|
|
1090
|
+
*/
|
|
1091
|
+
async closeTunnel(port) {
|
|
1092
|
+
const tunnel = this.tunnels.get(port);
|
|
1093
|
+
if (!tunnel) {
|
|
1094
|
+
this.log(`No tunnel found for port ${port}`);
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
this.log(`🔗 Closing tunnel for port ${port}...`);
|
|
1098
|
+
// Close injection proxy first (if exists) with timeout
|
|
1099
|
+
if (tunnel.injectionProxy) {
|
|
1100
|
+
try {
|
|
1101
|
+
// Use Promise.race to enforce timeout - injection proxy close can hang
|
|
1102
|
+
// if there are active HTTP keep-alive connections
|
|
1103
|
+
const PROXY_CLOSE_TIMEOUT_MS = 3000;
|
|
1104
|
+
await Promise.race([
|
|
1105
|
+
tunnel.injectionProxy.close(),
|
|
1106
|
+
new Promise((resolve) => setTimeout(() => {
|
|
1107
|
+
this.log(`⚠️ Injection proxy close timed out after ${PROXY_CLOSE_TIMEOUT_MS}ms, continuing...`);
|
|
1108
|
+
resolve();
|
|
1109
|
+
}, PROXY_CLOSE_TIMEOUT_MS))
|
|
1110
|
+
]);
|
|
1111
|
+
this.log(`✅ Injection proxy closed for port ${port}`);
|
|
1112
|
+
}
|
|
1113
|
+
catch (err) {
|
|
1114
|
+
this.log(`⚠️ Error closing injection proxy: ${err instanceof Error ? err.message : String(err)}`);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
return new Promise((resolve) => {
|
|
1118
|
+
const timeout = setTimeout(() => {
|
|
1119
|
+
// Force kill if not dead after 1 second
|
|
1120
|
+
if (!tunnel.process.killed) {
|
|
1121
|
+
tunnel.process.kill('SIGKILL');
|
|
1122
|
+
}
|
|
1123
|
+
this.tunnels.delete(port);
|
|
1124
|
+
resolve();
|
|
1125
|
+
}, 1000);
|
|
1126
|
+
tunnel.process.once('exit', () => {
|
|
1127
|
+
clearTimeout(timeout);
|
|
1128
|
+
this.tunnels.delete(port);
|
|
1129
|
+
resolve();
|
|
1130
|
+
});
|
|
1131
|
+
// Send SIGTERM
|
|
1132
|
+
tunnel.process.kill('SIGTERM');
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* Close all active tunnels
|
|
1137
|
+
*/
|
|
1138
|
+
async closeAll() {
|
|
1139
|
+
this.log(`🔗 Closing ${this.tunnels.size} active tunnel(s)...`);
|
|
1140
|
+
const ports = Array.from(this.tunnels.keys());
|
|
1141
|
+
await Promise.all(ports.map(port => this.closeTunnel(port)));
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Get tunnel URL for a specific port (if exists)
|
|
1145
|
+
*/
|
|
1146
|
+
getTunnelUrl(port) {
|
|
1147
|
+
const tunnel = this.tunnels.get(port);
|
|
1148
|
+
return tunnel ? tunnel.url : null;
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Get all active tunnels
|
|
1152
|
+
*/
|
|
1153
|
+
getActiveTunnels() {
|
|
1154
|
+
return Array.from(this.tunnels.values()).map(({ port, url }) => ({ port, url }));
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
// Singleton instance
|
|
1158
|
+
const tunnelManager = new TunnelManager();
|
|
1159
|
+
|
|
1160
|
+
export { TunnelManager, tunnelManager };
|
|
1161
|
+
//# sourceMappingURL=manager-CvGX9qqe.js.map
|