Revamp cljs expression evaluation to full-blown REPL

This commit is contained in:
Dominik Jain 2026-04-28 15:59:07 +02:00
parent 66d518f15d
commit f1affdbadc
5 changed files with 143 additions and 99 deletions

View File

@ -1,43 +1,6 @@
# ClojureScript REPL Access via shadow-cljs
## Overview
The penpot frontend uses shadow-cljs with `:target :esm` and multi-module code splitting. The CLJS REPL evaluates code in the browser runtime via a websocket connection.
## Method 1: Interactive REPL (inside container)
```bash
docker exec -it penpot-devenv-main bash
cd /home/penpot/penpot/frontend
npx shadow-cljs cljs-repl main
```
Requires an active browser session with penpot open. Type `:cljs/quit` to exit.
## Method 2: Scriptable eval via clj-eval (preferred for automation)
```bash
docker exec penpot-devenv-main bash -c "cd /home/penpot/penpot/frontend && \
printf '<CLJ_EXPRESSION>\n' | timeout 10 npx shadow-cljs clj-eval --stdin 2>&1"
```
For CLJS evaluation, wrap in `shadow.cljs.devtools.api/cljs-eval`:
```bash
docker exec penpot-devenv-main bash -c "cd /home/penpot/penpot/frontend && \
printf '(shadow.cljs.devtools.api/cljs-eval :main \"<CLJS_CODE>\" {})\n' | \
timeout 10 npx shadow-cljs clj-eval --stdin 2>&1"
```
Return format: `{:results ["<result1>" ...] :out "" :err "" :ns cljs.user}`
You can target a specific runtime by client-id:
```
(shadow.cljs.devtools.api/cljs-eval :main "<code>" {:client-id 5})
```
To list connected runtimes and their client-ids:
```
(shadow.cljs.devtools.api/repl-runtimes :main)
```
## Method 3: nREPL client (tools/nrepl_eval.py)
A custom Python nREPL client exists at `tools/nrepl_eval.py`. However, it uses `(shadow/repl :main)` to switch to CLJS mode, which doesn't reliably select the correct runtime. **Prefer Method 2 for automation.**
Execute code in the REPL via the Penpot MCP's `cljs_repl` tool.
## Accessing App State
@ -47,7 +10,7 @@ However, **page objects are NOT in the main store atom**. They live behind deriv
### Top-level store keys (subset)
`:current-page-id`, `:current-file-id`, `:workspace-local`, `:workspace-global`,
`:workspace-trimmed-page`, `:workspace-undo`, `:workspace-guides`, `:workspace-layout`,
`:workspace-drawing`, `:workspace-presence`, `:workspace-ready`, `:profile`, `:route`, etc.
`:workspace-presence`, `:workspace-ready`, `:profile`, `:route`, etc.
**Notable absence:** There is no `:workspace-data` key in the store. The old path
`(get-in state [:workspace-data :pages-index page-id :objects])` does NOT work.
@ -87,14 +50,17 @@ The shape `:type` is a keyword like `:rect`, `:path`, `:text`, `:ellipse`, `:ima
Note `:rect` in CLJS corresponds to "rectangle" in the JS Plugin API, and `:frame` corresponds to "board".
## Notes
- nREPL server runs on port 3447 inside the container, mapped to host
- The `:main` build has multiple modules: shared, main, main-workspace, rasterizer, etc.
- `app.main.store/state` is a potok store (wrapping an okulary atom) created via `defonce`
- Ignore the "WARNING: shadow-cljs not installed in project" message — it works via the running server
- Use `timeout` to avoid hanging if the browser is disconnected
- `DO NOT` call `shadow.cljs.devtools.api/repl-runtime-select` with a runtime that can't eval — it will jam the REPL until restart
## Troubleshooting
The REPL may occasionally not connect to the right runtime.
Run `(.-title js/document)` to verify — it should show your file name (e.g. "New File 1 - Penpot"), not "Penpot - Rasterizer".
`cljs_repl` may not connect to the right runtime when several are attached (e.g. workspace tab + rasterizer). Verify with `(.-title js/document)` — it should show your file name, not "Penpot - Rasterizer".
To list runtimes or target one by client-id, use `npx shadow-cljs clj-eval` from `/home/penpot/penpot/frontend`. It talks to the shadow-cljs JVM process, so unlike `cljs_repl` it has access to `shadow.cljs.devtools.api`:
```bash
printf '(shadow.cljs.devtools.api/repl-runtimes :main)\n' | timeout 10 npx shadow-cljs clj-eval --stdin
printf '(shadow.cljs.devtools.api/cljs-eval :main "<cljs-code>" {:client-id 5})\n' | timeout 10 npx shadow-cljs clj-eval --stdin
```

View File

@ -1,4 +1,5 @@
import nreplClient from "nrepl-client";
import type { NreplConnection, NreplMessage } from "nrepl-client";
import { createLogger } from "./logger";
/**
@ -18,8 +19,9 @@ export interface NreplEvalResult {
/**
* A client for communicating with a shadow-cljs nREPL server.
*
* This client wraps the nrepl-client library, providing a typed, promise-based
* interface for evaluating Clojure and ClojureScript expressions.
* This client maintains a persistent nREPL session, so that definitions,
* requires, and other state are preserved across evaluations providing
* a full REPL experience.
*/
export class NreplClient {
private static readonly NREPL_PORT = 3447;
@ -28,30 +30,39 @@ export class NreplClient {
private readonly logger = createLogger("NreplClient");
/** the persistent connection to the nREPL server, established lazily */
private connection: NreplConnection | null = null;
/** the cloned session ID that persists state across evaluations */
private sessionId: string | null = null;
/**
* Evaluates a Clojure expression on the nREPL server.
*
* A new connection is established for each evaluation and closed afterwards.
* Evaluates a Clojure expression on the nREPL server within the persistent session.
*
* @param code - the Clojure expression to evaluate
* @returns the evaluation result
*/
async eval(code: string): Promise<NreplEvalResult> {
this.logger.debug("Evaluating Clojure expression: %s", code);
return this.withConnection((conn) => {
return new Promise<NreplEvalResult>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`nREPL evaluation timed out after ${NreplClient.EVAL_TIMEOUT_MS}ms`));
}, NreplClient.EVAL_TIMEOUT_MS);
const conn = await this.ensureConnection();
const sessionId = await this.ensureSession(conn);
conn.eval(code, (err: Error | null, result: any[]) => {
clearTimeout(timeout);
if (err) {
reject(err);
return;
}
return new Promise<NreplEvalResult>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`nREPL evaluation timed out after ${NreplClient.EVAL_TIMEOUT_MS}ms`));
}, NreplClient.EVAL_TIMEOUT_MS);
conn.send({ op: "eval", code, session: sessionId }, (err: Error | null, result: NreplMessage[]) => {
clearTimeout(timeout);
if (err) {
reject(err);
return;
}
try {
resolve(this.parseEvalResult(result));
});
} catch (parseErr) {
reject(parseErr);
}
});
});
}
@ -74,28 +85,59 @@ export class NreplClient {
}
/**
* Opens a connection, executes the given operation, and ensures the connection is closed afterwards.
* Closes the persistent connection and session, releasing all resources.
*/
private async withConnection<T>(operation: (conn: any) => Promise<T>): Promise<T> {
const conn = nreplClient.connect({
port: NreplClient.NREPL_PORT,
host: NreplClient.NREPL_HOST,
});
async close(): Promise<void> {
if (this.connection) {
this.logger.info("Closing nREPL connection");
this.connection.end();
this.connection = null;
this.sessionId = null;
}
}
return new Promise<T>((resolve, reject) => {
conn.once("connect", async () => {
try {
const result = await operation(conn);
resolve(result);
} catch (err) {
reject(err);
} finally {
conn.end();
}
/**
* Ensures a connection to the nREPL server is established, creating one if necessary.
*
* If the existing connection has been closed or errored, a new one is created.
*/
private async ensureConnection(): Promise<NreplConnection> {
if (this.connection && !this.connection.destroyed) {
return this.connection;
}
// reset state since the old connection is gone
this.connection = null;
this.sessionId = null;
this.logger.info("Connecting to nREPL server at %s:%d", NreplClient.NREPL_HOST, NreplClient.NREPL_PORT);
return new Promise<NreplConnection>((resolve, reject) => {
const conn = nreplClient.connect({
port: NreplClient.NREPL_PORT,
host: NreplClient.NREPL_HOST,
});
conn.once("connect", () => {
this.connection = conn;
// handle unexpected disconnects so the next eval reconnects
conn.once("close", () => {
this.logger.warn("nREPL connection closed unexpectedly");
this.connection = null;
this.sessionId = null;
});
conn.once("error", (err: Error) => {
this.logger.error("nREPL connection error: %s", err);
this.connection = null;
this.sessionId = null;
});
resolve(conn);
});
conn.once("error", (err: Error) => {
this.logger.error("nREPL connection error: %s", err);
reject(
new Error(
`Failed to connect to nREPL server at ${NreplClient.NREPL_HOST}:${NreplClient.NREPL_PORT}: ${err.message}`
@ -105,10 +147,43 @@ export class NreplClient {
});
}
/**
* Ensures a persistent nREPL session exists, cloning one from the server if necessary.
*
* A cloned session maintains its own state (namespace bindings, definitions, etc.)
* independently of other sessions.
*/
private async ensureSession(conn: NreplConnection): Promise<string> {
if (this.sessionId) {
return this.sessionId;
}
this.logger.info("Cloning new nREPL session");
return new Promise<string>((resolve, reject) => {
conn.clone((err: Error | null, result: NreplMessage[]) => {
if (err) {
reject(new Error(`Failed to clone nREPL session: ${err.message}`));
return;
}
const sessionMsg = result.find((msg) => msg["new-session"] !== undefined) as any;
if (!sessionMsg) {
reject(new Error("nREPL clone response did not contain a new session ID"));
return;
}
this.sessionId = sessionMsg["new-session"];
this.logger.info("Cloned nREPL session: %s", this.sessionId);
resolve(this.sessionId!);
});
});
}
/**
* Parses the raw nREPL response messages into a structured result.
*/
private parseEvalResult(messages: any[]): NreplEvalResult {
private parseEvalResult(messages: NreplMessage[]): NreplEvalResult {
const values: string[] = [];
const outParts: string[] = [];
const errParts: string[] = [];

View File

@ -11,7 +11,7 @@ import { HighLevelOverviewTool } from "./tools/HighLevelOverviewTool";
import { PenpotApiInfoTool } from "./tools/PenpotApiInfoTool";
import { ExportShapeTool } from "./tools/ExportShapeTool";
import { ImportImageTool } from "./tools/ImportImageTool";
import { EvalCljsExpressionTool } from "./tools/EvalCljsExpressionTool";
import { CljsReplTool } from "./tools/CljsReplTool";
import { NreplClient } from "./NreplClient";
import { ReplServer } from "./ReplServer";
import { ApiDocs } from "./ApiDocs";
@ -190,7 +190,7 @@ export class PenpotMcpServer {
toolInstances.push(new ImportImageTool(this));
}
if (this.isDevEnv()) {
toolInstances.push(new EvalCljsExpressionTool(this, new NreplClient()));
toolInstances.push(new CljsReplTool(this, new NreplClient()));
}
return toolInstances.map((instance) => {

View File

@ -7,53 +7,54 @@ import { PenpotMcpServer } from "../PenpotMcpServer";
import { NreplClient } from "../NreplClient";
/**
* Arguments for the EvalCljsExpressionTool.
* Arguments for the CljsReplTool.
*/
export class EvalCljsExpressionArgs {
export class CljsReplArgs {
static schema = {
expression: z.string().min(1, "Expression cannot be empty"),
code: z.string().min(1, "Code cannot be empty"),
};
/**
* The ClojureScript expression to evaluate in the frontend runtime.
* The ClojureScript code to evaluate in the frontend runtime.
*/
expression!: string;
code!: string;
}
/**
* Tool for evaluating ClojureScript expressions in the Penpot frontend runtime.
* A ClojureScript REPL for the Penpot frontend runtime.
*
* This tool connects to the shadow-cljs nREPL server and evaluates the given
* ClojureScript expression in the context of the running browser application,
* providing direct access to the frontend application state and APIs.
* This tool provides a persistent REPL session connected to the shadow-cljs nREPL server.
* Definitions, requires, and other state are preserved across calls, enabling iterative
* exploration and manipulation of the running Penpot application.
*/
export class EvalCljsExpressionTool extends Tool<EvalCljsExpressionArgs> {
export class CljsReplTool extends Tool<CljsReplArgs> {
private readonly nreplClient: NreplClient;
/**
* Creates a new EvalCljsExpressionTool instance.
* Creates a new CljsReplTool instance.
*
* @param mcpServer - the MCP server instance
* @param nreplClient - the nREPL client for communicating with shadow-cljs
*/
constructor(mcpServer: PenpotMcpServer, nreplClient: NreplClient) {
super(mcpServer, EvalCljsExpressionArgs.schema);
super(mcpServer, CljsReplArgs.schema);
this.nreplClient = nreplClient;
}
public getToolName(): string {
return "eval_cljs_expression";
return "cljs_repl";
}
public getToolDescription(): string {
return (
"Evaluates a ClojureScript expression in the Penpot frontend runtime via the shadow-cljs nREPL server. " +
"The expression is evaluated in the browser context, providing access to the application state and ClojureScript APIs."
"Persistent ClojureScript REPL in the Penpot frontend runtime (via shadow-cljs nREPL). " +
"Definitions, requires, and state are preserved across calls — use it to build up helpers incrementally. " +
"Multiple top-level expressions per call are supported; each produces a result line."
);
}
protected async executeCore(args: EvalCljsExpressionArgs): Promise<ToolResponse> {
const result = await this.nreplClient.evalCljs(args.expression);
protected async executeCore(args: CljsReplArgs): Promise<ToolResponse> {
const result = await this.nreplClient.evalCljs(args.code);
const parts: string[] = [];
if (result.values.length > 0) {

View File

@ -16,7 +16,7 @@ declare module "nrepl-client" {
send(message: Record<string, unknown>, callback: (err: Error | null, result: NreplMessage[]) => void): void;
/**
* Clones the current session.
* Clones the current session, creating a new session that inherits the current state.
*/
clone(callback: (err: Error | null, result: NreplMessage[]) => void): void;
@ -29,6 +29,7 @@ declare module "nrepl-client" {
interface NreplMessage {
id?: string;
session?: string;
"new-session"?: string;
ns?: string;
value?: string;
out?: string;
@ -48,4 +49,5 @@ declare module "nrepl-client" {
function connect(options: ConnectOptions): NreplConnection;
export default { connect };
export type { NreplConnection, NreplMessage, ConnectOptions };
}