Skip to content

Commit 4bc8628

Browse files
committed
fix(browser-playground): await async operations and inline permission callbacks for worker serialization
1 parent d2e78d1 commit 4bc8628

File tree

22 files changed

+2073
-20
lines changed

22 files changed

+2073
-20
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,5 @@ scratch/
77
.ralph/
88
packages/sandboxed-node/.cache/
99
packages/secure-exec/src/generated/
10+
packages/playground/secure-exec-worker.js
11+
packages/playground/vendor/

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,7 @@ TODO:
1515
- **Driver-based**: Provide a driver to map filesystem, network, and child_process.
1616
- **Permissions**: Gate syscalls with custom allow/deny functions.
1717
- **Opt-in system features**: Disable network/child_process/FS by omission.
18+
19+
## Examples
20+
21+
- Browser playground: `pnpm -C packages/playground dev`
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
## Why
2+
3+
The repository exposes a browser runtime, but there is no straightforward browser-facing example that lets contributors interactively try it. We also now have Python runtime support, and the fastest way to make both surfaces legible is a small in-browser playground that shows code entry, execution, and streamed output in one place.
4+
5+
## What Changes
6+
7+
- Add a simple browser playground example under `examples/` with a Monaco editor, a language switcher, and an output panel.
8+
- Run TypeScript through the browser `NodeRuntime` path so the example demonstrates the existing browser runtime directly.
9+
- Run Python through Pyodide in the browser so the example can expose both languages in one page while keeping the implementation lightweight.
10+
- Reuse the sandbox-agent inspector dark theme tokens and interaction styling so the example matches the existing visual language.
11+
- Add lightweight local tooling to build the browser runtime worker bundle used by the example and serve the repo root for local testing.
12+
13+
## Capabilities
14+
15+
### New Capabilities
16+
17+
- `browser-examples`: Interactive browser example requirements for repository-supported playgrounds.
18+
19+
## Impact
20+
21+
- Affected code: new example files under `packages/playground/`.
22+
- Affected docs: example-specific README and a short root README reference.
23+
- Affected validation: targeted worker bundle build and a browser smoke test for TypeScript and Python execution.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Repository Browser Playground Example
4+
The repository SHALL provide a simple browser playground example that lets contributors edit code, execute it in-browser, and inspect output without additional application scaffolding.
5+
6+
#### Scenario: Playground exposes an editor, run controls, and output
7+
- **WHEN** a contributor opens the browser playground example
8+
- **THEN** the page MUST provide a code editor, a language selector, a run action, and a visible output surface
9+
10+
#### Scenario: Playground uses the repository dark theme
11+
- **WHEN** the browser playground example renders
12+
- **THEN** it MUST use the sandbox-agent inspector dark theme tokens and overall visual treatment rather than a default browser theme
13+
14+
### Requirement: Playground Supports TypeScript And Python
15+
The browser playground example SHALL support both TypeScript and Python execution paths in one interface.
16+
17+
#### Scenario: TypeScript executes through secure-exec browser runtime
18+
- **WHEN** a contributor runs TypeScript in the playground
19+
- **THEN** the example MUST execute the code through the repository's browser runtime support and show streamed output plus final execution status
20+
21+
#### Scenario: Python executes in-browser
22+
- **WHEN** a contributor runs Python in the playground
23+
- **THEN** the example MUST execute the code in-browser and show stdout, stderr, and final execution status in the same output surface
24+
25+
### Requirement: Playground Remains Runnable From The Repository
26+
The browser playground example SHALL include repository-local instructions and helper tooling so contributors can run it without creating a separate app.
27+
28+
#### Scenario: Contributor starts the example locally
29+
- **WHEN** a contributor follows the example README
30+
- **THEN** the repository MUST provide a documented local command that builds any required worker asset and serves the example from the repo
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
## 1. OpenSpec
2+
3+
- [x] 1.1 Add a new `browser-examples` capability delta defining the browser playground requirements.
4+
5+
## 2. Example App
6+
7+
- [x] 2.1 Add `packages/playground/` with a browser page, Monaco editor wiring, language switcher, and output panel using the inspector dark theme.
8+
- [x] 2.2 Run TypeScript code through `secure-exec` browser runtime execution and surface streamed stdout/stderr plus exit state.
9+
- [x] 2.3 Run Python code through an in-browser Pyodide runner and surface stdout/stderr plus exit state.
10+
- [x] 2.4 Add local helper scripts to bundle the browser runtime worker and serve the repo root for the example.
11+
12+
## 3. Documentation And Validation
13+
14+
- [x] 3.1 Add example usage instructions and a discoverability note in repository docs.
15+
- [x] 3.2 Run targeted validation for the example worker build and a browser smoke check covering both TypeScript and Python execution.

