Skip to content

fix(editor): serve Monaco from our own origin so the workspace works air-gapped - #251

Merged
cevheri merged 1 commit into
mainfrom
feat/monaco-selfhost-247
Jul 30, 2026
Merged

cevheri merged 1 commit into
mainfrom
feat/monaco-selfhost-247

Conversation

@cevheri

@cevheri cevheri commented Jul 30, 2026 •

Copy link
Copy Markdown
Member

Closes #247.

Monaco was the last thing in the app fetched off-origin. @monaco-editor/react loads it from cdn.jsdelivr.net unless its loader is told otherwise, and nothing told it otherwise, so an install without internet had no SQL editor — a spinner that never resolved, while connections, the schema explorer and the results grid all rendered fine. The same dependency flaked the Channel E2E (docker) gate: 3 of the last 40 Docker builds failed waiting for .monaco-editor, each one a jsdelivr timeout on a change that had nothing to do with the editor.

What changed

scripts/copy-monaco.mjs Stages node_modules/monaco-editor/min/vs into public/monaco/vs at build time (16 MB). monaco-editor was already a direct dependency, so the assets were on disk all along — just never served.
src/lib/editor/monaco-loader.ts Points the AMD loader at /monaco/vs, replacing the jsdelivr default. Called at QueryEditor module load, before any <Editor> mounts.
package.json, Dockerfile Wired into dev and build, and explicitly into the Dockerfile — it calls next build directly and inherits nothing from package.json. That is the one feeding the flaky gate.
e2e/offline-editor.spec.ts Blocks every off-origin request, then asserts the editor renders, a query against the SQLite sample returns its rows, and that nothing off-origin was attempted at all.
.oxlintrc.json, eslint.config.mjs Skip public/monaco/** — see the gotcha below.

Every other artifact (standalone tarballs, .deb/.rpm, snap, AppImage, Flatpak, npx) derives from bun run build, so all of them inherit the staging step.

Acceptance criteria

  • Loading the workspace issues zero off-origin requests — the spec fails if any request leaves our origin, whether or not the run has internet.
  • With off-origin requests blocked, the editor renders and a query runs end to end — verified against a production build and bun dev.
  • A regression test asserts the offline case — plus unit coverage for the path resolution, the staging script (including its failure exit), and the build wiring.
  • Docker/snap/deb/AppImage/Flatpak inherit it — CI ended up proving three of them outright: Channel E2E (docker) (the gate that was flaking), Channel E2E (tarball + npx) and E2E Tests all boot a packaged artifact and wait for .monaco-editor. The rest derive from the same bun run build and the same bundle; no separate build of snap/deb/rpm/AppImage/Flatpak was run for this PR.

Verification

TDD throughout: every test was watched failing first. The offline spec was confirmed to catch the regression — with the loader configuration removed it reproduces the issue symptom exactly (.monaco-editor element count 0), and passes with it restored.

Local gates: format, lint, typecheck, knip, test, build, plus test:coverage at 100% (25922/25922 lines), build:lib and attw.

Two things worth knowing

Embedded mode needs a platform-side step. The npm package ships only dist and bin, so an embedded studio resolves /monaco/vs against platform's origin. Platform must either copy the bundle into its own public/ (monaco-editor arrives transitively) or set NEXT_PUBLIC_MONACO_VS_PATH. Recorded in .claude/rules/platform-integration.md, since nothing in this repo's CI can catch it.

Two cdn.jsdelivr.net strings still survive in .next/static, so the issue's "no string survives" check does not literally hold: one is the loader library's own default config literal (overwritten at runtime before init), the other is snapdom's font-host allowlist in the diagram exporter. Neither is ever requested — which is what the offline spec pins, and a stronger guarantee than grepping for a string.

Trade: +16 MB in the image and in each packaged artifact, in exchange for never fetching per visit. For the desktop builds, which already carry a Node runtime and a Next.js payload, that is arguably the better side of the trade anyway.

…air-gapped

@monaco-editor/react fetches Monaco from cdn.jsdelivr.net unless its loader is
told otherwise, and nothing told it otherwise (#247). On a machine without
internet the editor pane was a spinner that never resolved while the rest of the
app looked healthy - connections, schema explorer and the results grid all
rendered - so studio appeared fine while being unusable for its main purpose.
The same dependency flaked the Channel E2E gate: 3 of the last 40 Docker builds
failed waiting for .monaco-editor, every one of them a jsdelivr timeout on a
change that had nothing to do with the editor.

scripts/copy-monaco.mjs stages node_modules/monaco-editor/min/vs into
public/monaco/vs, and src/lib/editor/monaco-loader.ts points the AMD loader at
that path. monaco-editor was already a direct dependency, so the assets were on
disk at build time all along - they were simply never served.

Wired into the dev and build scripts, and explicitly into the Dockerfile, which
calls next build directly and therefore inherits nothing from package.json. That
one is what feeds the flaky gate. The tarballs, .deb/.rpm, snap, AppImage,
Flatpak and npx payloads all derive from bun run build, so they inherit it.

NEXT_PUBLIC_MONACO_VS_PATH overrides the path for deployments that serve the
bundle elsewhere. libredb-platform needs it or its own copy step: the npm package
ships only dist and bin, so an embedded studio resolves /monaco/vs against
platform's origin. Recorded in the platform-integration rule, since nothing in
CI can catch that from this repo.

ESLint and oxlint now skip public/monaco: 16 MB of minified vendor JS in the
working tree exhausts ESLint's heap outright, and because the directory only
exists after something has built, a clean lint before the first build says
nothing about the next one. Biome needs no change - its includes are an allowlist
of first-party trees.

e2e/offline-editor.spec.ts is the regression guard the existing specs could not
be: they pass because the CDN is reachable, which is the wrong thing to assert
for a self-hosted product. It blocks every off-origin request, then asserts the
editor renders, a query against the SQLite sample returns its rows, and that
nothing off-origin was attempted at all.

Verified against both a production build and bun dev, and confirmed the spec
fails without the loader configuration - zero .monaco-editor elements, the exact
symptom from the issue. Two inert cdn.jsdelivr.net strings do survive in
.next/static (the loader library's own default config literal and snapdom's
font-host allowlist); no request is made to either, which the offline spec pins.
@sonarqubecloud

Copy link
Copy Markdown

@cevheri
cevheri merged commit 9fc630c into main Jul 30, 2026
18 checks passed
@cevheri
cevheri deleted the feat/monaco-selfhost-247 branch July 30, 2026 23:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Self-host Monaco so the app works fully air-gapped (and stop the E2E flaking on jsdelivr)

1 participant