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: 14 additions & 0 deletions .claude/rules/platform-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,20 @@ Platform's `globals.css` must scan ALL studio dist files (tsup creates chunks):
```
Without chunk scanning, responsive/utility classes in chunked components won't generate CSS.

**Monaco assets are NOT shipped either** (same `files` restriction). Standalone studio stages
`node_modules/monaco-editor/min/vs` into `public/monaco/vs` at build time (`scripts/copy-monaco.mjs`,
issue #247) and points the loader at `/monaco/vs`; embedded studio resolves that path against
*platform's* origin, so platform must serve it too:

```bash
# platform build step — monaco-editor arrives transitively with @libredb/studio
cp -r node_modules/monaco-editor/min/vs public/monaco/vs
```

Serving them from a different path instead is fine — set `NEXT_PUBLIC_MONACO_VS_PATH` in platform.
Skipping both leaves the editor pane a spinner that never resolves (404s on `/monaco/vs/loader.js`),
in embedded mode only.

**Studio's `globals.css` is NOT shipped** (`package.json` `files` is `["dist","bin"]`), so anything
expressed as a global CSS rule instead of a utility class exists in standalone studio only. Platform
must replicate those rules in its own `globals.css`. Currently that means the cursor-affordance block
Expand Down
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -233,3 +233,11 @@ LLM_MODEL=gemini-2.5-flash
# SEED_CACHE_TTL_MS=60000 # Cache TTL in ms (default: 60s)
# Credential env vars referenced in seed config (e.g., ${MY_DB_PASSWORD}):
# MY_DB_PASSWORD=secret

# ─── Monaco Editor Assets ────────────────────────────────────────────────────
# The SQL editor is served from this origin: `bun run build` stages the Monaco AMD
# bundle from node_modules into public/monaco/vs, so no CDN is contacted at runtime
# and the app works air-gapped. Override only when the assets live elsewhere —
# a sub-path mount, a CDN of your own, or libredb-platform embedding the npm
# package (see .claude/rules/platform-integration.md).
# NEXT_PUBLIC_MONACO_VS_PATH=/monaco/vs
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
/out/
.next

# Monaco AMD bundle staged from node_modules by scripts/copy-monaco.mjs (issue #247)
/public/monaco/

# production
/build
dist
Expand Down
2 changes: 1 addition & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,5 @@
}
}
],
"ignorePatterns": ["dist/**", ".next/**", "out/**", "build/**", "coverage/**", "node_modules/**", "snap-payload/**", "desktop/src-tauri/payload/**", "desktop/src-tauri/target/**", "desktop/src-tauri/bin/**", ".claude/**", "next-env.d.ts"]
"ignorePatterns": ["dist/**", ".next/**", "public/monaco/**", "out/**", "build/**", "coverage/**", "node_modules/**", "snap-payload/**", "desktop/src-tauri/payload/**", "desktop/src-tauri/target/**", "desktop/src-tauri/bin/**", ".claude/**", "next-env.d.ts"]
}
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ ENV JWT_SECRET=$JWT_SECRET_BUILD
ENV ADMIN_PASSWORD=$ADMIN_PASSWORD_BUILD
ENV USER_PASSWORD=$USER_PASSWORD_BUILD

RUN npx next build
# Stage Monaco from node_modules into public/ so the editor is served from our own
# origin (issue #247). Explicit here: this bypasses the package.json build script.
RUN node scripts/copy-monaco.mjs && npx next build

# Production image - use Node.js slim for lower memory footprint
# trixie-slim: glibc must match the stage where native modules were built (see builder).
Expand Down
13 changes: 13 additions & 0 deletions docs/editor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,24 @@ execution pipeline behind it.
| [SQL Alias Completion](sql-alias-completion.md) | Context-aware autocompletion with table-alias resolution (`FROM`/`JOIN`/CTE), the completion provider, and the alias extractor |
| [Query Optimization](query-optimization.md) | Query pagination, silent auto-limiting, Load More, background `EXPLAIN`, and performance insights |

## Asset loading

Monaco is served from studio's own origin, never a CDN — `bun run dev` / `bun run build` stage
`node_modules/monaco-editor/min/vs` into `public/monaco/vs` (`scripts/copy-monaco.mjs`) and
`src/lib/editor/monaco-loader.ts` points `@monaco-editor/react`'s AMD loader at that path,
replacing its `cdn.jsdelivr.net` default. The workspace therefore issues zero off-origin
requests and works air-gapped (`e2e/offline-editor.spec.ts` asserts exactly that).

Deployments that serve the bundle elsewhere — a sub-path mount, or platform embedding the npm
package, which does not ship `public/` — set `NEXT_PUBLIC_MONACO_VS_PATH`.

## Source map

| Area | Source |
|------|--------|
| Editor component | `src/components/QueryEditor.tsx` |
| Monaco loader config | `src/lib/editor/monaco-loader.ts` |
| Asset staging | `scripts/copy-monaco.mjs` |
| Completion provider | `src/lib/editor/sql-completions.ts` |
| Alias extraction | `src/lib/sql/alias-extractor.ts` |
| Query limiting | `src/lib/db/utils/query-limiter.ts` |
Expand Down
68 changes: 68 additions & 0 deletions e2e/offline-editor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Air-gapped workspace (issue #247): with every off-origin request blocked, the SQL
* editor must still render and run a query.
*
* Before Monaco was self-hosted, @monaco-editor/react fetched it from cdn.jsdelivr.net:
* the editor pane stayed a spinner forever on an isolated network, while the rest of the
* app looked healthy — and the Channel E2E gate flaked whenever the runner could not
* reach the CDN. The existing specs pass *because* the CDN is reachable, which is the
* wrong thing to assert for a self-hosted product, so this one asserts the opposite:
* nothing the workspace needs comes from another host.
*/
import { expect, test, type Page } from "@playwright/test";

async function loginAsUser(page: Page): Promise<void> {
await page.goto("/login");
await page.locator('input[type="email"]').fill("user@libredb.org");
await page.locator('input[type="password"]').fill("test-user");
await page.getByRole("button", { name: "Sign In" }).click();
await page.waitForURL("/");
}

test.describe("Workspace with all off-origin requests blocked", () => {
// Login (15s) + async SQLite seed (45s) + monaco (20s) + grid (20s) exceeds the default budget.
test.describe.configure({ timeout: 120_000 });

test("renders the editor and runs a query without reaching any external host", async ({ page, baseURL }) => {
const appOrigin = new URL(baseURL as string).origin;
const blocked: string[] = [];

await page.route("**/*", async (route) => {
const url = route.request().url();
if (url.startsWith(appOrigin) || url.startsWith("data:") || url.startsWith("blob:")) {
return route.continue();
}
blocked.push(url);
return route.abort();
});

await loginAsUser(page);

// The editor itself: this is what the CDN dependency used to break.
await expect(page.locator(".monaco-editor").first()).toBeVisible({ timeout: 20_000 });

const sample = page.locator("text=Sample (Employees)").first();
await expect(sample).toBeVisible({ timeout: 45_000 });
await sample.click();

await page.waitForFunction(
() =>
((window as unknown as { monaco?: { editor: { getEditors(): unknown[] } } }).monaco?.editor.getEditors()
.length ?? 0) > 0,
);
await page.evaluate(() => {
const monaco = (window as unknown as { monaco?: { editor: { getEditors(): { setValue(v: string): void }[] } } })
.monaco;
if (!monaco) throw new Error("monaco global not found");
monaco.editor.getEditors()[0].setValue("SELECT COUNT(*) AS employee_count FROM employee");
});
await page.getByRole("button", { name: "RUN" }).click();

await expect(page.locator("text=employee_count").first()).toBeVisible({ timeout: 20_000 });
await expect(page.locator("text=1000").first()).toBeVisible({ timeout: 5_000 });

// Zero off-origin traffic: anything here is a new external dependency that would
// break air-gapped installs, whether or not this run had internet access.
expect(blocked).toEqual([]);
});
});
6 changes: 5 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@ const eslintConfig = defineConfig([
// desktop AppImage build (scripts/build-desktop-appimage.sh stages the whole
// standalone payload, node_modules included, in there);
// .claude/ holds agent worktrees whose checkouts (and .next build output)
// must not be linted from this checkout
// must not be linted from this checkout;
// public/monaco/** is the Monaco AMD bundle staged from node_modules by
// scripts/copy-monaco.mjs (issue #247) — 16 MB of minified vendor JS that
// exhausts ESLint's heap, and it only appears once something has built
globalIgnores([
".next/**",
"public/monaco/**",
"out/**",
"build/**",
"dist/**",
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@
},
"packageManager": "bun@1.3.14",
"scripts": {
"dev": "next dev",
"build": "next build",
"dev": "node scripts/copy-monaco.mjs && next dev",
"build": "node scripts/copy-monaco.mjs && next build",
"build:lib": "tsup",
"attw": "rm -rf .attw && bun pm pack --quiet --destination .attw && attw .attw/*.tgz --profile node16",
"prepublishOnly": "tsup && bun run attw",
Expand Down
64 changes: 64 additions & 0 deletions scripts/copy-monaco.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* Monaco asset staging (issue #247).
*
* `@monaco-editor/react` fetches Monaco from cdn.jsdelivr.net unless its loader is
* pointed elsewhere, which leaves the SQL editor broken on air-gapped installs and
* makes CI depend on a third-party CDN. This step copies the AMD bundle that ships
* with the `monaco-editor` dependency into `public/monaco/vs`, so Next serves it from
* our own origin; `src/lib/editor/monaco-loader.ts` is the matching client-side half.
*
* Wired into the `dev` and `build` scripts in package.json, and into the Dockerfile,
* which calls `next build` directly and so does not inherit the npm script. Every other
* artifact (standalone tarballs, .deb/.rpm, snap, AppImage, Flatpak, npx) is derived from
* `bun run build`, so they inherit it. Unit tested in tests/unit/copy-monaco.test.ts.
*/

import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const SOURCE_SUBPATH = path.join("node_modules", "monaco-editor", "min", "vs");
const TARGET_SUBPATH = path.join("public", "monaco", "vs");

/**
* Copies the Monaco AMD bundle into the served `public/` tree.
*
* @param {string} root Repository root to stage within.
* @returns {{ source: string, target: string, version: string }}
*/
export function stageMonacoAssets(root) {
const source = path.join(root, SOURCE_SUBPATH);
const target = path.join(root, TARGET_SUBPATH);
const manifest = path.join(root, "node_modules", "monaco-editor", "package.json");

if (!fs.existsSync(source)) {
throw new Error(
`Cannot stage the editor: ${SOURCE_SUBPATH} is missing. Install dependencies (bun install) so the monaco-editor package is on disk.`,
);
}

// Remove first: a plain overwrite would leave files behind that a newer
// monaco-editor no longer ships, and stale AMD chunks break the loader.
fs.rmSync(target, { recursive: true, force: true });
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.cpSync(source, target, { recursive: true });

const { version } = JSON.parse(fs.readFileSync(manifest, "utf8"));
return { source, target, version };
}

function main() {
try {
const { target, version } = stageMonacoAssets(process.cwd());
console.log(`Staged monaco-editor ${version} into ${path.relative(process.cwd(), target)}`);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}

// CLI entry only when executed directly (the unit test imports this module).
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main();
}
5 changes: 5 additions & 0 deletions src/components/QueryEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,13 @@
import type { SchemaCompletionCache, SchemaColumnItem } from "@/lib/editor/sql-completions";
import { registerMongoDBCompletionProvider } from "@/lib/editor/mongodb-completions";
import { registerLibreDBLanguage } from "@/lib/editor/libredb-language";
import { configureMonacoLoader } from "@/lib/editor/monaco-loader";
import { useAiChat } from "@/hooks/use-ai-chat";

// Serve Monaco from our own origin rather than @monaco-editor/react's jsdelivr default.
// Runs at module load so it is in place before the first <Editor> mounts.
configureMonacoLoader();

export interface QueryEditorRef {
getSelectedText: () => string;
getEffectiveQuery: () => string;
Expand Down Expand Up @@ -99,7 +104,7 @@
onContentChange,
onExplain,
language = "sql",
tables = [],

Check warning on line 107 in src/components/QueryEditor.tsx

View workflow job for this annotation

GitHub Actions / Build and Push Docker Image

react(no-object-type-as-default-prop)

Do not use an array literal as default prop value. Use a stable reference instead.

Check warning on line 107 in src/components/QueryEditor.tsx

View workflow job for this annotation

GitHub Actions / Lint, Typecheck and Build

react(no-object-type-as-default-prop)

Do not use an array literal as default prop value. Use a stable reference instead.
databaseType,
schemaContext,
capabilities,
Expand Down
31 changes: 31 additions & 0 deletions src/lib/editor/monaco-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { loader } from "@monaco-editor/react";

/**
* Where the self-hosted Monaco AMD bundle is served from.
*
* `@monaco-editor/react` defaults to `https://cdn.jsdelivr.net/npm/monaco-editor@<v>/min/vs`,
* which leaves the editor broken on air-gapped installs and makes CI depend on a third-party
* CDN. `scripts/copy-monaco.mjs` stages `node_modules/monaco-editor/min/vs` into
* `public/monaco/vs` at build time, so this path is served from our own origin.
*/
export const DEFAULT_MONACO_VS_PATH = "/monaco/vs";