packages/playground/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Browser Playground Example
2+
3+
This example provides a small in-browser playground with:
4+
5+
- Monaco for editing code
6+
- `secure-exec` browser runtime for TypeScript execution
7+
- Pyodide for Python execution
8+
- the sandbox-agent inspector dark theme
9+
10+
Run it from the repo:
11+
12+
```bash
13+
pnpm -C packages/playground dev
14+
```
15+
16+
Then open:
17+
18+
```text
19+
http://localhost:4173/
20+
```
21+
22+
Notes:
23+
24+
- `pnpm run setup-vendor` symlinks Monaco, TypeScript, and Pyodide from `node_modules` into `vendor/` (runs automatically before `dev` and `build`).
25+
- The dev server sets COOP/COEP headers required for SharedArrayBuffer and serves all assets from the local filesystem.
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/**
2+
* Static dev server for the browser playground.
3+
*
4+
* SharedArrayBuffer (required by the secure-exec web worker) needs COOP/COEP
5+
* headers. Once COEP is "require-corp", every subresource must be same-origin
6+
* or carry Cross-Origin-Resource-Policy. Vendor assets (Monaco, Pyodide,
7+
* TypeScript) are installed as npm packages and symlinked into vendor/ by
8+
* `scripts/setup-vendor.ts`, so everything is served from the local filesystem.
9+
*/
10+
import { createReadStream } from "node:fs";
11+
import { realpath, stat } from "node:fs/promises";
12+
import {
13+
createServer,
14+
type OutgoingHttpHeaders,
15+
type Server,
16+
type ServerResponse,
17+
} from "node:http";
18+
import { extname, join, normalize, resolve } from "node:path";
19+
import { fileURLToPath } from "node:url";
20+
21+
const DEFAULT_PORT = Number(process.env.PORT ?? "4173");
22+
const playgroundDir = resolve(fileURLToPath(new URL("..", import.meta.url)));
23+
const secureExecDir = resolve(playgroundDir, "../secure-exec");
24+
25+
/* Map URL prefixes to filesystem directories outside playgroundDir */
26+
const PATH_ALIASES: Array<{ prefix: string; dir: string }> = [
27+
{ prefix: "/secure-exec/", dir: secureExecDir },
28+
];
29+
30+
const mimeTypes = new Map<string, string>([
31+
[".css", "text/css; charset=utf-8"],
32+
[".data", "application/octet-stream"],
33+
[".html", "text/html; charset=utf-8"],
34+
[".js", "text/javascript; charset=utf-8"],
35+
[".json", "application/json; charset=utf-8"],
36+
[".mjs", "text/javascript; charset=utf-8"],
37+
[".svg", "image/svg+xml"],
38+
[".wasm", "application/wasm"],
39+
[".zip", "application/zip"],
40+
]);
41+
42+
function getFilePath(urlPath: string): string | null {
43+
const pathname = decodeURIComponent(urlPath.split("?")[0] ?? "/");
44+
const relativePath = pathname === "/" ? "/frontend/index.html" : pathname;
45+
46+
/* Check path aliases for sibling packages (e.g. secure-exec dist) */
47+
for (const alias of PATH_ALIASES) {
48+
if (relativePath.startsWith(alias.prefix)) {
49+
const rest = relativePath.slice(alias.prefix.length);
50+
const safePath = normalize(rest).replace(/^(\.\.[/\\])+/, "");
51+
const absolutePath = resolve(alias.dir, safePath);
52+
if (!absolutePath.startsWith(alias.dir)) return null;
53+
return absolutePath;
54+
}
55+
}
56+
57+
const safePath = normalize(relativePath).replace(/^(\.\.[/\\])+/, "");
58+
const absolutePath = resolve(playgroundDir, `.${safePath}`);
59+
if (!absolutePath.startsWith(playgroundDir)) {
60+
return null;
61+
}
62+
return absolutePath;
63+
}
64+
65+
function getRedirectLocation(urlPath: string): string | null {
66+
const [pathname, search = ""] = urlPath.split("?");
67+
if (pathname === "/" || pathname.endsWith("/")) {
68+
return null;
69+
}
70+
return `${pathname}/${search ? `?${search}` : ""}`;
71+
}
72+
73+
const COEP_HEADERS = {
74+
"Cross-Origin-Embedder-Policy": "require-corp",
75+
"Cross-Origin-Opener-Policy": "same-origin",
76+
} as const;
77+
78+
function writeHeaders(response: ServerResponse, status: number, extras: OutgoingHttpHeaders = {}): void {
79+
response.writeHead(status, {
80+
"Cache-Control": "no-store",
81+
...COEP_HEADERS,
82+
...extras,
83+
});
84+
}
85+
86+
export function createBrowserPlaygroundServer(): Server {
87+
return createServer(async (_request, response) => {
88+
const requestUrl = _request.url ?? "/";
89+
90+
const filePath = getFilePath(requestUrl);
91+
if (!filePath) {
92+
writeHeaders(response, 403);
93+
response.end("Forbidden");
94+
return;
95+
}
96+
97+
/* Resolve symlinks (vendor/ entries point into node_modules) */
98+
let resolvedPath: string;
99+
try {
100+
resolvedPath = await realpath(filePath);
101+
} catch {
102+
writeHeaders(response, 404);
103+
response.end("Not found");
104+
return;
105+
}
106+
107+
let finalPath = resolvedPath;
108+
try {
109+
const fileStat = await stat(resolvedPath);
110+
if (fileStat.isDirectory()) {
111+
const redirectLocation = getRedirectLocation(requestUrl);
112+
if (redirectLocation) {
113+
writeHeaders(response, 308, { Location: redirectLocation });
114+
response.end();
115+
return;
116+
}
117+
finalPath = join(resolvedPath, "index.html");
118+
}
119+
} catch {
120+
writeHeaders(response, 404);
121+
response.end("Not found");
122+
return;
123+
}
124+
125+
try {
126+
const fileStat = await stat(finalPath);
127+
if (!fileStat.isFile()) {
128+
writeHeaders(response, 404);
129+
response.end("Not found");
130+
return;
131+
}
132+
133+
const mimeType = mimeTypes.get(extname(finalPath)) ?? "application/octet-stream";
134+
writeHeaders(response, 200, {
135+
"Content-Length": String(fileStat.size),
136+
"Content-Type": mimeType,
137+
});
138+
createReadStream(finalPath).pipe(response);
139+
} catch {
140+
writeHeaders(response, 500);
141+
response.end("Failed to read file");
142+
}
143+
});
144+
}
145+
146+
export function startBrowserPlaygroundServer(port = DEFAULT_PORT): Server {
147+
const server = createBrowserPlaygroundServer();
148+
server.listen(port, () => {
149+
console.log(`Browser playground: http://localhost:${port}/`);
150+
});
151+
return server;
152+
}
153+
154+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
155+
startBrowserPlaygroundServer();
156+
}

0 commit comments

Comments
 (0)