Skip to content
Merged
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
14 changes: 10 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ All non-container code is **TypeScript on Node.js**. No Python, no Go, no plain
## Repo layout

```
apps/<name>/ Workspace packages for non-container TS code. Currently `web` (browser shell) and `server` (MVP backend).
containers/<name>/ Per-container code. Currently only `sim`. Future: router, lsp.
apps/<name>/ Workspace packages for non-container TS code. Currently `web` (browser shell), `server` (MVP backend), and `lsp` (JDT LS WebSocket bridge).
containers/<name>/ Per-container code. Currently `sim` and `lsp`. Future: router.
vendor/AdvantageScope/ Pinned submodule of upstream AdvantageScope (used to build AS Lite).
scripts/ Root TypeScript scripts run via tsx (build:ascope, serve:ascope).
dist/ Build output (gitignored). dist/advantagescope/ is the AS Lite bundle.
Expand All @@ -28,6 +28,8 @@ The root is an npm workspaces root (`"workspaces": ["apps/*"]`). Add new TS code
- [x] Task 3 — Minimal web shell (Monaco + AS Lite + Run + console)
- [x] Task 4 — Backend wiring for save and run

- [x] Java LSP MVP add-on - Monaco Java hover/completion/diagnostics via Eclipse JDT LS

## Working principles for this repo

- The author plans to rewrite most of this code post-MVP. Optimize for clarity and getting the loop functional, not production quality. Boring obvious option wins. Keep dep footprint reasonable.
Expand All @@ -46,15 +48,19 @@ The root is an npm workspaces root (`"workspaces": ["apps/*"]`). Add new TS code
## Commands

- Build sim image: `docker build -t frc-sim:mvp containers/sim`
- Build Java LSP image: `docker build -f containers/lsp/Dockerfile -t frc-lsp:mvp .`
- Run sim: `docker run --rm -p 5810:5810 --memory=2g frc-sim:mvp`
- Run named MVP sim container: `docker run -d --name frc-sim-mvp -p 5810:5810 --memory=2g frc-sim:mvp`
- Run named MVP LSP container: `docker run -d --name frc-lsp-mvp -p 30003:30003 --memory=2g frc-lsp:mvp`
- Build AS Lite bundle: `npm run build:ascope` (writes `dist/advantagescope/`)
- Serve AS Lite: `npm run serve:ascope` (HTTP on `:8080`; override with `PORT=...`)
- Run Java LSP bridge on host: `npm run dev:lsp` (WS on `:30003/jdtls`; normally run in `frc-lsp-mvp`)
- Run backend: `npm run dev:server` (Fastify on `:4000`; override with `PORT=...`; uses `SIM_CONTAINER`, default `frc-sim-mvp`)
- Run web shell dev server: `npm run dev:web` (Vite on `:3000`; iframes AS Lite from `:8080`)
- Run full MVP dev stack: `npm run dev:mvp` (keeps/starts `frc-sim-mvp`, then starts AS Lite, backend, and web)
- Typecheck root scripts: `npm run typecheck`. Web shell: `npm run typecheck --workspace apps/web`. Backend: `npm run typecheck --workspace apps/server`.
- Run full MVP dev stack: `npm run dev:mvp` (keeps/starts `frc-sim-mvp` and `frc-lsp-mvp`, then starts AS Lite, backend, and web)
- Typecheck root scripts: `npm run typecheck`. Web shell: `npm run typecheck --workspace apps/web`. Backend: `npm run typecheck --workspace apps/server`. LSP bridge: `npm run typecheck --workspace apps/lsp`.
- First-time setup: `git submodule update --init --recursive && npm install && npm run build:ascope`
- End-to-end verify Task 2: run sim and `serve:ascope`, open Chrome at `http://localhost:8080`, expect AS Lite connected with `/SmartDashboard/counter` incrementing on a Line Graph and `/SmartDashboard/robotPose` moving on a 2D Field tab.
- End-to-end verify Task 3: run sim, `serve:ascope`, and `dev:web` in three terminals; open Chrome at `http://localhost:3000`, expect Monaco showing `Robot.java` (editable), AS Lite iframe live with counter+pose, and clicking Run appends `clicked` to the console panel.
- End-to-end verify Task 4: rebuild `frc-sim:mvp`, run `npm run dev:mvp`, open `http://localhost:3000`, edit `Robot.java`, wait for auto-save, click Run, and expect build/sim logs plus AS Lite reconnecting to the updated NT4 data. Syntax errors should show raw Gradle compile output and recover after fixing the file and running again.
- End-to-end verify Java LSP: rebuild `frc-lsp:mvp`, run `npm run dev:mvp`, open `http://localhost:3000`, expect `java language server connected`, hover WPILib symbols such as `Pose2d`, request completions after `SmartDashboard.`, and introduce/fix a Java error to confirm Monaco diagnostics.
24 changes: 17 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

