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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
- [What is BrowserCode?](#about)
- [Quickstart](#quickstart)
- [Make it your own](#make-it-your-own)
- [Embedding](#embedding)
- [Breaking BrowserCode](#breaking-browsercode)
- [Roadmap](#roadmap)

Expand Down Expand Up @@ -88,6 +89,22 @@ Other useful scripts:

CLI availability and behavior are configured in [`src/lib/config/tools.ts`](src/lib/config/tools.ts) — this is the place to start if you want to add or tweak a CLI.

<h2 id="embedding">Embedding</h2>

BrowserCode can run inside another site's page through `/embed`, composed entirely from the query string.

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

BrowserPod needs `SharedArrayBuffer`, so the embedding page must send `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp`, and the iframe must carry `allow="cross-origin-isolated"`.

Parameters, panes and further detail are in [`docs/embedding.md`](docs/embedding.md).

<h2 id="breaking-browsercode">Breaking BrowserCode</h2>

This is BrowserCode beta. Don't be kind to it. Stretch it, bend it, find out what breaks. Here are a few walls you might hit:
Expand Down
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
43 changes: 43 additions & 0 deletions src/lib/components/PodBlockerOverlay.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { PodBlocker } from '$lib/utils/platform';

let { blocker }: { blocker: PodBlocker } = $props();

const DOCS_URL = 'https://github.com/leaningtech/browsercode/blob/main/docs/embedding.md';
</script>

<div
class="absolute inset-0 z-50 flex items-center justify-center bg-bc-abyss/80 p-4 backdrop-blur-md"
>
<div
class="glass-panel w-full max-w-85 rounded-xl border border-bc-mist/15 px-6 py-8 text-center"
>
<div
class="mx-auto mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-bc-coral/10 text-bc-coral"
>
<Icon icon="mingcute:alert-line" width="22" height="22" />
</div>
{#if blocker === 'not-isolated'}
<h3 class="mb-2 text-sm font-semibold text-zinc-50">Isolation headers missing</h3>
<p class="text-[12px] leading-relaxed break-words text-zinc-400">
The page embedding this frame must send
<code class="text-zinc-200">Cross-Origin-Opener-Policy: same-origin</code>
and
<code class="text-zinc-200">Cross-Origin-Embedder-Policy: require-corp</code>, and set
<code class="text-zinc-200">allow="cross-origin-isolated"</code> on the iframe.
</p>
<a
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
class="mt-3 inline-block text-[12px] text-bc-mist hover:text-bc-azure">Learn more</a
>
{:else}
<h3 class="mb-2 text-sm font-semibold text-zinc-50">Incompatible Browser</h3>
<p class="text-[12px] leading-relaxed text-zinc-400">
Requires <strong class="text-zinc-200">Atomics.waitAsync</strong> (Chrome, Edge, Safari 16.4+).
</p>
{/if}
</div>
</div>
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
Loading