From 38fa213d17aeb4cff19a59973bc461122b486a81 Mon Sep 17 00:00:00 2001 From: rijulshrestha Date: Mon, 14 Sep 2026 13:16:11 +0100 Subject: [PATCH 1/2] feat: add an embeddable /embed route with per-pane shell options --- docs/embedding.md | 40 +++ src/hooks.server.ts | 16 +- src/lib/components/Portal.svelte | 6 +- src/lib/components/ide/IdeShell.svelte | 349 ++++++++++++++----------- src/lib/embed/options.ts | 57 ++++ src/lib/ide/shell-options.ts | 24 ++ src/lib/utils/platform.ts | 13 + src/routes/+layout.svelte | 14 +- src/routes/embed/+page.svelte | 40 +++ static/_headers | 6 +- 10 files changed, 398 insertions(+), 167 deletions(-) create mode 100644 docs/embedding.md create mode 100644 src/lib/embed/options.ts create mode 100644 src/lib/ide/shell-options.ts create mode 100644 src/routes/embed/+page.svelte diff --git a/docs/embedding.md b/docs/embedding.md new file mode 100644 index 0000000..797907a --- /dev/null +++ b/docs/embedding.md @@ -0,0 +1,40 @@ +# Embedding BrowserCode + +BrowserCode runs entirely in the browser, so an embed is our page inside your iframe. + +```html + +``` + +## Your page must be cross-origin isolated + +This is the one requirement we cannot satisfy for you. BrowserPod needs `SharedArrayBuffer`, which browsers only expose on cross-origin isolated pages, and isolation is inherited from the top-level document. The page doing the embedding has to send both headers itself: + +``` +Cross-Origin-Opener-Policy: same-origin +Cross-Origin-Embedder-Policy: require-corp +``` + +and the iframe needs `allow="cross-origin-isolated"`. Without all three the embed loads and then reports that the headers are missing. + +Be aware that `require-corp` blocks cross-origin resources that do not opt in, which can break images, fonts, analytics and third-party iframes elsewhere on your page. Deploy `Cross-Origin-Embedder-Policy-Report-Only` first to see what would break, and consider `credentialless` instead, which clears most of it. If you cannot enable these headers at all, open BrowserCode in a new tab instead, which needs nothing from your page. + +## Options + +| Parameter | Value | +| ----------- | ----------------------------------------------------------------------------- | +| `repo` | Any GitHub URL or `owner/repo`, optionally `.../tree//` | +| `framework` | A template id (`vite`, `react`, `svelte`, `vue`, `nextjs`, `nuxt`, `express`) | +| `view` | Comma separated: `files`, `search`, `editor`, `terminal`, `preview` | + +`view` defaults to every pane. Pass `repo` or `framework`, not both; with neither, the default template boots. Controls that stop making sense are dropped automatically, so a preview-only embed has no hide button and no port badge. + +``` +/embed?repo=https://github.com/user/repo/tree/main/examples/demo +/embed?framework=vite&view=preview +/embed?framework=nextjs&view=files,editor,terminal +``` diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 8006769..3279dbb 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,13 +1,21 @@ import type { Handle } from '@sveltejs/kit'; +const AGENTS_CSP = + "frame-ancestors 'self' https://browserpod.io https://*.browserpod.io https://*.browserpod.pages.dev"; + +/** Dev mirror of `static/_headers`; the static build has no server, so keep the two in step. */ export const handle: Handle = async ({ event, resolve }) => { const response = await resolve(event); response.headers.set('Cross-Origin-Opener-Policy', 'same-origin'); response.headers.set('Cross-Origin-Embedder-Policy', 'require-corp'); response.headers.set('Cross-Origin-Resource-Policy', 'cross-origin'); - response.headers.set( - 'Content-Security-Policy', - "frame-ancestors 'self' https://browserpod.io https://*.browserpod.io https://*.browserpod.pages.dev" - ); + + // Clears the blanket frame-ancestors vite.config.ts sets, so only /agents stays unframable. + if (event.url.pathname.startsWith('/agents')) { + response.headers.set('Content-Security-Policy', AGENTS_CSP); + } else { + response.headers.delete('Content-Security-Policy'); + } + return response; }; diff --git a/src/lib/components/Portal.svelte b/src/lib/components/Portal.svelte index 334f14f..f8b14f5 100644 --- a/src/lib/components/Portal.svelte +++ b/src/lib/components/Portal.svelte @@ -10,9 +10,11 @@ onBeforeReload?: () => Promise; /** Collapses the pane; omitted by hosts with nowhere to collapse to. */ onCollapse?: () => void; + /** Which server the frame shows. A lone preview has nothing to choose between. */ + showPort?: boolean; }; - let { portal, onBeforeReload, onCollapse }: Props = $props(); + let { portal, onBeforeReload, onCollapse, showPort = true }: Props = $props(); /** Matches the sweep animation below. */ const SWEEP_MS = 620; @@ -112,7 +114,7 @@ {/if} - {#if portal.url} + {#if portal.url && showPort}
- -
- - + {#if hasRail} + + {/if} {#if isMobile && activePanel} @@ -349,25 +416,24 @@
- {#if !isMobile} + {#if !isMobile && shell.editor && shell.terminal}
- {#if !isMobile && isPreviewVisible} + {#if !isMobile && isPreviewVisible && collapsiblePreview} - {/if} - -
- {#if !isCompatibleBrowser} -
+ {#if previewCollapsed} +
- {:else} + {/if} + + {/if} + +
{#if portal.portals.length > 0} session.saveAll()} - onCollapse={isMobile ? undefined : togglePreview} + onCollapse={isMobile || !collapsiblePreview ? undefined : togglePreview} + showPort={hasLeftColumn} /> {/if} @@ -469,42 +520,28 @@ />
{/if} - {/if} +
- + {/if} - {#if isMobile} + {#if isMobile && mobileTabs.length > 1} {/if} diff --git a/src/lib/embed/options.ts b/src/lib/embed/options.ts new file mode 100644 index 0000000..7b653d1 --- /dev/null +++ b/src/lib/embed/options.ts @@ -0,0 +1,57 @@ +/** Parses the `/embed` query string, so a host page composes an embed entirely from the URL. */ +import { defaultFrameworkId, isFrameworkId, type FrameworkId } from '$lib/config/frameworks'; +import { parseGitHubUrl, type ParsedRepo } from '$lib/github/parse'; +import type { ShellOptions } from '$lib/ide/shell-options'; + +export type EmbedSource = + | { kind: 'framework'; id: FrameworkId } + | { kind: 'repo'; ref: ParsedRepo }; + +export type EmbedOptions = { source: EmbedSource; shell: ShellOptions }; + +const VIEWS = ['files', 'search', 'editor', 'terminal', 'preview'] as const; +type View = (typeof VIEWS)[number]; + +/** Narrows a raw `view=` entry, so unknown names fall out of the list rather than throwing. */ +function isView(value: string): value is View { + return (VIEWS as readonly string[]).includes(value); +} + +/** Null when the URL names a project we cannot boot, so the route can report the bad parameter. */ +export function parseEmbedOptions(url: URL): EmbedOptions | null { + const source = parseSource(url); + return source && { source, shell: parseShell(url) }; +} + +/** `repo` wins over `framework`; a bad value of either is an error, not a silent fallback. */ +function parseSource(url: URL): EmbedSource | null { + const repo = url.searchParams.get('repo'); + if (repo) { + const ref = parseGitHubUrl(repo); + return ref && { kind: 'repo', ref }; + } + const framework = url.searchParams.get('framework'); + if (framework === null) return { kind: 'framework', id: defaultFrameworkId }; + return isFrameworkId(framework) ? { kind: 'framework', id: framework } : null; +} + +/** Turns `view=` into the shell's panes, leaving the playground-only chrome off throughout. */ +function parseShell(url: URL): ShellOptions { + const requested = url.searchParams.get('view'); + const views = requested ? requested.split(',').map((view) => view.trim()) : [...VIEWS]; + const shown = new Set(views.filter(isView)); + // An unrecognised `view` would otherwise render an empty shell. + if (shown.size === 0) VIEWS.forEach((view) => shown.add(view)); + + return { + // Names the project and open file, which is chrome without an editor. + header: shown.has('editor'), + fileTree: shown.has('files'), + search: shown.has('search'), + editor: shown.has('editor'), + terminal: shown.has('terminal'), + preview: shown.has('preview'), + tools: false, + leaveGuard: false + }; +} diff --git a/src/lib/ide/shell-options.ts b/src/lib/ide/shell-options.ts new file mode 100644 index 0000000..c776fc2 --- /dev/null +++ b/src/lib/ide/shell-options.ts @@ -0,0 +1,24 @@ +/** Which parts of the IDE shell render. Embeds narrow this; the playground takes `FULL_SHELL`. */ +export type ShellOptions = { + header: boolean; + fileTree: boolean; + search: boolean; + editor: boolean; + terminal: boolean; + preview: boolean; + /** Settings, bug report and zen toggle. */ + tools: boolean; + /** The beforeunload prompt, which an embed would fire inside the host's page. */ + leaveGuard: boolean; +}; + +export const FULL_SHELL: ShellOptions = { + header: true, + fileTree: true, + search: true, + editor: true, + terminal: true, + preview: true, + tools: true, + leaveGuard: true +}; diff --git a/src/lib/utils/platform.ts b/src/lib/utils/platform.ts index 8c2a34c..9f576e3 100644 --- a/src/lib/utils/platform.ts +++ b/src/lib/utils/platform.ts @@ -9,3 +9,16 @@ export function isIos(): boolean { (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) ); } + +/** Why BrowserPod cannot run here, or null when it can. */ +export type PodBlocker = 'unsupported-browser' | 'not-isolated'; + +/** + * SharedArrayBuffer is only exposed on cross-origin isolated pages, and isolation is inherited + * from the top-level document, so an embed fails here when its host page omits the headers. + */ +export function podBlocker(): PodBlocker | null { + if (typeof Atomics?.waitAsync !== 'function') return 'unsupported-browser'; + if (!globalThis.crossOriginIsolated) return 'not-isolated'; + return null; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 8dd76a9..9550717 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -19,9 +19,13 @@ // flyouts) the rest of the time. let ribbonAboveTour = $derived(stepperState.open && stepperState.step === 6); + // Embeds render inside a host page, where none of the app chrome belongs. + let isEmbed = $derived($page.route.id?.startsWith('/embed') ?? false); + // Show on the landing surfaces (Home, /agents, bare /ide) and during tour step 6. let showRibbon = $derived( !zenState.on && + !isEmbed && (ribbonAboveTour || $page.route.id === '/' || $page.route.id === '/agents' || @@ -85,9 +89,11 @@
- - - {#if !zenState.on} + {#if !isEmbed} + + + {/if} + {#if !zenState.on && !isEmbed} {/if} @@ -121,7 +127,7 @@
- {#if !zenState.on} + {#if !zenState.on && !isEmbed} {/if} diff --git a/src/routes/embed/+page.svelte b/src/routes/embed/+page.svelte new file mode 100644 index 0000000..8d848fa --- /dev/null +++ b/src/routes/embed/+page.svelte @@ -0,0 +1,40 @@ + + +{#if options && session} + +{:else} +
+
+
+ +
+

Nothing to boot

+

+ Pass ?repo=<github url> or + ?framework=<id>, optionally with + &view=files,editor,terminal,preview. +

+
+
+{/if} diff --git a/static/_headers b/static/_headers index 90cb508..32b99a2 100644 --- a/static/_headers +++ b/static/_headers @@ -1,6 +1,10 @@ -# Enable Cross-Origin Isolation and allow embedding from browserpod.io (and CI previews) +# Cross-origin isolation for SharedArrayBuffer. Do not remove. /* Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp Cross-Origin-Resource-Policy: cross-origin + +# Takes an API key, so it stays unframable. Every other route carries no frame-ancestors, which +# is what lets a host page embed /embed. +/agents* Content-Security-Policy: frame-ancestors 'self' https://browserpod.io https://*.browserpod.io https://*.browserpod.pages.dev From cb0b011815eced36dc01f84ad3a2531d30f5608c Mon Sep 17 00:00:00 2001 From: rijulshrestha Date: Mon, 14 Sep 2026 13:56:45 +0100 Subject: [PATCH 2/2] feat: make CLI agents embeddable through /embed --- docs/embedding.md | 27 +++++++--- src/lib/agents/codex.ts | 14 ++++- src/lib/agents/session.svelte.ts | 7 ++- .../components/agents/AgentErrorCard.svelte | 16 +++--- .../components/agents/AgentLoadingCard.svelte | 16 +++--- src/lib/components/agents/AgentShell.svelte | 53 +++++++++++-------- .../agents/CredentialGateOverlay.svelte | 2 +- src/lib/embed/options.ts | 9 +++- src/routes/embed/+page.svelte | 36 +++++++++---- 9 files changed, 118 insertions(+), 62 deletions(-) diff --git a/docs/embedding.md b/docs/embedding.md index 797907a..09557fb 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -1,6 +1,6 @@ # Embedding BrowserCode -BrowserCode runs entirely in the browser, so an embed is our page inside your iframe. +BrowserCode runs entirely in the browser. An embed is our page inside your iframe. ```html ``` -## Your page must be cross-origin isolated +## Required headers -This is the one requirement we cannot satisfy for you. BrowserPod needs `SharedArrayBuffer`, which browsers only expose on cross-origin isolated pages, and isolation is inherited from the top-level document. The page doing the embedding has to send both headers itself: +BrowserPod needs `SharedArrayBuffer`, which browsers expose only on cross-origin isolated pages. Isolation is inherited from the top-level document, so the embedding page must send both headers itself: ``` Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp ``` -and the iframe needs `allow="cross-origin-isolated"`. Without all three the embed loads and then reports that the headers are missing. +The iframe must carry `allow="cross-origin-isolated"`. Without all three the embed reports that the headers are missing. -Be aware that `require-corp` blocks cross-origin resources that do not opt in, which can break images, fonts, analytics and third-party iframes elsewhere on your page. Deploy `Cross-Origin-Embedder-Policy-Report-Only` first to see what would break, and consider `credentialless` instead, which clears most of it. If you cannot enable these headers at all, open BrowserCode in a new tab instead, which needs nothing from your page. +`require-corp` blocks cross-origin resources on your page that do not send `Cross-Origin-Resource-Policy` or use CORS. -## Options +## Parameters | Parameter | Value | | ----------- | ----------------------------------------------------------------------------- | | `repo` | Any GitHub URL or `owner/repo`, optionally `.../tree//` | | `framework` | A template id (`vite`, `react`, `svelte`, `vue`, `nextjs`, `nuxt`, `express`) | +| `agent` | A CLI agent id (`claude`, `codex`) | | `view` | Comma separated: `files`, `search`, `editor`, `terminal`, `preview` | -`view` defaults to every pane. Pass `repo` or `framework`, not both; with neither, the default template boots. Controls that stop making sense are dropped automatically, so a preview-only embed has no hide button and no port badge. +## Behaviour + +- Pass one of `agent`, `repo` or `framework`. With none, the default template boots. +- `view` defaults to every pane. +- Agents are terminal first, so `view` only decides whether the preview pane comes with it. +- One agent session per browser. A second embed of the same agent, or the same agent open in another tab, shows the duplicate session dialog. +- `codex` asks for an OpenAI API key inside the frame. +- `claude` opens a new tab for OAuth sign-in. +- Controls without meaning are omitted: a preview-only embed has no hide button and no port badge. + +## Examples ``` /embed?repo=https://github.com/user/repo/tree/main/examples/demo /embed?framework=vite&view=preview /embed?framework=nextjs&view=files,editor,terminal +/embed?agent=claude +/embed?agent=codex&view=terminal ``` diff --git a/src/lib/agents/codex.ts b/src/lib/agents/codex.ts index 58118f8..1ad3c6c 100644 --- a/src/lib/agents/codex.ts +++ b/src/lib/agents/codex.ts @@ -22,12 +22,22 @@ code_mode = false code_mode_only = false `; +/** Embedded in a third-party frame the browser may partition storage away, or refuse it outright. */ export function getCodexApiKey(): string | null { - return localStorage.getItem(API_KEY_STORAGE); + try { + return localStorage.getItem(API_KEY_STORAGE); + } catch (error) { + console.warn('Could not read the stored API key:', error); + return null; + } } export function setCodexApiKey(key: string): void { - localStorage.setItem(API_KEY_STORAGE, key); + try { + localStorage.setItem(API_KEY_STORAGE, key); + } catch (error) { + console.warn('Could not persist the API key:', error); + } } /** Codex reads the key from its environment, so it is only injectable at launch. */ diff --git a/src/lib/agents/session.svelte.ts b/src/lib/agents/session.svelte.ts index 035ddd8..0ad49ac 100644 --- a/src/lib/agents/session.svelte.ts +++ b/src/lib/agents/session.svelte.ts @@ -33,7 +33,10 @@ export class AgentSession { private releaseLock: () => void = () => {}; private disposeLeaveGuard: () => void = () => {}; - constructor(requestedTool: string | undefined) { + private readonly leaveGuard: boolean; + + constructor(requestedTool: string | undefined, options: { leaveGuard?: boolean } = {}) { + this.leaveGuard = options.leaveGuard ?? true; this.id = resolveToolId(requestedTool); // resolveToolId only ever returns an id that is in toolItems, so this always resolves. this.tool = toolItems.find((item) => item.id === this.id)!; @@ -58,7 +61,7 @@ export class AgentSession { this.lock = 'held'; // Only warn on tab close/refresh/back-button once there is work here to lose. - this.disposeLeaveGuard = installLeaveGuard(); + if (this.leaveGuard) this.disposeLeaveGuard = installLeaveGuard(); // Covers pod boot, the image streaming in, and any warm-up probe. this.gate?.begin(); diff --git a/src/lib/components/agents/AgentErrorCard.svelte b/src/lib/components/agents/AgentErrorCard.svelte index de8d0a6..2e7e450 100644 --- a/src/lib/components/agents/AgentErrorCard.svelte +++ b/src/lib/components/agents/AgentErrorCard.svelte @@ -12,7 +12,7 @@ /** The boot failure, verbatim — vague "something went wrong" copy helps nobody debug a pod. */ message: string; onRetry: () => void; - onCancel: () => void; + onCancel?: () => void; } = $props(); @@ -45,12 +45,14 @@

- + {#if onCancel} + + {/if} + {#if onCancel} + + {/if}
diff --git a/src/lib/components/agents/AgentShell.svelte b/src/lib/components/agents/AgentShell.svelte index cab14bb..a196ab5 100644 --- a/src/lib/components/agents/AgentShell.svelte +++ b/src/lib/components/agents/AgentShell.svelte @@ -9,12 +9,15 @@ import { PortalState } from '$lib/stores/portals.svelte'; import { zenState } from '$lib/stores/zen.svelte'; import { startDrag } from '$lib/utils/drag'; + import { FULL_SHELL, type ShellOptions } from '$lib/ide/shell-options'; import { watchIsMobile } from '$lib/utils/viewport'; import CredentialGateOverlay from './CredentialGateOverlay.svelte'; import TerminalTip from './TerminalTip.svelte'; import ToolMenuSheet from './ToolMenuSheet.svelte'; - let { session }: { session: AgentSession } = $props(); + // `shell` narrows what renders; the agents page omits it and gets the lot. Agents read the + // preview and tools fields only, the terminal being the session itself. + let { session, shell = FULL_SHELL }: { session: AgentSession; shell?: ShellOptions } = $props(); /** The div the pod's terminal attaches to, rendered by Terminal.svelte. */ let consoleEl = $state(null); @@ -95,7 +98,7 @@ - {#if !isMobile} + {#if !isMobile && shell.tools} {/if} {/if} @@ -132,7 +135,7 @@ {/if} - {#if !isMobile && portal.portals.length > 0 && isPortalVisible} + {#if !isMobile && shell.preview && portal.portals.length > 0 && isPortalVisible} + {#if shell.tools} + + {/if} - {#if portal.portals.length > 0} + {#if shell.preview && portal.portals.length > 0} {/if} - + {#if shell.tools} + + {/if} {/if} diff --git a/src/lib/components/agents/CredentialGateOverlay.svelte b/src/lib/components/agents/CredentialGateOverlay.svelte index 9017d04..2ab5c8e 100644 --- a/src/lib/components/agents/CredentialGateOverlay.svelte +++ b/src/lib/components/agents/CredentialGateOverlay.svelte @@ -12,7 +12,7 @@ /** Relaunches the CLI, which is the only way a retry or a new secret takes effect. */ onRestart: () => void; /** Abandons the boot; every stage here is reached before the CLI has started. */ - onCancel: () => void; + onCancel?: () => void; }; let { gate, tool, credential, onRestart, onCancel }: Props = $props(); diff --git a/src/lib/embed/options.ts b/src/lib/embed/options.ts index 7b653d1..d2b2454 100644 --- a/src/lib/embed/options.ts +++ b/src/lib/embed/options.ts @@ -1,11 +1,13 @@ /** Parses the `/embed` query string, so a host page composes an embed entirely from the URL. */ import { defaultFrameworkId, isFrameworkId, type FrameworkId } from '$lib/config/frameworks'; +import { isEnabledTool, type ToolId } from '$lib/config/tools'; import { parseGitHubUrl, type ParsedRepo } from '$lib/github/parse'; import type { ShellOptions } from '$lib/ide/shell-options'; export type EmbedSource = | { kind: 'framework'; id: FrameworkId } - | { kind: 'repo'; ref: ParsedRepo }; + | { kind: 'repo'; ref: ParsedRepo } + | { kind: 'agent'; id: ToolId }; export type EmbedOptions = { source: EmbedSource; shell: ShellOptions }; @@ -23,8 +25,11 @@ export function parseEmbedOptions(url: URL): EmbedOptions | null { return source && { source, shell: parseShell(url) }; } -/** `repo` wins over `framework`; a bad value of either is an error, not a silent fallback. */ +/** `agent` then `repo` then `framework`; a bad value of any is an error, not a silent fallback. */ function parseSource(url: URL): EmbedSource | null { + const agent = url.searchParams.get('agent'); + if (agent !== null) return isEnabledTool(agent) ? { kind: 'agent', id: agent } : null; + const repo = url.searchParams.get('repo'); if (repo) { const ref = parseGitHubUrl(repo); diff --git a/src/routes/embed/+page.svelte b/src/routes/embed/+page.svelte index 8d848fa..c404496 100644 --- a/src/routes/embed/+page.svelte +++ b/src/routes/embed/+page.svelte @@ -2,7 +2,10 @@ import { page } from '$app/stores'; import Icon from '@iconify/svelte'; import IdeShell from '$lib/components/ide/IdeShell.svelte'; + import AgentShell from '$lib/components/agents/AgentShell.svelte'; + import DuplicateTabDialog from '$lib/components/agents/DuplicateTabDialog.svelte'; import { IdeSession } from '$lib/ide/session.svelte'; + import { AgentSession } from '$lib/agents/session.svelte'; import { templateSource } from '$lib/ide/template-source'; import { repoSource } from '$lib/ide/repo-source'; import { parseEmbedOptions } from '$lib/embed/options'; @@ -10,17 +13,27 @@ // The host page composes the embed through the query string; nothing here is navigable, so the // options are read once for this page's lifetime. const options = parseEmbedOptions($page.url); - const session = - options && - new IdeSession( - options.source.kind === 'framework' - ? templateSource(options.source.id) - : repoSource(options.source.ref) - ); + const source = options?.source; + + const agentSession = + source?.kind === 'agent' ? new AgentSession(source.id, { leaveGuard: false }) : null; + const ideSession = + source && source.kind !== 'agent' + ? new IdeSession( + source.kind === 'framework' ? templateSource(source.id) : repoSource(source.ref) + ) + : null; -{#if options && session} - +{#if options && agentSession} +
+ {#if agentSession.lock === 'taken'} + + {/if} + +
+{:else if options && ideSession} + {:else}
@@ -31,8 +44,9 @@

Nothing to boot

- Pass ?repo=<github url> or - ?framework=<id>, optionally with + Pass ?repo=<github url>, + ?framework=<id> or + ?agent=<id>, optionally with &view=files,editor,terminal,preview.