An experimental browser-based IDE for learning FRC robot programming. Students write Java in Monaco, click Run, and watch their robot simulate in real time with telemetry rendered by AdvantageScope Lite. See [`Project-MVP.md`](./Project-MVP.md) for full scope and the task breakdown.

Status: Tasks 1-4 of the MVP are implemented: sim container, AdvantageScope Lite hosting, browser shell, and backend save/run wiring.
Status: Tasks 1-4 of the MVP are implemented: sim container, AdvantageScope Lite hosting, browser shell, and backend save/run wiring. The browser shell also has a Java LSP MVP add-on for hover, completion, and diagnostics through Eclipse JDT LS.

## Prerequisites

Expand Down Expand Up @@ -41,39 +41,43 @@ npm run build:ascope # builds AS Lite → dist/advantagesc

The AS Lite build downloads ~50 MB of bundled field/robot models on first run and takes several minutes (rollup + wasm). Subsequent runs are fast because `npm install` and the Lite bundles are cached.

To rebuild the sim image (Task 1) if you haven't yet:
To rebuild the sim and Java LSP images if you haven't yet:

```bash
docker build -t frc-sim:mvp containers/sim
docker build -f containers/lsp/Dockerfile -t frc-lsp:mvp .
```

## Running the MVP loop (Task 4)

Build the sim image, then start the full local stack:
Build the sim and LSP images, then start the full local stack:

```bash
docker build -t frc-sim:mvp containers/sim
docker build -f containers/lsp/Dockerfile -t frc-lsp:mvp .
npm run dev:mvp
```

`npm run dev:mvp` keeps or starts a named container, `frc-sim-mvp`, then launches:
`npm run dev:mvp` keeps or starts named containers, `frc-sim-mvp` and `frc-lsp-mvp`, then launches:

- AS Lite static server on `http://localhost:8080`
- Backend on `http://localhost:4000`
- Java LSP WebSocket bridge on `ws://localhost:30003/jdtls` inside the LSP container
- Web IDE on `http://localhost:3000`

