Skip to content
Closed
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
1 change: 1 addition & 0 deletions examples/repl-playground/.dev.vars.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
PLAYGROUND_TOKEN=change-me
5 changes: 5 additions & 0 deletions examples/repl-playground/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules/
.wrangler/
.dev.vars
.playground-token
.access-creds
84 changes: 84 additions & 0 deletions examples/repl-playground/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# repl-playground example

> [!IMPORTANT]
> **PREVIEW ONLY** This package is provided as a preview for feedback only.
> APIs are unstable and the design is subject to change.

A browser and HTTP playground for the durable JavaScript REPL
(`workspace.repl()` and `createJsToolDefinition()`).

It works like a notebook for agents. Variables and functions persist
between evals, survive restarts, and every capability call is recorded
once, then replayed from the log. The page lets you run cells and inspect
committed history, recorded effects, and live call counters, so you can
see replay make zero live calls.

## What's in the box

A Durable Object per workspace (`ws` param), each hosting a real
`Workspace` with these grants. They are fake, deterministic fixtures
seeded with a planted incident to investigate:

| grant | what it is |
| --------- | --------------------------------------------------------- |
| `crm` | customers and tickets (list/get/update/comment/stats) |
| `billing` | invoices, refunds, revenue summary |
| `metrics` | time-series query (`api-eu-west.p99_ms` spikes…) |
| `logs` | service log search |
| `team` | roster data and `oncall(area)` |
| `notify` | notification outbox (nothing is really sent) |
| `fs` | the workspace filesystem |
| `fetch` | real fetch, allowlisted to a few public hosts |

Run `help()` in a cell for the full docs, or open a grant in the sidebar.

## Run it

```sh
cp .dev.vars.example .dev.vars # set PLAYGROUND_TOKEN
npm run dev
```

Open http://localhost:8787 or use the CLI:

```sh
PLAYGROUND_TOKEN=change-me npm run play -- eval 'const open = await crm.tickets.list({ status: "open" }); open.length'
PLAYGROUND_TOKEN=change-me npm run play -- restart
PLAYGROUND_TOKEN=change-me npm run play -- eval 'open.length' # still there, zero live calls
PLAYGROUND_TOKEN=change-me npm run play -- counts
```

## Deploy

This playground runs arbitrary code against its grants, so don't
deploy it unprotected.

```sh
wrangler secret put PLAYGROUND_TOKEN
npm run deploy
```

For browser access, put the hostname behind
[Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/applications/configure-apps/self-hosted-public-app/)
and set `ACCESS_TEAM` (`https://<team>.cloudflareaccess.com`) and
`ACCESS_AUDS` (your Access application's AUD tag) in `wrangler.jsonc`.
The worker then accepts a verified Access JWT in place of the bearer
token. For the CLI, set `PLAYGROUND_URL`, plus `CF_ACCESS_CLIENT_ID` and
`CF_ACCESS_CLIENT_SECRET` if you're using an Access service token.

## Endpoints

All endpoints except `/health` need auth.

| endpoint | purpose |
| --------------------------------------- | ---------------------------------------------- |
| `GET /` | browser UI |
| `GET /tool?ws=` | generated `js` tool name, description, schema |
| `POST /eval` `{ code, sessionName?, ws? }` | run one cell through the `js` tool |
| `GET /sessions?ws=` | sessions and cell counts |
| `GET /history?ws=&session=&effects=1` | committed cells and recorded effects |
| `GET /counts?ws=` | live capability-call counters and real egress |
| `GET /outbox?ws=` | notifications "sent" by `notify` |
| `GET /grants` | grant declarations shown in the sidebar |
| `POST /restart` `{ ws? }` | drop in-memory state (storage survives) |
| `POST /reset` `{ ws? }` | wipe the workspace |
21 changes: 21 additions & 0 deletions examples/repl-playground/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "@example/computer-repl-playground",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Browser + HTTP playground for the durable JavaScript REPL: persistent sessions, recorded capability grants, and zero-call replay.",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"typecheck": "tsc --noEmit",
"play": "node scripts/play.mjs"
},
"dependencies": {
"@cloudflare/computer": "*"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260616.1",
"typescript": "^6.0.3",
"wrangler": "^4.107.1"
}
}
98 changes: 98 additions & 0 deletions examples/repl-playground/scripts/play.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
#!/usr/bin/env node
// Tiny CLI for the repl-playground worker.
//
// node scripts/play.mjs eval 'code...' [--session NAME] [--ws NAME] [--full]
// node scripts/play.mjs eval - (read code from stdin)
// node scripts/play.mjs tool|sessions|counts|outbox|restart|reset [--ws NAME]
// node scripts/play.mjs history [--session NAME] [--effects] [--ws NAME]
//
// Configuration (env vars, or files in the example root):
// PLAYGROUND_URL base URL (default http://localhost:8787 for `wrangler dev`)
// PLAYGROUND_TOKEN bearer token, or a .playground-token file
// CF_ACCESS_CLIENT_ID / CF_ACCESS_CLIENT_SECRET
// optional Access service token, or a .access-creds file

