Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions docs/embedding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Embedding BrowserCode

BrowserCode runs entirely in the browser. An embed is our page inside your iframe.

```html
<iframe
src="https://browsercode.io/embed?repo=github.com/sveltejs/kit&view=preview"
allow="cross-origin-isolated"
style="width: 100%; height: 600px; border: 0"
></iframe>
```

## Required headers

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
```

The iframe must carry `allow="cross-origin-isolated"`. Without all three the embed reports that the headers are missing.

`require-corp` blocks cross-origin resources on your page that do not send `Cross-Origin-Resource-Policy` or use CORS.

## Parameters

| Parameter | Value |
| ----------- | ----------------------------------------------------------------------------- |
| `repo` | Any GitHub URL or `owner/repo`, optionally `.../tree/<ref>/<dir>` |
| `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` |

## 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
```
16 changes: 12 additions & 4 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
@@ -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;
};
14 changes: 12 additions & 2 deletions src/lib/agents/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
7 changes: 5 additions & 2 deletions src/lib/agents/session.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)!;
Expand All @@ -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();
Expand Down
6 changes: 4 additions & 2 deletions src/lib/components/Portal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@
onBeforeReload?: () => Promise<void>;
/** 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;
Expand Down Expand Up @@ -112,7 +114,7 @@
<span class="tool-sep"></span>
{/if}

{#if portal.url}
{#if portal.url && showPort}
<div class="relative">
<button
onclick={portal.togglePorts}
Expand Down
16 changes: 9 additions & 7 deletions src/lib/components/agents/AgentErrorCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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();
</script>

Expand Down Expand Up @@ -45,12 +45,14 @@
</p>

<div class="mt-5.5 flex items-center justify-end gap-3">
<button
onclick={onCancel}
class="rounded-md bg-white/5 px-4.5 py-2 text-[13px] font-medium text-zinc-300 transition hover:bg-white/10"
>
Back to agents
</button>
{#if onCancel}
<button
onclick={onCancel}
class="rounded-md bg-white/5 px-4.5 py-2 text-[13px] font-medium text-zinc-300 transition hover:bg-white/10"
>
Back to agents
</button>
{/if}
<button
onclick={onRetry}
class="rounded-[7px] bg-bc-azure/90 px-5 py-2 text-[13px] font-medium text-white transition hover:bg-bc-azure"
Expand Down
16 changes: 9 additions & 7 deletions src/lib/components/agents/AgentLoadingCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
credential: CredentialSpec;
/** Nothing stored yet, so the prompt comes right after this card. */
willAskForCredential: boolean;
onCancel: () => void;
onCancel?: () => void;
} = $props();

/** The bare host reads better as link text than the full URL. */
Expand Down Expand Up @@ -72,12 +72,14 @@