Open [http://localhost:3000](http://localhost:3000). Monaco loads `Robot.java` through `GET /file`, edits auto-save through `POST /file`, and Run opens `WS /run` to stream Gradle and sim logs. AS Lite remains iframed from `:8080` and reconnects to NT4 on `:5810`.
Open [http://localhost:3000](http://localhost:3000). Monaco loads `Robot.java` through `GET /file`, connects that same editor buffer to JDT LS for Java hover/completion/diagnostics, edits auto-save through `POST /file`, and Run opens `WS /run` to stream Gradle and sim logs. AS Lite remains iframed from `:8080` and reconnects to NT4 on `:5810`.

To verify a save from another terminal:

```bash
docker exec frc-sim-mvp cat /workspace/project/src/main/java/frc/robot/Robot.java
```

Stopping `npm run dev:mvp` stops the host dev processes but leaves `frc-sim-mvp` running so the container filesystem remains the current project state. Remove it manually when you want a fresh baked project:
Stopping `npm run dev:mvp` stops the host dev processes but leaves `frc-sim-mvp` and `frc-lsp-mvp` running so their startup cost is paid once. Remove them manually when you want a fresh baked project or LSP workspace:

```bash
docker rm -f frc-sim-mvp
docker rm -f frc-lsp-mvp
```

## Running AS Lite standalone (Task 2 manual verification)
Expand Down Expand Up @@ -124,7 +128,9 @@ See [`docs/decisions/002-advantagescope-lite-hosting.md`](./docs/decisions/002-a
```
apps/web/ Vite browser shell with Monaco, AS Lite iframe, console, and Run
apps/server/ Fastify backend for file save, build/restart, and log streaming
apps/lsp/ Node TypeScript WebSocket bridge from browser LSP traffic to Eclipse JDT LS
containers/sim/ Docker sim image + WPILib hello-world (Task 1)
containers/lsp/ Docker Java LSP image with Eclipse JDT LS and a baked WPILib project
vendor/AdvantageScope/ Pinned submodule of upstream AdvantageScope, used to build AS Lite
scripts/ TypeScript build/run scripts (run via tsx)
dist/advantagescope/ Built AS Lite static bundle (gitignored; output of npm run build:ascope)
Expand All @@ -139,15 +145,19 @@ AGENTS.md Repo notes for AI agents working in this repo
| --- | --- |
| `npm run build:ascope` | Build AdvantageScope Lite from the pinned submodule into `dist/advantagescope/` |
| `npm run serve:ascope` | Serve `dist/advantagescope/` over plain HTTP on `:8080` (override with `PORT=...`) |
| `npm run dev:lsp` | Run the Java LSP bridge on `:30003/jdtls` on the host; normally the Docker image runs this |
| `npm run dev:server` | Run the MVP backend on `:4000` (override with `PORT=...`; uses `SIM_CONTAINER`, default `frc-sim-mvp`) |
| `npm run dev:web` | Run the Vite web IDE on `:3000` |
| `npm run dev:mvp` | Ensure `frc-sim-mvp` is running, then start AS Lite, backend, and web dev servers |
| `npm run dev:mvp` | Ensure `frc-sim-mvp` and `frc-lsp-mvp` are running, then start AS Lite, backend, and web dev servers |
| `npm run typecheck` | `tsc --noEmit` over `scripts/` |
| `npm run typecheck --workspace apps/server` | Typecheck the backend |
| `npm run typecheck --workspace apps/web` | Typecheck the browser shell |
| `npm run typecheck --workspace apps/lsp` | Typecheck the Java LSP bridge |
| `docker build -t frc-sim:mvp containers/sim` | Build the sim container (Task 1) |
| `docker build -f containers/lsp/Dockerfile -t frc-lsp:mvp .` | Build the Java LSP container |
| `docker run --rm -p 5810:5810 --memory=2g frc-sim:mvp` | Run the sim with NT4 on `:5810` |
| `docker run -d --name frc-sim-mvp -p 5810:5810 --memory=2g frc-sim:mvp` | Run the named long-lived MVP sim container used by the backend |
| `docker run -d --name frc-lsp-mvp -p 30003:30003 --memory=2g frc-lsp:mvp` | Run the named Java LSP container used by the web editor |

## License

Expand Down
22 changes: 22 additions & 0 deletions apps/lsp/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "@frc-sim/lsp",
"private": true,
"version": "0.0.0",
"description": "Java LSP WebSocket bridge for the FRC simulator MVP.",
"type": "module",
"scripts": {
"dev": "tsx src/main.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"vscode-languageserver-protocol": "3.17.5",
"vscode-ws-jsonrpc": "3.5.0",
"ws": "8.19.0"
},
"devDependencies": {
"@types/node": "^22.10.0",
"@types/ws": "8.18.1",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}
180 changes: 180 additions & 0 deletions apps/lsp/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { spawn, type SpawnOptions } from "node:child_process";
import { createServer, type IncomingMessage, type Server } from "node:http";
import type { Socket } from "node:net";
import { URL } from "node:url";
import {
InitializeRequest,
Message,
type InitializeParams,
type RequestMessage,
type ResponseMessage,
} from "vscode-languageserver-protocol";
import {
type IWebSocket,
WebSocketMessageReader,
WebSocketMessageWriter,
} from "vscode-ws-jsonrpc";
import {
createConnection,
createServerProcess,
forward,
} from "vscode-ws-jsonrpc/server";
import { WebSocketServer, type RawData, type ServerOptions } from "ws";

const serverName = "Eclipse JDT LS";
const port = Number(process.env.PORT ?? 30003);
const host = process.env.HOST ?? "0.0.0.0";
const pathName = process.env.LSP_PATH ?? "/jdtls";
const jdtLsHome = process.env.JDTLS_HOME ?? "/opt/jdtls";
const jdtLsLauncher =
process.env.JDTLS_LAUNCHER ??
`${jdtLsHome}/plugins/org.eclipse.equinox.launcher_1.6.900.v20240613-2009.jar`;
const jdtLsConfig = process.env.JDTLS_CONFIG ?? `${jdtLsHome}/config_linux`;
const jdtLsData = process.env.JDTLS_DATA ?? "/workspace/jdtls-data";
const logMessages = process.env.LSP_LOG_MESSAGES === "1";

type LanguageServerRunConfig = {
pathName: string;
serverPort: number;
runCommand: string;
runCommandArgs: string[];
wsServerOptions: ServerOptions;
spawnOptions?: SpawnOptions;
};

const runConfig: LanguageServerRunConfig = {
pathName,
serverPort: port,
runCommand: "java",
runCommandArgs: [
"-Declipse.application=org.eclipse.jdt.ls.core.id1",
"-Dosgi.bundles.defaultStartLevel=4",
"-Declipse.product=org.eclipse.jdt.ls.core.product",
"-Dlog.level=ALL",
"-Xmx1G",
"--add-modules=ALL-SYSTEM",
"--add-opens",
"java.base/java.util=ALL-UNNAMED",
"--add-opens",
"java.base/java.lang=ALL-UNNAMED",
"-jar",
jdtLsLauncher,
"-configuration",
jdtLsConfig,
"-data",
jdtLsData,
],
wsServerOptions: { noServer: true, perMessageDeflate: false },
};

function launchLanguageServer(config: LanguageServerRunConfig, socket: IWebSocket): void {
const reader = new WebSocketMessageReader(socket);
const writer = new WebSocketMessageWriter(socket);
const socketConnection = createConnection(reader, writer, () => socket.dispose());
const serverConnection = createServerProcess(
serverName,
config.runCommand,
config.runCommandArgs,
config.spawnOptions,
);

if (serverConnection === undefined) {
socket.dispose();
return;
}

forward(socketConnection, serverConnection, (message) => {
if (Message.isRequest(message)) {
if (message.method === InitializeRequest.type.method) {
const initializeParams = message.params as InitializeParams;
initializeParams.processId = process.pid;
}
if (logMessages) {
console.log(`${serverName} received ${message.method}`);
}
return message as RequestMessage;
}

if (Message.isResponse(message)) {
if (logMessages) {
console.log(`${serverName} sent response ${message.id}`);
}
return message as ResponseMessage;
}

return message;
});
}

function asWebSocket(webSocket: import("ws").WebSocket): IWebSocket {
return {
send: (content) => {
webSocket.send(content, (error) => {
if (error) {
throw error;
}
});
},
onMessage: (callback) => {
webSocket.on("message", (data: RawData) => callback(data));
},
onError: (callback) => {
webSocket.on("error", callback);
},
onClose: (callback) => {
webSocket.on("close", callback);
},
dispose: () => {
webSocket.close();
},
};
}

function handleUpgrade(
config: LanguageServerRunConfig,
httpServer: Server,
wsServer: WebSocketServer,
): void {
httpServer.on("upgrade", (request: IncomingMessage, socket: Socket, head: Buffer) => {
const baseUrl = `http://${request.headers.host ?? "localhost"}/`;
const requestPath = request.url ? new URL(request.url, baseUrl).pathname : undefined;

if (requestPath !== config.pathName) {
socket.destroy();
return;
}

wsServer.handleUpgrade(request, socket, head, (webSocket) => {
const lspSocket = asWebSocket(webSocket);
if (webSocket.readyState === webSocket.OPEN) {
launchLanguageServer(config, lspSocket);
} else {
webSocket.on("open", () => launchLanguageServer(config, lspSocket));
}
});
});
}

process.on("uncaughtException", (err: Error) => {
console.error(`Uncaught exception: ${err.message}`);
if (err.stack) {
console.error(err.stack);
}
});

const httpServer = createServer((_req, res) => {
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
res.end(`${serverName} WebSocket bridge. Connect to ${pathName}.\n`);
});
const wsServer = new WebSocketServer(runConfig.wsServerOptions);
handleUpgrade(runConfig, httpServer, wsServer);

httpServer.listen(runConfig.serverPort, host, () => {
console.log(`${serverName} bridge listening on ws://${host}:${runConfig.serverPort}${pathName}`);
});

process.on("SIGTERM", () => {
wsServer.close();
httpServer.close();
});

16 changes: 16 additions & 0 deletions apps/lsp/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"lib": ["es2022"],
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"noEmit": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
6 changes: 3 additions & 3 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"monaco-editor": "^0.52.0"
},
"devDependencies": {
"typescript": "^5.7.2",
"vite": "^6.0.0"
},
"dependencies": {
"monaco-editor": "^0.52.2"
}
}
Loading