Skip to content

Commit 8ed6ce5

Browse files
crisdosaygoclaude
andcommitted
Add BrowserBox browser integration
- Replace remote browser navigation with BrowserBox webview component - Bootstrap BrowserBox demo sessions from win9-5.com session API - Keep local iframe/directory browser path intact for local content - Remove COEP credentialless (blocks cross-origin BrowserBox iframe) - Update browserbox-webview.js with embedder-origin API Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8fe1638 commit 8ed6ce5

File tree

5 files changed

+4499
-280
lines changed

5 files changed

+4499
-280
lines changed

components/apps/Browser/StyledBrowser.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,24 @@ type StyledBrowserProps = {
55
};
66

77
const StyledBrowser = styled.div<StyledBrowserProps>`
8-
iframe {
8+
iframe,
9+
.browserbox-host {
910
background-color: ${({ $hasSrcDoc }) => ($hasSrcDoc ? "#fff" : "initial")};
1011
border: 0;
1112
height: calc(100% - 42px - 37px);
1213
width: 100%;
1314
}
1415
16+
.browserbox-host {
17+
background-color: #fff;
18+
}
19+
20+
.browserbox-host > browserbox-webview {
21+
display: block;
22+
height: 100%;
23+
width: 100%;
24+
}
25+
1526
nav {
1627
background-color: rgb(87 87 87);
1728
display: flex;
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
export const DEFAULT_BROWSERBOX_SESSION_API_BASE_URL =
2+
process.env.NEXT_PUBLIC_BROWSERBOX_SESSION_API_BASE_URL?.trim() ||
3+
"https://win9-5.com";
4+
5+
export const BROWSERBOX_WEBVIEW_ASSET_RELATIVE_PATH = "browserbox-webview.js";
6+
7+
const BROWSERBOX_SCRIPT_DATA_ATTRIBUTE = "data-browserbox-webview";
8+
const SESSION_REQUEST_TIMEOUT_MS = 120_000;
9+
10+
type BrowserBoxSessionSource = {
11+
expiresAt?: number;
12+
expires_at?: number;
13+
id?: string;
14+
loginLink?: string;
15+
loginUrl?: string;
16+
login_url?: string;
17+
region?: string;
18+
remainingMs?: number;
19+
remaining_ms?: number;
20+
sessionId?: string;
21+
session_id?: string;
22+
};
23+
24+
export type BrowserBoxSession = BrowserBoxSessionSource & {
25+
active?: boolean;
26+
loginUrl: string;
27+
region: string;
28+
remainingMs: number;
29+
sessionId: string;
30+
};
31+
32+
export type BrowserBoxTab = {
33+
active?: boolean;
34+
canGoBack?: boolean;
35+
canGoForward?: boolean;
36+
faviconDataURI?: string;
37+
id?: string;
38+
loading?: boolean;
39+
title?: string;
40+
url?: string;
41+
};
42+
43+
export type BrowserBoxWebviewElement = HTMLElement & {
44+
getTabs: () => Promise<BrowserBoxTab[]>;
45+
goBack: () => Promise<void>;
46+
goForward: () => Promise<void>;
47+
navigateTo: (url: string) => Promise<void>;
48+
reload: () => Promise<void>;
49+
stop: () => Promise<void>;
50+
whenReady: () => Promise<void>;
51+
};
52+
53+
let browserBoxAssetPromise: Promise<void> | undefined;
54+
55+
const withTrailingSlashRemoved = (value: string): string =>
56+
value.endsWith("/") ? value.slice(0, -1) : value;
57+
58+
export const normalizeBrowserBoxLoginLink = (rawLoginLink: string): string => {
59+
if (typeof rawLoginLink !== "string" || rawLoginLink.trim().length === 0) {
60+
return "";
61+
}
62+
63+
try {
64+
const parsed = new URL(rawLoginLink, window.location.href);
65+
parsed.searchParams.set("ui", "false");
66+
return parsed.href;
67+
} catch {
68+
return rawLoginLink.trim();
69+
}
70+
};
71+
72+
export const getBrowserBoxWebviewAssetUrl = (): string => {
73+
if (typeof window === "undefined") {
74+
return `/${BROWSERBOX_WEBVIEW_ASSET_RELATIVE_PATH}`;
75+
}
76+
77+
return new URL(
78+
BROWSERBOX_WEBVIEW_ASSET_RELATIVE_PATH,
79+
document.baseURI
80+
).toString();
81+
};
82+
83+
export const loadBrowserBoxWebviewAsset = async (
84+
assetUrl = getBrowserBoxWebviewAssetUrl()
85+
): Promise<void> => {
86+
if (typeof window === "undefined") return;
87+
if (window.customElements?.get("browserbox-webview")) return;
88+
if (!browserBoxAssetPromise) {
89+
browserBoxAssetPromise = new Promise<void>((resolve, reject) => {
90+
const existingScript = document.querySelector<HTMLScriptElement>(
91+
`script[${BROWSERBOX_SCRIPT_DATA_ATTRIBUTE}="true"]`
92+
);
93+
94+
if (existingScript) {
95+
existingScript.addEventListener("load", () => resolve(), {
96+
once: true,
97+
});
98+
existingScript.addEventListener(
99+
"error",
100+
() =>
101+
reject(
102+
new Error("Existing BrowserBox webview asset failed to load.")
103+
),
104+
{ once: true }
105+
);
106+
return;
107+
}
108+
109+
const script = document.createElement("script");
110+
111+
script.async = true;
112+
script.dataset.browserboxWebview = "true";
113+
script.src = assetUrl;
114+
script.addEventListener("load", () => resolve(), { once: true });
115+
script.addEventListener(
116+
"error",
117+
() =>
118+
reject(new Error(`Failed to load BrowserBox asset at ${assetUrl}.`)),
119+
{ once: true }
120+
);
121+
document.head.append(script);
122+
}).finally(() => {
123+
if (!window.customElements?.get("browserbox-webview")) {
124+
browserBoxAssetPromise = undefined;
125+
}
126+
});
127+
}
128+
129+
await browserBoxAssetPromise;
130+
};
131+
132+
export class BrowserBoxSessionClient {
133+
public readonly baseUrl: string;
134+
135+
public constructor(serverBaseUrl = DEFAULT_BROWSERBOX_SESSION_API_BASE_URL) {
136+
this.baseUrl = withTrailingSlashRemoved(serverBaseUrl.trim());
137+
}
138+
139+
public normalizeSession(raw: BrowserBoxSessionSource): BrowserBoxSession {
140+
const loginUrl = raw.loginUrl || raw.login_url || raw.loginLink || "";
141+
const sessionId = raw.sessionId || raw.session_id || raw.id || "";
142+
let remainingMs = Number(raw.remainingMs ?? raw.remaining_ms);
143+
144+
if (!Number.isFinite(remainingMs)) {
145+
const expiresAt = Number(raw.expiresAt ?? raw.expires_at);
146+
147+
remainingMs = Number.isFinite(expiresAt)
148+
? Math.max(0, expiresAt - Date.now())
149+
: 0;
150+
}
151+
152+
return {
153+
...raw,
154+
loginUrl,
155+
region: raw.region || "iad",
156+
remainingMs,
157+
sessionId,
158+
};
159+
}
160+
161+
public async createSession(): Promise<BrowserBoxSession> {
162+
const controller = new AbortController();
163+
const timeoutId = window.setTimeout(
164+
() => controller.abort(),
165+
SESSION_REQUEST_TIMEOUT_MS
166+
);
167+
168+
try {
169+
const response = await fetch(`${this.baseUrl}/api/session`, {
170+
body: JSON.stringify({}),
171+
credentials: "include",
172+
headers: { "Content-Type": "application/json" },
173+
method: "POST",
174+
mode: "cors",
175+
signal: controller.signal,
176+
});
177+
const payload = (await response.json().catch(() => ({}))) as
178+
| BrowserBoxSessionSource
179+
| { error?: string };
180+
181+
if (!response.ok) {
182+
const errorMessage =
183+
payload &&
184+
typeof payload === "object" &&
185+
"error" in payload &&
186+
typeof payload.error === "string" &&
187+
payload.error.length > 0
188+
? payload.error
189+
: `Failed to create BrowserBox session (${response.status}).`;
190+
191+
throw new Error(errorMessage);
192+
}
193+
194+
return this.normalizeSession(payload as BrowserBoxSessionSource);
195+
} finally {
196+
window.clearTimeout(timeoutId);
197+
}
198+
}
199+
200+
public async checkSession(): Promise<
201+
{ active: false } | ({ active: true } & BrowserBoxSession)
202+
> {
203+
try {
204+
const response = await fetch(`${this.baseUrl}/api/session/status`, {
205+
credentials: "include",
206+
method: "GET",
207+
mode: "cors",
208+
});
209+
const payload = (await response.json().catch(() => ({}))) as
210+
| (BrowserBoxSessionSource & { active?: boolean })
211+
| { active?: boolean };
212+
213+
if (!response.ok || !payload?.active) {
214+
return { active: false };
215+
}
216+
217+
return {
218+
active: true,
219+
...this.normalizeSession(payload as BrowserBoxSessionSource),
220+
};
221+
} catch {
222+
return { active: false };
223+
}
224+
}
225+
226+
public async notifyDisconnect(
227+
sessionId: string,
228+
options: { mode?: "defer" | "hard" } = {}
229+
): Promise<void> {
230+
if (!sessionId) return;
231+
232+
const payload = JSON.stringify({
233+
mode: options.mode === "hard" ? "hard" : "defer",
234+
sessionId,
235+
});
236+
const url = `${this.baseUrl}/api/session/disconnect`;
237+
238+
if (typeof navigator.sendBeacon === "function") {
239+
try {
240+
const blob = new Blob([payload], { type: "application/json" });
241+
242+
navigator.sendBeacon(url, blob);
243+
return;
244+
} catch {
245+
// Fall through to fetch keepalive.
246+
}
247+
}
248+
249+
try {
250+
await fetch(url, {
251+
body: payload,
252+
credentials: "include",
253+
headers: { "Content-Type": "application/json" },
254+
keepalive: true,
255+
method: "POST",
256+
mode: "cors",
257+
});
258+
} catch (error) {
259+
console.error("BrowserBox disconnect notification failed.", error);
260+
}
261+
}
262+
}

0 commit comments

Comments
 (0)