interface MonacoLoaderLike {
config: (config: { paths?: { vs?: string } }) => void;
}

/**
* Resolves the base path Monaco's AMD loader should fetch from. Deployments that serve the
* assets elsewhere (a sub-path mount, or platform embedding the npm package) set
* `NEXT_PUBLIC_MONACO_VS_PATH`.
*/
export function resolveMonacoVsPath(override: string | undefined): string {
const trimmed = override?.trim();
if (!trimmed) return DEFAULT_MONACO_VS_PATH;
return trimmed.replace(/\/+$/, "");

Check warning on line 25 in src/lib/editor/monaco-loader.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Simplify this regular expression to reduce its runtime, as it has super-linear performance due to backtracking.

See more on https://sonarcloud.io/project/issues?id=libredb_libredb-studio&issues=AZ-0bHpW20ESPB3h2wCE&open=AZ-0bHpW20ESPB3h2wCE&pullRequest=251
}

/** Points Monaco's AMD loader at our own origin. Must run before the first `<Editor>` mounts. */
export function configureMonacoLoader(monacoLoader: MonacoLoaderLike = loader): void {
monacoLoader.config({ paths: { vs: resolveMonacoVsPath(process.env.NEXT_PUBLIC_MONACO_VS_PATH) } });
}
36 changes: 36 additions & 0 deletions tests/isolated/monaco-loader-wiring.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Isolated: verifies that importing QueryEditor points Monaco's AMD loader at our own
* origin. Must own its process — it mocks @monaco-editor/react and pulls the whole
* QueryEditor module chain, and the assertion depends on observing the loader call made
* at module-evaluation time (hence the dynamic import after the mock is registered).
*
* Without this, @monaco-editor/react silently fetches Monaco from cdn.jsdelivr.net:
* no editor on air-gapped installs, and CI flakes whenever the CDN is unreachable.
*/
import { expect, mock, test } from "bun:test";

