mirror of
https://github.com/penpot/penpot.git
synced 2026-05-13 12:04:06 +00:00
✨ Revamp cljs expression evaluation to full-blown REPL
This commit is contained in:
parent
66d518f15d
commit
f1affdbadc
@ -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
|
||||
```
|
||||
@ -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[] = [];
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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) {
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user