From 71d4f94a88d31e9cf96268d60c9c8942589dac30 Mon Sep 17 00:00:00 2001 From: Baylem Date: Wed, 11 Nov 2026 00:06:21 -0500 Subject: [PATCH 1/9] feat(web): migrate the dashboard toolchain to TypeScript 7 Signed-off-by: Baylem --- .claude/agents/dependabot-fixer.md | 2 +- .claude/skills/deps-check/SKILL.md | 20 +- .github/workflows/ci.yaml | 4 +- .gitignore | 1 + .specify/memory/constitution.md | 11 +- AGENTS.md | 16 +- docs/dependencies.md | 6 +- docs/roadmap.md | 2 +- .../tasks.md | 4 +- web/.oxlintrc.json | 54 + web/eslint.config.js | 46 +- web/package-lock.json | 7649 ++++++++--------- web/package.json | 20 +- web/scripts/check-lint-toolchain.sh | 34 + web/scripts/i18n-migrate.test.ts | 57 + web/scripts/i18n-migrate.ts | 588 +- web/specs.md | 19 +- web/tsconfig.node.tsbuildinfo | 1 - web/tsconfig.tsbuildinfo | 1 - 19 files changed, 3979 insertions(+), 4556 deletions(-) create mode 100644 web/.oxlintrc.json create mode 100644 web/scripts/check-lint-toolchain.sh create mode 100644 web/scripts/i18n-migrate.test.ts delete mode 100644 web/tsconfig.node.tsbuildinfo delete mode 100644 web/tsconfig.tsbuildinfo diff --git a/.claude/agents/dependabot-fixer.md b/.claude/agents/dependabot-fixer.md index 3ee9d95bc..fe8a7aab5 100644 --- a/.claude/agents/dependabot-fixer.md +++ b/.claude/agents/dependabot-fixer.md @@ -14,7 +14,7 @@ You follow the rules in `CLAUDE.md`. Implementation work goes through Workflow s 1. **Read the PR live.** `gh pr view `, `gh pr diff `. Note the branch and head sha. Dependabot branches are named `dependabot/...`. 2. **Read the failing checks.** Delegate the diagnosis to the `ci-triager` agent. Do not diagnose from memory. Ask it for the failing job, root cause and owning file. -3. **Run the blocked-upgrades check first.** Run the `/deps-check` skill. If the bump is a blocked one (TypeScript 7 or ESLint 10 while the peer range is still old), **stop and report.** Do not work around it. Do not use overrides or `--legacy-peer-deps`. Do not close #272, #386 or #387. +3. **Check peer compatibility.** For npm install failures, use `/deps-check` to identify which installed package excludes the proposed version. Keep blocked PRs open. 4. **Read the changelog and migration notes** of the bumped package. Use the release notes on the package's GitHub page or on the registry. Note every breaking change. 5. **Fix real breakages in code.** Change call sites to match the new API. Do not add `@ts-ignore`, `eslint-disable`, `//nolint`, overrides, or loosened configs. Do not delete or weaken tests (system rule 1). If a test must change, stop and ask the maintainer. 6. **Regenerate lock files with the tool, never by hand.** diff --git a/.claude/skills/deps-check/SKILL.md b/.claude/skills/deps-check/SKILL.md index efdc0befd..e01340572 100644 --- a/.claude/skills/deps-check/SKILL.md +++ b/.claude/skills/deps-check/SKILL.md @@ -1,22 +1,10 @@ --- name: deps-check -description: "Use at session start, or when a dependency upgrade is proposed, to check the blocked dependency upgrades listed in CLAUDE.md (TypeScript 7 and ESLint 10) against the npm registry." +description: "Identify which published peer dependency blocks a proposed npm upgrade." --- -This is the session-start check from `CLAUDE.md`. Run it every session. It only reads the registry. It changes nothing. - ## Steps -1. Run the registry checks: - ```sh - npm view @typescript-eslint/parser@latest version peerDependencies.typescript - npm view @typescript-eslint/parser@canary version peerDependencies.typescript - npm view eslint-plugin-react@latest version peerDependencies.eslint - ``` -2. TypeScript 7 entry. It is blocked while the peer range ends `<7` (for example `<6.1.0`). Report blocked or unblocked, with the version and the peer range seen for both `@latest` and `@canary`. Unblocked means a release accepts TypeScript 7. -3. ESLint 10 entry. It is blocked while the `eslint-plugin-react` peer range lacks `^10`. Report blocked or unblocked, with the version and the peer range seen. Other plugins already accept `^10`: `eslint-plugin-react-hooks@7.1.1` and `@typescript-eslint/*@8.69.0`. -4. Report one line per entry: name, blocked or unblocked, version, peer range. Then say what blocks it, in one line. -5. If an entry is unblocked, list its unblock steps from `CLAUDE.md`. Do not perform them unless the maintainer asks. - - TypeScript 7: do T055 and T056 in `specs/009-remediate-security-dependabot/tasks.md`. Bump `typescript` and `@typescript-eslint/*` in `web/package.json`. Fix real type errors, with no `@ts-ignore`. Merge Dependabot #272. Mark the tasks `[X]`. Delete the TypeScript entry from `CLAUDE.md`. - - ESLint 10: bump `eslint` and `@eslint/js` together in `web/package.json`. Fix real lint errors, with no `eslint-disable`. Merge #386, and #387 if it is open. Delete the ESLint entry from `CLAUDE.md`. -6. Never close #272, #386 or #387. Never use `--legacy-peer-deps` or overrides to get around a peer range. +1. Read the failed install or `npm ls` output. Identify the proposed version and the package declaring the conflicting peer range. +2. Use `npm view @ peerDependencies` for the locked version and `npm view @latest version peerDependencies` for the current release. +3. Report which release admits the upgrade, or which range still blocks it. Preserve npm's peer checks and keep blocked PRs open. diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8ce0fa057..98b6e6d55 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1026,6 +1026,8 @@ jobs: - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: { node-version: "24", cache: "npm", cache-dependency-path: web/package-lock.json } - run: npm ci + - name: Verify dashboard lint rules and compiler version + run: bash scripts/check-lint-toolchain.sh - uses: ./.github/actions/go-cache with: key-suffix: web-i18n @@ -1087,7 +1089,7 @@ jobs: fi echo "ok $code" done - - name: eslint + - name: ESLint and Oxlint run: npm run lint - run: npm run build - name: unit tests + coverage diff --git a/.gitignore b/.gitignore index 60ec0605f..e495b3be8 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ test/e2e/.kube/ web/node_modules/ web/node_modules web/dist/ +web/*.tsbuildinfo web/build/ web/.vite/ web/.turbo/ diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index edf73d3b9..184a886a4 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,5 +1,10 @@ **Styling / build** - `tailwindcss` `^4.3.3`, `autoprefixer` `^10.5.4`, `postcss` `^8.5.26` (dev) — `postcss.config.js` wires Tailwind + autoprefixer; `tailwind.config.ts` defines the design tokens used throughout `src/styles` and Tailwind classes app-wide. - `vite` `^8.2.2`, `@vitejs/plugin-react` `^6.1.1` (dev) — `vite.config.ts` drives the dev server (with the API/WS proxy to the in-cluster API used by `make web-dev`), production build, and is the engine behind `vitest.config.ts`. - `typescript` `^7.0.2` (dev) provides the native `tsc` used for the dashboard build and Playwright type checks. The i18n migration script reads TSX syntax with Babel. -- Monaco ships a separate TypeScript 5.9.3 language service for the in-browser file editor. That version comes from the Monaco distribution. [Monaco worker metadata](https://github.com/microsoft/monaco-editor/blob/v0.56.0/src/languages/features/typescript/lib/typescriptServicesMetadata.ts) +- Opening a JavaScript or TypeScript file starts Microsoft's TypeScript language server in a WebAssembly worker. The browser package is pinned to `7.1.0-dev.20261030.1`, the version Microsoft tested in Playground v2. Its shared input buffer requires a secure, cross-origin-isolated page (HTTPS or localhost with the isolation headers). On other origins the editor provides syntax highlighting and saving. The worker checks the open file against TypeScript's standard library in browser memory. [Microsoft's Playground implementation](https://github.com/microsoft/TypeScript-Website/pull/3653) **Lint** (dev, all wired directly in `eslint.config.js`) - `eslint` `^10.14.0` and `@eslint/js` `^10.0.1` check JavaScript. Babel parses TypeScript and JSX for `eslint-plugin-react-hooks` and `eslint-plugin-formatjs`. diff --git a/web/Dockerfile b/web/Dockerfile index 06d59463d..abb08ac5b 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -37,7 +37,7 @@ COPY web/ ./ # Generated language manifest (computed by the i18n stage above). COPY --from=i18n /out/languages.json ./src/generated/i18n/languages.json # Vite emits to web/dist. tsc + vite build run via `npm run build`. -RUN npm run build +RUN npm run build && gzip -9 -n -k dist/assets/tsc-*.wasm dist/assets/monaco-ts-worker-*.js # ---- serve with a non-root nginx ---- # nginx-unprivileged listens on 8080 and runs as uid 101 (nginx), so it needs diff --git a/web/e2e/specs/fileManager.spec.ts b/web/e2e/specs/fileManager.spec.ts index 15a5000f8..2c2b1c541 100644 --- a/web/e2e/specs/fileManager.spec.ts +++ b/web/e2e/specs/fileManager.spec.ts @@ -60,4 +60,46 @@ test.describe("file manager tab", () => { await page.getByText("server.properties").click(); await readReq; }); + + test("loads the TypeScript worker on opening a script and provides diagnostics and completions", async ({ page, context }) => { + test.setTimeout(90_000); + await page.addInitScript(() => localStorage.setItem("gameplane-e2e-dataset", "typescript-file")); + const wasmUrls: URL[] = []; + context.on("response", (response) => { + const url = new URL(response.url()); + if (/\/tsc(?:-[\w-]+)?\.wasm$/.test(url.pathname) && + response.ok() && response.headers()["content-type"]?.startsWith("application/wasm")) { + wasmUrls.push(url); + } + }); + + const server = new ServerDetailPage(page); + await server.goto("alpha"); + await server.clickTab("Settings"); + await page.getByRole("tab", { name: "Placement", exact: true }).click(); + await expect(page.locator(".monaco-editor .view-line").first()).toBeVisible(); + await server.clickTab("Files"); + const file = page.getByText("server.ts", { exact: true }); + await expect(file).toBeVisible(); + expect(wasmUrls).toHaveLength(0); + const openedAt = performance.now(); + await file.click(); + + const editor = page.locator(".monaco-editor"); + await expect(editor.locator(".squiggly-error").first()).toBeVisible({ timeout: 60_000 }); + const firstDiagnosticMs = Math.round(performance.now() - openedAt); + expect(wasmUrls.map((url) => url.origin)).toContain(new URL(page.url()).origin); + await editor.locator(".view-line").first().click(); + await page.keyboard.press("ControlOrMeta+a"); + await page.keyboard.insertText("const port: number = 25565;"); + await expect(editor.locator(".squiggly-error")).toHaveCount(0); + await page.keyboard.press("End"); + await page.keyboard.press("Enter"); + await page.keyboard.insertText("port.toF"); + const completionRequestedAt = performance.now(); + await page.keyboard.press("Control+Space"); + await expect(editor.getByRole("listbox", { name: "Suggest" }).getByText("toFixed", { exact: true })).toBeVisible(); + const completionMs = Math.round(performance.now() - completionRequestedAt); + console.log(`Monaco TS7: file click to first error ${firstDiagnosticMs} ms, completion shortcut to suggestion ${completionMs} ms`); + }); }); diff --git a/web/e2e/specs/settingsSubTabs.spec.ts b/web/e2e/specs/settingsSubTabs.spec.ts index 90bae9bb8..44fb0d2b8 100644 --- a/web/e2e/specs/settingsSubTabs.spec.ts +++ b/web/e2e/specs/settingsSubTabs.spec.ts @@ -47,6 +47,23 @@ test.describe("server settings sub-tabs", () => { await loginIfNeeded(page); }); + test("validates edits in the Placement JSON editor", async ({ page }) => { + const server = new ServerDetailPage(page); + await server.goto("alpha"); + await server.clickTab("Settings"); + await page.getByRole("tab", { name: "Placement", exact: true }).click(); + const tolerations = page.locator(".monaco-editor").first(); + await tolerations.locator(".view-line").first().click(); + await page.keyboard.press("ControlOrMeta+a"); + await page.keyboard.insertText("[}"); + await expect(page.getByText("Invalid JSON", { exact: true })).toBeVisible(); + await expect(tolerations.locator(".squiggly-error").first()).toBeVisible(); + await page.keyboard.press("ControlOrMeta+a"); + await page.keyboard.insertText("[]"); + await expect(page.getByText("Invalid JSON", { exact: true })).toHaveCount(0); + await expect(tolerations.locator(".squiggly-error")).toHaveCount(0); + }); + test("walks every sub-tab without console errors", async ({ page }) => { const errors: string[] = []; page.on("pageerror", (err) => errors.push(err.message)); diff --git a/web/nginx.conf.template b/web/nginx.conf.template index 2a0afd753..e73959b3f 100644 --- a/web/nginx.conf.template +++ b/web/nginx.conf.template @@ -119,6 +119,9 @@ server { # same ones on its own responses (secureHeaders in api/cmd/main.go). add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "DENY" always; + # The TypeScript editor and its worker share a buffer for LSP messages. + add_header Cross-Origin-Opener-Policy "same-origin" always; + add_header Cross-Origin-Embedder-Policy "credentialless" always; add_header Referrer-Policy "no-referrer" always; add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; @@ -131,7 +134,10 @@ server { # Hashed assets are content-addressed and keep their immutable cache. location /assets/ { try_files $uri =404; + gzip_static on; + gzip_vary on; expires 1y; + add_header Cross-Origin-Embedder-Policy "credentialless" always; add_header Cache-Control "public, immutable"; add_header X-Content-Type-Options "nosniff" always; } diff --git a/web/package-lock.json b/web/package-lock.json index bb8068c2c..a45fd8a71 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,7 @@ "name": "@gameplane/web", "version": "0.4.0", "dependencies": { + "@bjorn3/browser_wasi_shim": "0.4.2", "@heroui/react": "3.2.7", "@heroui/styles": "3.2.7", "@monaco-editor/loader": "^1.7.0", @@ -18,17 +19,19 @@ "@tanstack/react-query": "^5.106.1", "@tanstack/react-router": "^1.170.46", "@tanstack/react-virtual": "^3.14.26", + "@typescript/typescript-wasip1-wasm": "7.1.0-dev.20261030.1", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "clsx": "^2.1.1", "lucide-react": "^1.69.0", - "monaco-editor": "^0.56.0", + "monaco-editor": "0.57.0", "react": "^19.3.1", "react-aria": "^3.53.1", "react-aria-components": "^1.22.1", "react-dom": "^19.3.1", "react-intl": "^12.1.6", - "tailwind-merge": "^3.7.0" + "tailwind-merge": "^3.7.0", + "vscode-languageserver-protocol": "3.17.5" }, "devDependencies": { "@babel/core": "^8.1.0", @@ -663,6 +666,12 @@ "node": ">=18" } }, + "node_modules/@bjorn3/browser_wasi_shim": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@bjorn3/browser_wasi_shim/-/browser_wasi_shim-0.4.2.tgz", + "integrity": "sha512-/iHkCVUG3VbcbmEHn5iIUpIrh7a7WPiwZ3sHy4HZKZzBdSadwdddYDZAII2zBvQYV0Lfi8naZngPCN7WPHI/hA==", + "license": "MIT OR Apache-2.0" + }, "node_modules/@cacheable/memory": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", @@ -4374,6 +4383,12 @@ "node": ">=16.20.0" } }, + "node_modules/@typescript/typescript-wasip1-wasm": { + "version": "7.1.0-dev.20261030.1", + "resolved": "https://registry.npmjs.org/@typescript/typescript-wasip1-wasm/-/typescript-wasip1-wasm-7.1.0-dev.20261030.1.tgz", + "integrity": "sha512-m3WKNe9Wa0EsTOJXpf1t55hB7TzArcfuiygFi5MgD6EL5xB3eUTN9xNRklv/m0yyXSoVmpqdYNgujjgroCPlNA==", + "license": "Apache-2.0" + }, "node_modules/@typescript/typescript-win32-arm64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", @@ -6840,12 +6855,12 @@ } }, "node_modules/monaco-editor": { - "version": "0.56.0", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.56.0.tgz", - "integrity": "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg==", + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.57.0.tgz", + "integrity": "sha512-5BkI9KGoqrNvBGUe15/QlZq3OooZ8WLg1AxTpaqHRCP3HNpzPPZKE2EDz8M7c+VRmCeUw1Brp4cx/PWm3kI/5A==", "license": "MIT", "dependencies": { - "dompurify": "3.4.8", + "dompurify": "3.4.15", "marked": "14.0.0" } }, @@ -8328,6 +8343,31 @@ "vitest": ">=5 <6" } }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", diff --git a/web/package.json b/web/package.json index ab6d81042..6c9a0192c 100644 --- a/web/package.json +++ b/web/package.json @@ -27,6 +27,7 @@ "diff:screenshots": "node scripts/compare-screenshots.mjs" }, "dependencies": { + "@bjorn3/browser_wasi_shim": "0.4.2", "@heroui/react": "3.2.7", "@heroui/styles": "3.2.7", "@monaco-editor/loader": "^1.7.0", @@ -37,17 +38,19 @@ "@tanstack/react-query": "^5.106.1", "@tanstack/react-router": "^1.170.46", "@tanstack/react-virtual": "^3.14.26", + "@typescript/typescript-wasip1-wasm": "7.1.0-dev.20261030.1", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "clsx": "^2.1.1", "lucide-react": "^1.69.0", - "monaco-editor": "^0.56.0", + "monaco-editor": "0.57.0", "react": "^19.3.1", "react-aria": "^3.53.1", "react-aria-components": "^1.22.1", "react-dom": "^19.3.1", "react-intl": "^12.1.6", - "tailwind-merge": "^3.7.0" + "tailwind-merge": "^3.7.0", + "vscode-languageserver-protocol": "3.17.5" }, "devDependencies": { "@babel/core": "^8.1.0", diff --git a/web/src/lib/monaco-editor.test.tsx b/web/src/lib/monaco-editor.test.tsx new file mode 100644 index 000000000..1e5b41db9 --- /dev/null +++ b/web/src/lib/monaco-editor.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from "@testing-library/react"; +import type { EditorProps, Monaco, OnMount } from "@monaco-editor/react"; +import { afterEach, expect, it, vi } from "vitest"; +import Editor, { startFileLanguageService } from "./monaco-editor"; +import { startTypeScriptClient } from "./monaco-ts-client"; + +vi.mock("./monaco-loader", () => ({ + default: ({ value, language }: EditorProps) =>