const configCalls: Array<{ paths?: { vs?: string } }> = [];

mock.module("@monaco-editor/react", () => ({
default: () => null,
loader: {
init: () => Promise.resolve(),
config: (config: { paths?: { vs?: string } }) => {
configCalls.push(config);
},
},
useMonaco: () => null,
}));

// Imported after the mock so the module-scope loader configuration is observable.
await import("@/components/QueryEditor");

test("importing QueryEditor configures Monaco to load from our own origin", () => {
expect(configCalls).toEqual([{ paths: { vs: "/monaco/vs" } }]);
});

test("no configured Monaco path points at an external host", () => {
for (const call of configCalls) {
expect(call.paths?.vs?.startsWith("/")).toBe(true);
}
});
10 changes: 9 additions & 1 deletion tests/run-components.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ set -e

PASS=0
FAIL=0
TOTAL_GROUPS=20
TOTAL_GROUPS=21
EXTRA_BUN_ARGS=("$@")
GROUP_INDEX=0
COVERAGE_MODE=0
Expand Down Expand Up @@ -92,6 +92,14 @@ run_group "Group 0b: Factory singleton" \
run_group "Group 0c: Exports shim" --nocov \
tests/isolated/exports-shim.test.ts

# Group 0d: Monaco loader wiring (isolated — mocks @monaco-editor/react and must observe
# the loader call QueryEditor makes at module-evaluation time, so it dynamic-imports the
# component after the mock is registered).
# --nocov: same load-only concern as Group 0c — importing QueryEditor pulls its whole
# module chain without rendering it.
run_group "Group 0d: Monaco loader wiring" --nocov \
tests/isolated/monaco-loader-wiring.test.ts

# Group 1: Studio (isolated — mocks almost every child component)
run_group "Group 1/6: Studio" \
tests/components/Studio.test.tsx
Expand Down
Loading
Loading