import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const BASE = (process.env.PLAYGROUND_URL ?? "http://localhost:8787").replace(/\/$/, "");

const readOptional = (name) =>
existsSync(join(root, name)) ? readFileSync(join(root, name), "utf8").trim() : undefined;
const token = process.env.PLAYGROUND_TOKEN ?? readOptional(".playground-token");
const creds = {
...Object.fromEntries(
(readOptional(".access-creds") ?? "")
.split("\n")
.filter((line) => line.includes("="))
.map((line) => [line.slice(0, line.indexOf("=")), line.slice(line.indexOf("=") + 1).trim()]),
),
...(process.env.CF_ACCESS_CLIENT_ID ? { CF_ACCESS_CLIENT_ID: process.env.CF_ACCESS_CLIENT_ID } : {}),
...(process.env.CF_ACCESS_CLIENT_SECRET ? { CF_ACCESS_CLIENT_SECRET: process.env.CF_ACCESS_CLIENT_SECRET } : {}),
};
if (token === undefined) {
console.error("Set PLAYGROUND_TOKEN (or create .playground-token) to match the worker's token.");
process.exit(1);
}

const headers = {
authorization: `Bearer ${token}`,
...(creds.CF_ACCESS_CLIENT_ID
? { "CF-Access-Client-Id": creds.CF_ACCESS_CLIENT_ID, "CF-Access-Client-Secret": creds.CF_ACCESS_CLIENT_SECRET }
: {}),
"content-type": "application/json",
};

const args = process.argv.slice(2);
const command = args[0];
const flag = (name) => {
const i = args.indexOf(`--${name}`);
return i === -1 ? undefined : args[i + 1];
};
const has = (name) => args.includes(`--${name}`);
const ws = flag("ws") ?? "default";

async function call(path, init) {
const res = await fetch(`${BASE}${path}`, { headers, ...init });
const body = await res.json();
if (!res.ok) {
console.error(JSON.stringify(body, null, 2));
process.exit(1);
}
return body;
}

if (command === "eval") {
let code = args[1];
if (code === "-" || code === undefined) code = readFileSync(0, "utf8");
const sessionName = flag("session");
const out = await call("/eval", {
method: "POST",
body: JSON.stringify({ ws, code, ...(sessionName === undefined ? {} : { sessionName }) }),
});
if (has("full")) {
console.log(JSON.stringify(out, null, 2));
} else {
console.log(
`[${out.ok ? "ok" : "ERR"}] cell ${out.executionCount} · ${out.ms}ms · session ${out.session}`,
);
console.log(out.rendered);
}
} else if (command === "tool") {
const out = await call(`/tool?ws=${ws}`);
console.log(out.description);
console.log("\nschema:", JSON.stringify(out.inputSchema));
} else if (command === "history") {
const session = flag("session") ?? "main";
const effects = has("effects") ? "&effects=1" : "";
console.log(JSON.stringify(await call(`/history?ws=${ws}&session=${session}${effects}`), null, 2));
} else if (["sessions", "counts", "outbox"].includes(command)) {
console.log(JSON.stringify(await call(`/${command}?ws=${ws}`), null, 2));
} else if (command === "restart" || command === "reset") {
console.log(JSON.stringify(await call(`/${command}`, { method: "POST", body: JSON.stringify({ ws }) })));
} else {
console.error("usage: play.mjs eval|tool|sessions|history|counts|outbox|restart|reset ...");
process.exit(1);
}
Loading
Loading