<div class="mt-5.5 flex items-center justify-between">
<span class="text-xs text-white/28 tabular-nums">{elapsed.toFixed(1)}s elapsed</span>
<button
onclick={onCancel}
class="rounded-md bg-white/5 px-4.5 py-2 text-[13px] font-medium text-zinc-300 transition hover:bg-white/10"
>
Cancel
</button>
{#if onCancel}
<button
onclick={onCancel}
class="rounded-md bg-white/5 px-4.5 py-2 text-[13px] font-medium text-zinc-300 transition hover:bg-white/10"
>
Cancel
</button>
{/if}
</div>
</div>

Expand Down
53 changes: 30 additions & 23 deletions src/lib/components/agents/AgentShell.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement | null>(null);
Expand Down Expand Up @@ -95,7 +98,7 @@

<!-- Zen toggle: the agents view has no icon rail, so this floating control is the
always-visible way in and out. Desktop only. -->
{#if !isMobile}
{#if !isMobile && shell.tools}
<ZenToggle
baseClass="absolute bottom-4 left-4 z-30 flex items-center justify-center rounded-lg border p-2 backdrop-blur-sm transition"
activeClass="border-bc-azure/40 bg-bc-azure/20 text-bc-azure"
Expand All @@ -122,7 +125,7 @@
tool={session.tool}
credential={session.credential}
onRestart={session.restart}
onCancel={session.leave}
onCancel={shell.tools ? session.leave : undefined}
/>
{/if}
{/if}
Expand All @@ -132,7 +135,7 @@
<TerminalTip onDismiss={dismissTerminalTip} />
{/if}

{#if !isMobile && portal.portals.length > 0 && isPortalVisible}
{#if !isMobile && shell.preview && portal.portals.length > 0 && isPortalVisible}
<button
class="group absolute top-0 bottom-0 z-20 w-1.25 cursor-col-resize"
style="right: calc({portalFraction * 100}% - 0.625rem);"
Expand All @@ -154,15 +157,15 @@
>
<Portal {portal} />
</div>
{:else if isMobile && portal.portals.length > 0 && activeMobileView === 'preview'}
{:else if isMobile && shell.preview && portal.portals.length > 0 && activeMobileView === 'preview'}
<div class="absolute inset-0 overflow-hidden">
<Portal {portal} />
</div>
{/if}
</div>

{#if isMobile}
{#if showToolMenu}
{#if showToolMenu && shell.tools}
<ToolMenuSheet
activeId={session.id}
onSelect={selectTool}
Expand All @@ -174,15 +177,17 @@
class="flex shrink-0 items-stretch border-t border-white/8 bg-[#0e0e0e]"
style="height: calc(52px + env(safe-area-inset-bottom)); padding-bottom: env(safe-area-inset-bottom);"
>
<button
onclick={() => (showToolMenu = !showToolMenu)}
class="flex flex-1 cursor-pointer flex-col items-center justify-center gap-1 border-none transition-colors {showToolMenu
? 'text-white'
: 'text-white/35 hover:text-white/60'}"
>
<Icon icon="mingcute:menu-line" width="20" height="20" />
<span class="text-[10px] font-medium tracking-wide">Tools</span>
</button>
{#if shell.tools}
<button
onclick={() => (showToolMenu = !showToolMenu)}
class="flex flex-1 cursor-pointer flex-col items-center justify-center gap-1 border-none transition-colors {showToolMenu
? 'text-white'
: 'text-white/35 hover:text-white/60'}"
>
<Icon icon="mingcute:menu-line" width="20" height="20" />
<span class="text-[10px] font-medium tracking-wide">Tools</span>
</button>
{/if}
<button
onclick={() => (activeMobileView = 'terminal')}
class="flex flex-1 cursor-pointer flex-col items-center justify-center gap-1 border-none transition-colors {activeMobileView ===
Expand All @@ -193,7 +198,7 @@
<Icon icon="mingcute:terminal-line" width="20" height="20" />
<span class="text-[10px] font-medium tracking-wide">Terminal</span>
</button>
{#if portal.portals.length > 0}
{#if shell.preview && portal.portals.length > 0}
<button
onclick={() => (activeMobileView = 'preview')}
class="flex flex-1 cursor-pointer flex-col items-center justify-center gap-1 border-none transition-colors {activeMobileView ===
Expand All @@ -205,12 +210,14 @@
<span class="text-[10px] font-medium tracking-wide">Preview</span>
</button>
{/if}
<button
onclick={openTour}
class="flex flex-1 cursor-pointer flex-col items-center justify-center gap-1 border-none text-white/35 transition-colors hover:text-white/60"
>
<Icon icon="mingcute:question-line" width="20" height="20" />
<span class="text-[10px] font-medium tracking-wide">Help</span>
</button>
{#if shell.tools}
<button
onclick={openTour}
class="flex flex-1 cursor-pointer flex-col items-center justify-center gap-1 border-none text-white/35 transition-colors hover:text-white/60"
>
<Icon icon="mingcute:question-line" width="20" height="20" />
<span class="text-[10px] font-medium tracking-wide">Help</span>
</button>
{/if}
</nav>
{/if}
2 changes: 1 addition & 1 deletion src/lib/components/agents/CredentialGateOverlay.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading