From d7ccb0a0cc4d52fd8f105c34282704c57fedc931 Mon Sep 17 00:00:00 2001 From: Alex Mabe Date: Wed, 12 Aug 2026 07:42:14 -0400 Subject: [PATCH] perf(app): deduplicate and budget browser bundles --- .github/workflows/ci.yml | 2 +- app/README.md | 37 +- app/bundle-budgets.json | 12 + app/package.json | 8 +- app/public/wasm/flowscope_wasm.d.ts | 148 ----- app/public/wasm/flowscope_wasm.js | 608 ------------------ app/public/wasm/flowscope_wasm_bg.wasm.d.ts | 25 - app/scripts/check-bundle-budget.mjs | 178 +++++ app/scripts/check-bundle-budget.test.mjs | 125 ++++ app/scripts/remove-legacy-wasm.mjs | 14 + app/src/App.tsx | 2 +- app/src/components/AnalysisView.tsx | 74 ++- app/src/components/EditorArea.tsx | 9 +- app/src/components/ExportDialog.tsx | 29 +- app/src/components/HierarchyView.tsx | 10 +- app/src/components/NamespaceFilterBar.tsx | 2 +- app/src/components/SchemaAwareIssuesPanel.tsx | 2 +- app/src/components/SchemaEditor.tsx | 2 +- app/src/components/Workspace.tsx | 44 +- .../__tests__/librarian-panel.test.tsx | 2 +- .../__tests__/use-librarian-chat.test.ts | 2 +- .../librarian/components/librarian-panel.tsx | 2 +- .../librarian/hooks/use-librarian-chat.ts | 2 +- app/src/hooks/__tests__/useAnalysis.test.tsx | 2 +- app/src/hooks/useAnalysis.ts | 2 +- app/src/hooks/useDebugData.ts | 2 +- app/src/hooks/useFileNavigation.ts | 2 +- app/src/hooks/useShareImport.ts | 50 +- app/test.js | 61 -- app/tsconfig.json | 3 +- app/vite.config.ts | 5 + app/vitest.config.ts | 3 + packages/core/README.md | 11 +- packages/react/src/utils/layout.ts | 17 +- scripts/build-rust.sh | 31 +- 35 files changed, 556 insertions(+), 972 deletions(-) create mode 100644 app/bundle-budgets.json delete mode 100644 app/public/wasm/flowscope_wasm.d.ts delete mode 100644 app/public/wasm/flowscope_wasm.js delete mode 100644 app/public/wasm/flowscope_wasm_bg.wasm.d.ts create mode 100644 app/scripts/check-bundle-budget.mjs create mode 100644 app/scripts/check-bundle-budget.test.mjs create mode 100644 app/scripts/remove-legacy-wasm.mjs delete mode 100644 app/test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 873d0dc2..b7bdbecf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -206,7 +206,7 @@ jobs: yarn test:ts npm --prefix vscode run test - - name: Build demo app + - name: Build demo app and enforce bundle budgets run: yarn workspace @pondpilot/flowscope-app build schema-compat: diff --git a/app/README.md b/app/README.md index d6382964..8e82145e 100644 --- a/app/README.md +++ b/app/README.md @@ -36,7 +36,42 @@ This will start the development server at `http://localhost:5173`. ### Architecture -The application loads the WASM module generated from `@crates/flowscope-wasm`. +The browser WASM artifact has one canonical home: `packages/core/wasm/`. The +`@pondpilot/flowscope-core` loader dynamically imports the generated glue, and +Vite emits the referenced `.wasm` file as a single hashed asset for both the dev +server and production build. Do not copy WASM into `app/public`; public assets +are copied verbatim and would duplicate the package-owned bytes. CLI serve mode +embeds the same `app/dist` output, so it follows this loading path without a +second WASM copy. The VS Code extension intentionally uses its separate +Node-target build under `vscode/wasm-node/`. + +The default editor, Dagre graph, and analysis worker are startup features. +Non-default analysis tabs, ELK layout, Librarian, share dialogs, PNG export, +PDF parsing, and local embeddings load on first use. Keep optional heavyweight +features behind an interaction-driven dynamic import rather than adding them to +the startup graph. + +Production builds run `yarn check:bundle` automatically. The checker reads the +Vite manifest so only the entry and its static imports count toward startup; +dynamic feature chunks remain subject to the per-chunk and total budgets. +Thresholds live in `bundle-budgets.json`: + +| Budget | Threshold | +| -------------------------- | -------------------------------: | +| Emitted WASM | exactly 1 file, at most 9 MiB | +| Entry/startup JavaScript | at most 3 MiB raw, 600 KiB gzip | +| Startup CSS | at most 128 KiB raw, 24 KiB gzip | +| Any async JavaScript chunk | at most 2.25 MiB | +| All JavaScript | at most 8 MiB | +| Entire `dist` | at most 18 MiB | + +Run the checker and its focused tests with: + +```bash +yarn check:bundle +yarn test:bundle-budget +``` + - **State Management:** Zustand - **UI Components:** React Flow (graph), CodeMirror (editor), Tailwind CSS diff --git a/app/bundle-budgets.json b/app/bundle-budgets.json new file mode 100644 index 00000000..c8723b33 --- /dev/null +++ b/app/bundle-budgets.json @@ -0,0 +1,12 @@ +{ + "wasmFileCount": 1, + "maxWasmBytes": 9437184, + "maxEntryJsBytes": 3145728, + "maxStartupJsBytes": 3145728, + "maxStartupJsGzipBytes": 614400, + "maxStartupCssBytes": 131072, + "maxStartupCssGzipBytes": 24576, + "maxAsyncJsChunkBytes": 2359296, + "maxTotalJsBytes": 8388608, + "maxTotalDistBytes": 18874368 +} diff --git a/app/package.json b/app/package.json index 2b3af50b..6bf06c96 100644 --- a/app/package.json +++ b/app/package.json @@ -5,13 +5,17 @@ "type": "module", "description": "FlowScope web application for SQL lineage visualization", "scripts": { + "predev": "node scripts/remove-legacy-wasm.mjs", "dev": "vite", - "build": "tsc && vite build", + "prebuild": "node scripts/remove-legacy-wasm.mjs", + "build": "tsc && vite build && yarn check:bundle", + "check:bundle": "node scripts/check-bundle-budget.mjs", "preview": "vite preview", "typecheck": "tsc --noEmit", "lint": "eslint src", "lint:fix": "eslint src --fix", - "test": "vitest run", + "test": "vitest run && yarn test:bundle-budget", + "test:bundle-budget": "node --test scripts/check-bundle-budget.test.mjs", "test:coverage": "vitest run --coverage", "test:watch": "vitest" }, diff --git a/app/public/wasm/flowscope_wasm.d.ts b/app/public/wasm/flowscope_wasm.d.ts deleted file mode 100644 index 5938cddc..00000000 --- a/app/public/wasm/flowscope_wasm.d.ts +++ /dev/null @@ -1,148 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ - -/** - * Analyze SQL and export to DuckDB SQL statements in one step. - * - * Convenience function that combines analyze_sql_json + export_to_duckdb_sql. - * Takes a JSON AnalyzeRequest and returns SQL statements for duckdb-wasm. - * - * Note: This function does not support the schema parameter. Use - * analyze_sql_json + export_to_duckdb_sql separately for schema support. - */ -export function analyze_and_export_sql(request_json: string): string; - -/** - * Legacy simple API - accepts SQL string, returns JSON with table names - * Kept for backwards compatibility - */ -export function analyze_sql(sql_input: string): string; - -/** - * Main analysis entry point - accepts JSON request, returns JSON result - * This function never throws - errors are returned in the result's issues array - * - * Supports optional `encoding` field in request: - * - `"utf8"` (default): All span offsets are UTF-8 byte offsets - * - `"utf16"`: All span offsets are converted to UTF-16 code units - */ -export function analyze_sql_json(request_json: string): string; - -/** - * Compute completion context for a cursor position. - * Returns JSON-serialized CompletionContext. - * - * Supports optional `encoding` field in request: - * - `"utf8"` (default): cursor_offset is UTF-8 bytes, spans are UTF-8 bytes - * - `"utf16"`: cursor_offset is UTF-16 code units, spans are UTF-16 code units - */ -export function completion_context_json(request_json: string): string; - -/** - * Compute ranked completion items for a cursor position. - * - * Supports optional `encoding` field in request: - * - `"utf8"` (default): cursor_offset is UTF-8 bytes, spans are UTF-8 bytes - * - `"utf16"`: cursor_offset is UTF-16 code units, spans are UTF-16 code units - */ -export function completion_items_json(request_json: string): string; - -/** - * Enable tracing logs to the browser console (requires `tracing` feature). - */ -export function enable_tracing(): void; - -export function export_csv_bundle(request_json: string): Uint8Array; - -export function export_filename(request_json: string): string; - -export function export_html(request_json: string): string; - -export function export_json(request_json: string): string; - -export function export_mermaid(request_json: string): string; - -/** - * Export analysis result to SQL statements for DuckDB-WASM. - * - * Takes a JSON object with: - * - `result`: The AnalyzeResult to export - * - `schema` (optional): Schema name to prefix all tables/views (e.g., "lineage") - * - * Returns SQL statements (DDL + INSERT) that can be executed by duckdb-wasm. - * - * This is the WASM-compatible export path - generates SQL text that - * duckdb-wasm can execute to create a queryable database in the browser. - */ -export function export_to_duckdb_sql(request_json: string): string; - -export function export_xlsx(request_json: string): Uint8Array; - -/** - * Get version information - */ -export function get_version(): string; - -/** - * Install panic hook for better error messages in browser console - */ -export function set_panic_hook(): void; - -/** - * Split SQL into statement spans. - * - * Supports optional `encoding` field in request: - * - `"utf8"` (default): All span offsets are UTF-8 byte offsets - * - `"utf16"`: All span offsets are converted to UTF-16 code units - */ -export function split_statements_json(request_json: string): string; - -export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; - -export interface InitOutput { - readonly memory: WebAssembly.Memory; - readonly analyze_and_export_sql: (a: number, b: number) => [number, number, number, number]; - readonly analyze_sql: (a: number, b: number) => [number, number, number, number]; - readonly analyze_sql_json: (a: number, b: number) => [number, number]; - readonly completion_context_json: (a: number, b: number) => [number, number]; - readonly completion_items_json: (a: number, b: number) => [number, number]; - readonly enable_tracing: () => void; - readonly export_csv_bundle: (a: number, b: number) => [number, number, number, number]; - readonly export_filename: (a: number, b: number) => [number, number, number, number]; - readonly export_html: (a: number, b: number) => [number, number, number, number]; - readonly export_json: (a: number, b: number) => [number, number, number, number]; - readonly export_mermaid: (a: number, b: number) => [number, number, number, number]; - readonly export_to_duckdb_sql: (a: number, b: number) => [number, number, number, number]; - readonly export_xlsx: (a: number, b: number) => [number, number, number, number]; - readonly get_version: () => [number, number]; - readonly split_statements_json: (a: number, b: number) => [number, number]; - readonly set_panic_hook: () => void; - readonly __wbindgen_free: (a: number, b: number, c: number) => void; - readonly __wbindgen_malloc: (a: number, b: number) => number; - readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; - readonly __wbindgen_externrefs: WebAssembly.Table; - readonly __externref_table_dealloc: (a: number) => void; - readonly __wbindgen_start: () => void; -} - -export type SyncInitInput = BufferSource | WebAssembly.Module; - -/** - * Instantiates the given `module`, which can either be bytes or - * a precompiled `WebAssembly.Module`. - * - * @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated. - * - * @returns {InitOutput} - */ -export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput; - -/** - * If `module_or_path` is {RequestInfo} or {URL}, makes a request and - * for everything else, calls `WebAssembly.instantiate` directly. - * - * @param {{ module_or_path: InitInput | Promise }} module_or_path - Passing `InitInput` directly is deprecated. - * - * @returns {Promise} - */ -export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise } | InitInput | Promise): Promise; diff --git a/app/public/wasm/flowscope_wasm.js b/app/public/wasm/flowscope_wasm.js deleted file mode 100644 index cdc57332..00000000 --- a/app/public/wasm/flowscope_wasm.js +++ /dev/null @@ -1,608 +0,0 @@ -/* @ts-self-types="./flowscope_wasm.d.ts" */ - -/** - * Analyze SQL and export to DuckDB SQL statements in one step. - * - * Convenience function that combines analyze_sql_json + export_to_duckdb_sql. - * Takes a JSON AnalyzeRequest and returns SQL statements for duckdb-wasm. - * - * Note: This function does not support the schema parameter. Use - * analyze_sql_json + export_to_duckdb_sql separately for schema support. - * @param {string} request_json - * @returns {string} - */ -export function analyze_and_export_sql(request_json) { - let deferred3_0; - let deferred3_1; - try { - const ptr0 = passStringToWasm0(request_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len0 = WASM_VECTOR_LEN; - const ret = wasm.analyze_and_export_sql(ptr0, len0); - var ptr2 = ret[0]; - var len2 = ret[1]; - if (ret[3]) { - ptr2 = 0; len2 = 0; - throw takeFromExternrefTable0(ret[2]); - } - deferred3_0 = ptr2; - deferred3_1 = len2; - return getStringFromWasm0(ptr2, len2); - } finally { - wasm.__wbindgen_free(deferred3_0, deferred3_1, 1); - } -} - -/** - * Legacy simple API - accepts SQL string, returns JSON with table names - * Kept for backwards compatibility - * @param {string} sql_input - * @returns {string} - */ -export function analyze_sql(sql_input) { - let deferred3_0; - let deferred3_1; - try { - const ptr0 = passStringToWasm0(sql_input, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len0 = WASM_VECTOR_LEN; - const ret = wasm.analyze_sql(ptr0, len0); - var ptr2 = ret[0]; - var len2 = ret[1]; - if (ret[3]) { - ptr2 = 0; len2 = 0; - throw takeFromExternrefTable0(ret[2]); - } - deferred3_0 = ptr2; - deferred3_1 = len2; - return getStringFromWasm0(ptr2, len2); - } finally { - wasm.__wbindgen_free(deferred3_0, deferred3_1, 1); - } -} - -/** - * Main analysis entry point - accepts JSON request, returns JSON result - * This function never throws - errors are returned in the result's issues array - * - * Supports optional `encoding` field in request: - * - `"utf8"` (default): All span offsets are UTF-8 byte offsets - * - `"utf16"`: All span offsets are converted to UTF-16 code units - * @param {string} request_json - * @returns {string} - */ -export function analyze_sql_json(request_json) { - let deferred2_0; - let deferred2_1; - try { - const ptr0 = passStringToWasm0(request_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len0 = WASM_VECTOR_LEN; - const ret = wasm.analyze_sql_json(ptr0, len0); - deferred2_0 = ret[0]; - deferred2_1 = ret[1]; - return getStringFromWasm0(ret[0], ret[1]); - } finally { - wasm.__wbindgen_free(deferred2_0, deferred2_1, 1); - } -} - -/** - * Compute completion context for a cursor position. - * Returns JSON-serialized CompletionContext. - * - * Supports optional `encoding` field in request: - * - `"utf8"` (default): cursor_offset is UTF-8 bytes, spans are UTF-8 bytes - * - `"utf16"`: cursor_offset is UTF-16 code units, spans are UTF-16 code units - * @param {string} request_json - * @returns {string} - */ -export function completion_context_json(request_json) { - let deferred2_0; - let deferred2_1; - try { - const ptr0 = passStringToWasm0(request_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len0 = WASM_VECTOR_LEN; - const ret = wasm.completion_context_json(ptr0, len0); - deferred2_0 = ret[0]; - deferred2_1 = ret[1]; - return getStringFromWasm0(ret[0], ret[1]); - } finally { - wasm.__wbindgen_free(deferred2_0, deferred2_1, 1); - } -} - -/** - * Compute ranked completion items for a cursor position. - * - * Supports optional `encoding` field in request: - * - `"utf8"` (default): cursor_offset is UTF-8 bytes, spans are UTF-8 bytes - * - `"utf16"`: cursor_offset is UTF-16 code units, spans are UTF-16 code units - * @param {string} request_json - * @returns {string} - */ -export function completion_items_json(request_json) { - let deferred2_0; - let deferred2_1; - try { - const ptr0 = passStringToWasm0(request_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len0 = WASM_VECTOR_LEN; - const ret = wasm.completion_items_json(ptr0, len0); - deferred2_0 = ret[0]; - deferred2_1 = ret[1]; - return getStringFromWasm0(ret[0], ret[1]); - } finally { - wasm.__wbindgen_free(deferred2_0, deferred2_1, 1); - } -} - -/** - * Enable tracing logs to the browser console (requires `tracing` feature). - */ -export function enable_tracing() { - wasm.enable_tracing(); -} - -/** - * @param {string} request_json - * @returns {Uint8Array} - */ -export function export_csv_bundle(request_json) { - const ptr0 = passStringToWasm0(request_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len0 = WASM_VECTOR_LEN; - const ret = wasm.export_csv_bundle(ptr0, len0); - if (ret[3]) { - throw takeFromExternrefTable0(ret[2]); - } - var v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice(); - wasm.__wbindgen_free(ret[0], ret[1] * 1, 1); - return v2; -} - -/** - * @param {string} request_json - * @returns {string} - */ -export function export_filename(request_json) { - let deferred3_0; - let deferred3_1; - try { - const ptr0 = passStringToWasm0(request_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len0 = WASM_VECTOR_LEN; - const ret = wasm.export_filename(ptr0, len0); - var ptr2 = ret[0]; - var len2 = ret[1]; - if (ret[3]) { - ptr2 = 0; len2 = 0; - throw takeFromExternrefTable0(ret[2]); - } - deferred3_0 = ptr2; - deferred3_1 = len2; - return getStringFromWasm0(ptr2, len2); - } finally { - wasm.__wbindgen_free(deferred3_0, deferred3_1, 1); - } -} - -/** - * @param {string} request_json - * @returns {string} - */ -export function export_html(request_json) { - let deferred3_0; - let deferred3_1; - try { - const ptr0 = passStringToWasm0(request_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len0 = WASM_VECTOR_LEN; - const ret = wasm.export_html(ptr0, len0); - var ptr2 = ret[0]; - var len2 = ret[1]; - if (ret[3]) { - ptr2 = 0; len2 = 0; - throw takeFromExternrefTable0(ret[2]); - } - deferred3_0 = ptr2; - deferred3_1 = len2; - return getStringFromWasm0(ptr2, len2); - } finally { - wasm.__wbindgen_free(deferred3_0, deferred3_1, 1); - } -} - -/** - * @param {string} request_json - * @returns {string} - */ -export function export_json(request_json) { - let deferred3_0; - let deferred3_1; - try { - const ptr0 = passStringToWasm0(request_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len0 = WASM_VECTOR_LEN; - const ret = wasm.export_json(ptr0, len0); - var ptr2 = ret[0]; - var len2 = ret[1]; - if (ret[3]) { - ptr2 = 0; len2 = 0; - throw takeFromExternrefTable0(ret[2]); - } - deferred3_0 = ptr2; - deferred3_1 = len2; - return getStringFromWasm0(ptr2, len2); - } finally { - wasm.__wbindgen_free(deferred3_0, deferred3_1, 1); - } -} - -/** - * @param {string} request_json - * @returns {string} - */ -export function export_mermaid(request_json) { - let deferred3_0; - let deferred3_1; - try { - const ptr0 = passStringToWasm0(request_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len0 = WASM_VECTOR_LEN; - const ret = wasm.export_mermaid(ptr0, len0); - var ptr2 = ret[0]; - var len2 = ret[1]; - if (ret[3]) { - ptr2 = 0; len2 = 0; - throw takeFromExternrefTable0(ret[2]); - } - deferred3_0 = ptr2; - deferred3_1 = len2; - return getStringFromWasm0(ptr2, len2); - } finally { - wasm.__wbindgen_free(deferred3_0, deferred3_1, 1); - } -} - -/** - * Export analysis result to SQL statements for DuckDB-WASM. - * - * Takes a JSON object with: - * - `result`: The AnalyzeResult to export - * - `schema` (optional): Schema name to prefix all tables/views (e.g., "lineage") - * - * Returns SQL statements (DDL + INSERT) that can be executed by duckdb-wasm. - * - * This is the WASM-compatible export path - generates SQL text that - * duckdb-wasm can execute to create a queryable database in the browser. - * @param {string} request_json - * @returns {string} - */ -export function export_to_duckdb_sql(request_json) { - let deferred3_0; - let deferred3_1; - try { - const ptr0 = passStringToWasm0(request_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len0 = WASM_VECTOR_LEN; - const ret = wasm.export_to_duckdb_sql(ptr0, len0); - var ptr2 = ret[0]; - var len2 = ret[1]; - if (ret[3]) { - ptr2 = 0; len2 = 0; - throw takeFromExternrefTable0(ret[2]); - } - deferred3_0 = ptr2; - deferred3_1 = len2; - return getStringFromWasm0(ptr2, len2); - } finally { - wasm.__wbindgen_free(deferred3_0, deferred3_1, 1); - } -} - -/** - * @param {string} request_json - * @returns {Uint8Array} - */ -export function export_xlsx(request_json) { - const ptr0 = passStringToWasm0(request_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len0 = WASM_VECTOR_LEN; - const ret = wasm.export_xlsx(ptr0, len0); - if (ret[3]) { - throw takeFromExternrefTable0(ret[2]); - } - var v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice(); - wasm.__wbindgen_free(ret[0], ret[1] * 1, 1); - return v2; -} - -/** - * Get version information - * @returns {string} - */ -export function get_version() { - let deferred1_0; - let deferred1_1; - try { - const ret = wasm.get_version(); - deferred1_0 = ret[0]; - deferred1_1 = ret[1]; - return getStringFromWasm0(ret[0], ret[1]); - } finally { - wasm.__wbindgen_free(deferred1_0, deferred1_1, 1); - } -} - -/** - * Install panic hook for better error messages in browser console - */ -export function set_panic_hook() { - wasm.set_panic_hook(); -} - -/** - * Split SQL into statement spans. - * - * Supports optional `encoding` field in request: - * - `"utf8"` (default): All span offsets are UTF-8 byte offsets - * - `"utf16"`: All span offsets are converted to UTF-16 code units - * @param {string} request_json - * @returns {string} - */ -export function split_statements_json(request_json) { - let deferred2_0; - let deferred2_1; - try { - const ptr0 = passStringToWasm0(request_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len0 = WASM_VECTOR_LEN; - const ret = wasm.split_statements_json(ptr0, len0); - deferred2_0 = ret[0]; - deferred2_1 = ret[1]; - return getStringFromWasm0(ret[0], ret[1]); - } finally { - wasm.__wbindgen_free(deferred2_0, deferred2_1, 1); - } -} - -function __wbg_get_imports() { - const import0 = { - __proto__: null, - __wbg___wbindgen_throw_be289d5034ed271b: function(arg0, arg1) { - throw new Error(getStringFromWasm0(arg0, arg1)); - }, - __wbg_error_7534b8e9a36f1ab4: function(arg0, arg1) { - let deferred0_0; - let deferred0_1; - try { - deferred0_0 = arg0; - deferred0_1 = arg1; - console.error(getStringFromWasm0(arg0, arg1)); - } finally { - wasm.__wbindgen_free(deferred0_0, deferred0_1, 1); - } - }, - __wbg_getTime_1e3cd1391c5c3995: function(arg0) { - const ret = arg0.getTime(); - return ret; - }, - __wbg_new_0_73afc35eb544e539: function() { - const ret = new Date(); - return ret; - }, - __wbg_new_8a6f238a6ece86ea: function() { - const ret = new Error(); - return ret; - }, - __wbg_now_a3af9a2f4bbaa4d1: function() { - const ret = Date.now(); - return ret; - }, - __wbg_stack_0ed75d68575b0f3c: function(arg0, arg1) { - const ret = arg1.stack; - const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len1 = WASM_VECTOR_LEN; - getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); - getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); - }, - __wbindgen_cast_0000000000000001: function(arg0, arg1) { - // Cast intrinsic for `Ref(String) -> Externref`. - const ret = getStringFromWasm0(arg0, arg1); - return ret; - }, - __wbindgen_init_externref_table: function() { - const table = wasm.__wbindgen_externrefs; - const offset = table.grow(4); - table.set(0, undefined); - table.set(offset + 0, undefined); - table.set(offset + 1, null); - table.set(offset + 2, true); - table.set(offset + 3, false); - }, - }; - return { - __proto__: null, - "./flowscope_wasm_bg.js": import0, - }; -} - -function getArrayU8FromWasm0(ptr, len) { - ptr = ptr >>> 0; - return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len); -} - -let cachedDataViewMemory0 = null; -function getDataViewMemory0() { - if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) { - cachedDataViewMemory0 = new DataView(wasm.memory.buffer); - } - return cachedDataViewMemory0; -} - -function getStringFromWasm0(ptr, len) { - ptr = ptr >>> 0; - return decodeText(ptr, len); -} - -let cachedUint8ArrayMemory0 = null; -function getUint8ArrayMemory0() { - if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { - cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); - } - return cachedUint8ArrayMemory0; -} - -function passStringToWasm0(arg, malloc, realloc) { - if (realloc === undefined) { - const buf = cachedTextEncoder.encode(arg); - const ptr = malloc(buf.length, 1) >>> 0; - getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf); - WASM_VECTOR_LEN = buf.length; - return ptr; - } - - let len = arg.length; - let ptr = malloc(len, 1) >>> 0; - - const mem = getUint8ArrayMemory0(); - - let offset = 0; - - for (; offset < len; offset++) { - const code = arg.charCodeAt(offset); - if (code > 0x7F) break; - mem[ptr + offset] = code; - } - if (offset !== len) { - if (offset !== 0) { - arg = arg.slice(offset); - } - ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; - const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len); - const ret = cachedTextEncoder.encodeInto(arg, view); - - offset += ret.written; - ptr = realloc(ptr, len, offset, 1) >>> 0; - } - - WASM_VECTOR_LEN = offset; - return ptr; -} - -function takeFromExternrefTable0(idx) { - const value = wasm.__wbindgen_externrefs.get(idx); - wasm.__externref_table_dealloc(idx); - return value; -} - -let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); -cachedTextDecoder.decode(); -const MAX_SAFARI_DECODE_BYTES = 2146435072; -let numBytesDecoded = 0; -function decodeText(ptr, len) { - numBytesDecoded += len; - if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { - cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); - cachedTextDecoder.decode(); - numBytesDecoded = len; - } - return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); -} - -const cachedTextEncoder = new TextEncoder(); - -if (!('encodeInto' in cachedTextEncoder)) { - cachedTextEncoder.encodeInto = function (arg, view) { - const buf = cachedTextEncoder.encode(arg); - view.set(buf); - return { - read: arg.length, - written: buf.length - }; - }; -} - -let WASM_VECTOR_LEN = 0; - -let wasmModule, wasm; -function __wbg_finalize_init(instance, module) { - wasm = instance.exports; - wasmModule = module; - cachedDataViewMemory0 = null; - cachedUint8ArrayMemory0 = null; - wasm.__wbindgen_start(); - return wasm; -} - -async function __wbg_load(module, imports) { - if (typeof Response === 'function' && module instanceof Response) { - if (typeof WebAssembly.instantiateStreaming === 'function') { - try { - return await WebAssembly.instantiateStreaming(module, imports); - } catch (e) { - const validResponse = module.ok && expectedResponseType(module.type); - - if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') { - console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); - - } else { throw e; } - } - } - - const bytes = await module.arrayBuffer(); - return await WebAssembly.instantiate(bytes, imports); - } else { - const instance = await WebAssembly.instantiate(module, imports); - - if (instance instanceof WebAssembly.Instance) { - return { instance, module }; - } else { - return instance; - } - } - - function expectedResponseType(type) { - switch (type) { - case 'basic': case 'cors': case 'default': return true; - } - return false; - } -} - -function initSync(module) { - if (wasm !== undefined) return wasm; - - - if (module !== undefined) { - if (Object.getPrototypeOf(module) === Object.prototype) { - ({module} = module) - } else { - console.warn('using deprecated parameters for `initSync()`; pass a single object instead') - } - } - - const imports = __wbg_get_imports(); - if (!(module instanceof WebAssembly.Module)) { - module = new WebAssembly.Module(module); - } - const instance = new WebAssembly.Instance(module, imports); - return __wbg_finalize_init(instance, module); -} - -async function __wbg_init(module_or_path) { - if (wasm !== undefined) return wasm; - - - if (module_or_path !== undefined) { - if (Object.getPrototypeOf(module_or_path) === Object.prototype) { - ({module_or_path} = module_or_path) - } else { - console.warn('using deprecated parameters for the initialization function; pass a single object instead') - } - } - - if (module_or_path === undefined) { - module_or_path = new URL('flowscope_wasm_bg.wasm', import.meta.url); - } - const imports = __wbg_get_imports(); - - if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) { - module_or_path = fetch(module_or_path); - } - - const { instance, module } = await __wbg_load(await module_or_path, imports); - - return __wbg_finalize_init(instance, module); -} - -export { initSync, __wbg_init as default }; diff --git a/app/public/wasm/flowscope_wasm_bg.wasm.d.ts b/app/public/wasm/flowscope_wasm_bg.wasm.d.ts deleted file mode 100644 index 545c4a1a..00000000 --- a/app/public/wasm/flowscope_wasm_bg.wasm.d.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -export const memory: WebAssembly.Memory; -export const analyze_and_export_sql: (a: number, b: number) => [number, number, number, number]; -export const analyze_sql: (a: number, b: number) => [number, number, number, number]; -export const analyze_sql_json: (a: number, b: number) => [number, number]; -export const completion_context_json: (a: number, b: number) => [number, number]; -export const completion_items_json: (a: number, b: number) => [number, number]; -export const enable_tracing: () => void; -export const export_csv_bundle: (a: number, b: number) => [number, number, number, number]; -export const export_filename: (a: number, b: number) => [number, number, number, number]; -export const export_html: (a: number, b: number) => [number, number, number, number]; -export const export_json: (a: number, b: number) => [number, number, number, number]; -export const export_mermaid: (a: number, b: number) => [number, number, number, number]; -export const export_to_duckdb_sql: (a: number, b: number) => [number, number, number, number]; -export const export_xlsx: (a: number, b: number) => [number, number, number, number]; -export const get_version: () => [number, number]; -export const split_statements_json: (a: number, b: number) => [number, number]; -export const set_panic_hook: () => void; -export const __wbindgen_free: (a: number, b: number, c: number) => void; -export const __wbindgen_malloc: (a: number, b: number) => number; -export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; -export const __wbindgen_externrefs: WebAssembly.Table; -export const __externref_table_dealloc: (a: number) => void; -export const __wbindgen_start: () => void; diff --git a/app/scripts/check-bundle-budget.mjs b/app/scripts/check-bundle-budget.mjs new file mode 100644 index 00000000..8c26efe9 --- /dev/null +++ b/app/scripts/check-bundle-budget.mjs @@ -0,0 +1,178 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { gzipSync } from 'node:zlib'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const APP_DIR = path.dirname(SCRIPT_DIR); + +function walkFiles(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const absolutePath = path.join(directory, entry.name); + return entry.isDirectory() ? walkFiles(absolutePath) : [absolutePath]; + }); +} + +function sumFileSizes(files) { + return files.reduce((total, file) => total + statSync(file).size, 0); +} + +function sumGzipSizes(files) { + return files.reduce( + (total, file) => total + gzipSync(readFileSync(file), { level: 9 }).length, + 0 + ); +} + +function collectStartupManifestKeys(manifest, key, keys = new Set()) { + if (keys.has(key)) return keys; + const entry = manifest[key]; + if (!entry) { + throw new Error(`Bundle manifest references missing entry: ${key}`); + } + + keys.add(key); + for (const importedKey of entry.imports ?? []) { + collectStartupManifestKeys(manifest, importedKey, keys); + } + return keys; +} + +export function inspectBundle(distDirectory) { + const manifestPath = path.join(distDirectory, '.vite', 'manifest.json'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + const entryKeys = Object.keys(manifest).filter((key) => manifest[key].isEntry); + if (entryKeys.length !== 1) { + throw new Error( + `Expected exactly one application entry in the Vite manifest, found ${entryKeys.length}` + ); + } + + const entry = manifest[entryKeys[0]]; + const startupKeys = collectStartupManifestKeys(manifest, entryKeys[0]); + const startupEntries = [...startupKeys].map((key) => manifest[key]); + const startupJsRelative = new Set( + startupEntries.map((item) => item.file).filter((file) => /\.(?:js|mjs)$/.test(file)) + ); + const startupCssRelative = new Set(startupEntries.flatMap((item) => item.css ?? [])); + const absolute = (relativePath) => path.join(distDirectory, relativePath); + + const allFiles = walkFiles(distDirectory); + const jsFiles = allFiles.filter((file) => /\.(?:js|mjs)$/.test(file)); + const wasmFiles = allFiles.filter((file) => file.endsWith('.wasm')); + const startupJsFiles = [...startupJsRelative].map(absolute); + const startupCssFiles = [...startupCssRelative].map(absolute); + const entryFile = absolute(entry.file); + const startupJsAbsolute = new Set(startupJsFiles); + const asyncJsFiles = jsFiles.filter((file) => !startupJsAbsolute.has(file)); + const largestAsyncJsFile = + asyncJsFiles.length > 0 + ? asyncJsFiles.reduce((largest, file) => + statSync(file).size > statSync(largest).size ? file : largest + ) + : null; + + return { + entryJsBytes: statSync(entryFile).size, + startupJsBytes: sumFileSizes(startupJsFiles), + startupJsGzipBytes: sumGzipSizes(startupJsFiles), + startupCssBytes: sumFileSizes(startupCssFiles), + startupCssGzipBytes: sumGzipSizes(startupCssFiles), + wasmFileCount: wasmFiles.length, + largestWasmBytes: + wasmFiles.length > 0 ? Math.max(...wasmFiles.map((file) => statSync(file).size)) : 0, + largestAsyncJsChunkBytes: largestAsyncJsFile ? statSync(largestAsyncJsFile).size : 0, + largestAsyncJsChunk: largestAsyncJsFile + ? path.relative(distDirectory, largestAsyncJsFile) + : '(none)', + totalJsBytes: sumFileSizes(jsFiles), + totalDistBytes: sumFileSizes(allFiles), + }; +} + +const CHECKS = [ + ['wasmFileCount', 'wasmFileCount', 'exactly'], + ['largestWasmBytes', 'maxWasmBytes', 'at most'], + ['entryJsBytes', 'maxEntryJsBytes', 'at most'], + ['startupJsBytes', 'maxStartupJsBytes', 'at most'], + ['startupJsGzipBytes', 'maxStartupJsGzipBytes', 'at most'], + ['startupCssBytes', 'maxStartupCssBytes', 'at most'], + ['startupCssGzipBytes', 'maxStartupCssGzipBytes', 'at most'], + ['largestAsyncJsChunkBytes', 'maxAsyncJsChunkBytes', 'at most'], + ['totalJsBytes', 'maxTotalJsBytes', 'at most'], + ['totalDistBytes', 'maxTotalDistBytes', 'at most'], +]; + +export function validateBudgets(budgets) { + for (const [, budgetName, comparison] of CHECKS) { + const budget = budgets[budgetName]; + const isValidNumber = typeof budget === 'number' && Number.isFinite(budget) && budget >= 0; + const isValidExactCount = comparison !== 'exactly' || Number.isInteger(budget); + if (!isValidNumber || !isValidExactCount) { + const expected = comparison === 'exactly' ? 'a nonnegative integer' : 'a nonnegative number'; + throw new Error(`Invalid bundle budget "${budgetName}": expected ${expected}`); + } + } +} + +export function findBudgetFailures(metrics, budgets) { + validateBudgets(budgets); + return CHECKS.flatMap(([metricName, budgetName, comparison]) => { + const actual = metrics[metricName]; + const budget = budgets[budgetName]; + const failed = comparison === 'exactly' ? actual !== budget : actual > budget; + return failed ? [{ metricName, budgetName, actual, budget, comparison }] : []; + }); +} + +export function formatBytes(bytes) { + return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`; +} + +function printMetrics(metrics) { + console.log('Bundle budget summary'); + console.log(` WASM files: ${metrics.wasmFileCount}`); + console.log(` WASM size: ${formatBytes(metrics.largestWasmBytes)}`); + console.log(` Entry JS: ${formatBytes(metrics.entryJsBytes)}`); + console.log( + ` Startup JS: ${formatBytes(metrics.startupJsBytes)} raw / ${formatBytes(metrics.startupJsGzipBytes)} gzip` + ); + console.log( + ` Startup CSS: ${formatBytes(metrics.startupCssBytes)} raw / ${formatBytes(metrics.startupCssGzipBytes)} gzip` + ); + console.log( + ` Largest async JS chunk: ${formatBytes(metrics.largestAsyncJsChunkBytes)} (${metrics.largestAsyncJsChunk})` + ); + console.log(` Total JS: ${formatBytes(metrics.totalJsBytes)}`); + console.log(` Total dist: ${formatBytes(metrics.totalDistBytes)}`); +} + +function run() { + const distDirectory = path.resolve(process.argv[2] ?? path.join(APP_DIR, 'dist')); + const budgetPath = path.resolve(process.argv[3] ?? path.join(APP_DIR, 'bundle-budgets.json')); + const budgets = JSON.parse(readFileSync(budgetPath, 'utf8')); + const metrics = inspectBundle(distDirectory); + const failures = findBudgetFailures(metrics, budgets); + + printMetrics(metrics); + if (failures.length === 0) { + console.log('Bundle budgets passed.'); + return; + } + + console.error('\nBundle budgets failed:'); + for (const failure of failures) { + const actual = + failure.metricName === 'wasmFileCount' ? failure.actual : formatBytes(failure.actual); + const budget = + failure.metricName === 'wasmFileCount' ? failure.budget : formatBytes(failure.budget); + console.error( + ` ${failure.metricName}: ${actual}; expected ${failure.comparison} ${budget} (${failure.budgetName})` + ); + } + process.exitCode = 1; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { + run(); +} diff --git a/app/scripts/check-bundle-budget.test.mjs b/app/scripts/check-bundle-budget.test.mjs new file mode 100644 index 00000000..e60c5929 --- /dev/null +++ b/app/scripts/check-bundle-budget.test.mjs @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, test } from 'node:test'; + +import { findBudgetFailures, inspectBundle } from './check-bundle-budget.mjs'; +import { removeLegacyWasm } from './remove-legacy-wasm.mjs'; + +const temporaryDirectories = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function createBundleFixture({ duplicateWasm = false } = {}) { + const directory = mkdtempSync(path.join(tmpdir(), 'flowscope-bundle-budget-')); + temporaryDirectories.push(directory); + mkdirSync(path.join(directory, '.vite'), { recursive: true }); + mkdirSync(path.join(directory, 'assets'), { recursive: true }); + + const manifest = { + 'index.html': { + file: 'assets/entry.js', + isEntry: true, + imports: ['_vendor.js'], + dynamicImports: ['src/lazy.ts'], + css: ['assets/app.css'], + }, + '_vendor.js': { file: 'assets/vendor.js' }, + 'src/lazy.ts': { file: 'assets/lazy.js', isDynamicEntry: true }, + }; + writeFileSync(path.join(directory, '.vite', 'manifest.json'), JSON.stringify(manifest)); + writeFileSync(path.join(directory, 'assets', 'entry.js'), Buffer.alloc(10, 1)); + writeFileSync(path.join(directory, 'assets', 'vendor.js'), Buffer.alloc(20, 2)); + writeFileSync(path.join(directory, 'assets', 'lazy.js'), Buffer.alloc(100, 3)); + writeFileSync(path.join(directory, 'assets', 'app.css'), Buffer.alloc(5, 4)); + writeFileSync(path.join(directory, 'assets', 'engine.wasm'), Buffer.alloc(40, 5)); + if (duplicateWasm) { + writeFileSync(path.join(directory, 'engine-copy.wasm'), Buffer.alloc(40, 5)); + } + return directory; +} + +const unlimitedBudgets = { + wasmFileCount: 1, + maxWasmBytes: Number.MAX_SAFE_INTEGER, + maxEntryJsBytes: Number.MAX_SAFE_INTEGER, + maxStartupJsBytes: Number.MAX_SAFE_INTEGER, + maxStartupJsGzipBytes: Number.MAX_SAFE_INTEGER, + maxStartupCssBytes: Number.MAX_SAFE_INTEGER, + maxStartupCssGzipBytes: Number.MAX_SAFE_INTEGER, + maxAsyncJsChunkBytes: Number.MAX_SAFE_INTEGER, + maxTotalJsBytes: Number.MAX_SAFE_INTEGER, + maxTotalDistBytes: Number.MAX_SAFE_INTEGER, +}; + +test('counts only static entry imports in the startup budget', () => { + const metrics = inspectBundle(createBundleFixture()); + + assert.equal(metrics.entryJsBytes, 10); + assert.equal(metrics.startupJsBytes, 30); + assert.equal(metrics.startupCssBytes, 5); + assert.equal(metrics.totalJsBytes, 130); + assert.equal(metrics.wasmFileCount, 1); +}); + +test('reports duplicate WASM assets', () => { + const metrics = inspectBundle(createBundleFixture({ duplicateWasm: true })); + const failures = findBudgetFailures(metrics, unlimitedBudgets); + + assert.deepEqual( + failures.map((failure) => failure.metricName), + ['wasmFileCount'] + ); +}); + +test('reports startup JavaScript regressions', () => { + const metrics = inspectBundle(createBundleFixture()); + const failures = findBudgetFailures(metrics, { + wasmFileCount: 1, + maxWasmBytes: 100, + maxEntryJsBytes: 100, + maxStartupJsBytes: 29, + maxStartupJsGzipBytes: Number.MAX_SAFE_INTEGER, + maxStartupCssBytes: 100, + maxStartupCssGzipBytes: Number.MAX_SAFE_INTEGER, + maxAsyncJsChunkBytes: 100, + maxTotalJsBytes: 200, + maxTotalDistBytes: 1000, + }); + + assert.deepEqual( + failures.map((failure) => failure.metricName), + ['startupJsBytes'] + ); +}); + +test('rejects missing or malformed budget values', () => { + const metrics = inspectBundle(createBundleFixture()); + + assert.throws( + () => findBudgetFailures(metrics, { ...unlimitedBudgets, maxTotalDistBytes: undefined }), + /Invalid bundle budget "maxTotalDistBytes"/ + ); + assert.throws( + () => findBudgetFailures(metrics, { ...unlimitedBudgets, wasmFileCount: 1.5 }), + /Invalid bundle budget "wasmFileCount"/ + ); +}); + +test('removes only the legacy public WASM directory', () => { + const directory = mkdtempSync(path.join(tmpdir(), 'flowscope-legacy-wasm-')); + temporaryDirectories.push(directory); + mkdirSync(path.join(directory, 'public', 'wasm'), { recursive: true }); + writeFileSync(path.join(directory, 'public', 'wasm', 'flowscope_wasm_bg.wasm'), 'legacy'); + writeFileSync(path.join(directory, 'public', 'favicon.svg'), 'keep'); + + removeLegacyWasm(directory); + + assert.equal(existsSync(path.join(directory, 'public', 'wasm')), false); + assert.equal(existsSync(path.join(directory, 'public', 'favicon.svg')), true); +}); diff --git a/app/scripts/remove-legacy-wasm.mjs b/app/scripts/remove-legacy-wasm.mjs new file mode 100644 index 00000000..02a2648a --- /dev/null +++ b/app/scripts/remove-legacy-wasm.mjs @@ -0,0 +1,14 @@ +import { rmSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const APP_DIR = path.dirname(SCRIPT_DIR); + +export function removeLegacyWasm(appDirectory = APP_DIR) { + rmSync(path.join(appDirectory, 'public', 'wasm'), { recursive: true, force: true }); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { + removeLegacyWasm(); +} diff --git a/app/src/App.tsx b/app/src/App.tsx index 93de9d42..9befe6be 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { LineageProvider } from '@pondpilot/flowscope-react'; +import { LineageProvider } from '@flowscope-react/context'; import '@pondpilot/flowscope-react/styles.css'; import { ProjectProvider } from './lib/project-store'; diff --git a/app/src/components/AnalysisView.tsx b/app/src/components/AnalysisView.tsx index 95efa16b..ff8a087e 100644 --- a/app/src/components/AnalysisView.tsx +++ b/app/src/components/AnalysisView.tsx @@ -1,12 +1,8 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { LineageActions } from '@pondpilot/flowscope-react'; -import { - GraphErrorBoundary, - GraphView, - MatrixView, - SchemaView, - useLineage, -} from '@pondpilot/flowscope-react'; +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { LineageActions } from '@flowscope-react/types'; +import { GraphErrorBoundary } from '@flowscope-react/components/ErrorBoundary'; +import { GraphView } from '@flowscope-react/components/GraphView'; +import { useLineage } from '@flowscope-react/store'; import type { AnalyzeResult, SchemaTable } from '@pondpilot/flowscope-core'; import { Loader2, Settings } from 'lucide-react'; @@ -24,7 +20,7 @@ import { isValidTab, useNavigation } from '@/lib/navigation-context'; import { useViewStateStore, getNamespaceFilterStateWithDefaults } from '@/lib/view-state-store'; import { useProject } from '@/lib/project-store'; import { schemaMetadataToSQL } from '@/lib/schema-parser'; -import { HierarchyView, type HierarchyViewRef } from './HierarchyView'; +import type { HierarchyViewRef } from './HierarchyView'; import { LibrarianToggleButton } from './LibrarianToggleButton'; import { StatsPopover } from './StatsPopover'; import { NamespaceFilterBar } from './NamespaceFilterBar'; @@ -32,6 +28,28 @@ import { SchemaAwareIssuesPanel } from './SchemaAwareIssuesPanel'; import { SchemaEditor } from './SchemaEditor'; import { SchemaSearchControl } from './SchemaSearchControl'; +const LazyHierarchyView = lazy(() => + import('./HierarchyView').then(({ HierarchyView }) => ({ default: HierarchyView })) +); +const LazyMatrixView = lazy(() => + import('@flowscope-react/components/MatrixView').then(({ MatrixView }) => ({ + default: MatrixView, + })) +); +const LazySchemaView = lazy(() => + import('@flowscope-react/components/SchemaView').then(({ SchemaView }) => ({ + default: SchemaView, + })) +); + +function LazyViewFallback() { + return ( +
+ +
+ ); +} + interface AnalysisViewProps { graphContainerRef?: React.RefObject; isAnalyzing?: boolean; @@ -464,11 +482,13 @@ export function AnalysisView({ > {mountedTabs.has('hierarchy') && ( - + }> + + )} @@ -479,11 +499,13 @@ export function AnalysisView({ className="h-full mt-0 p-0 absolute inset-0 data-[state=inactive]:hidden" > {mountedTabs.has('matrix') && ( - + }> + + )} @@ -494,11 +516,13 @@ export function AnalysisView({ > {mountedTabs.has('schema') && (
- + }> + +
t.name)} diff --git a/app/src/components/EditorArea.tsx b/app/src/components/EditorArea.tsx index c0cd6e99..0f592fc1 100644 --- a/app/src/components/EditorArea.tsx +++ b/app/src/components/EditorArea.tsx @@ -1,12 +1,9 @@ import { useEffect, useCallback, useRef, useMemo, useState } from 'react'; import { Loader2, AlertCircle, AlertTriangle } from 'lucide-react'; import { toast } from 'sonner'; -import { - SqlView, - computeStalePaths, - useLineageActions, - useLineageState, -} from '@pondpilot/flowscope-react'; +import { SqlView } from '@flowscope-react/components/SqlView'; +import { useLineageActions, useLineageState } from '@flowscope-react/store'; +import { computeStalePaths } from '@flowscope-react/utils/staleContent'; import { cn } from '@/lib/utils'; import { useProject } from '@/lib/project-store'; import { useThemeStore, resolveTheme } from '@/lib/theme-store'; diff --git a/app/src/components/ExportDialog.tsx b/app/src/components/ExportDialog.tsx index 403e7eb6..7046f63c 100644 --- a/app/src/components/ExportDialog.tsx +++ b/app/src/components/ExportDialog.tsx @@ -1,7 +1,5 @@ import { useCallback, useState, type JSX } from 'react'; -import { toPng } from 'html-to-image'; import { toast } from 'sonner'; -import { gzipSync, strToU8 } from 'fflate'; import { Download, Image, @@ -47,14 +45,9 @@ import { formatSchemaError, validateSchemaName, } from '@pondpilot/flowscope-core'; -import { useIsDarkMode } from '@pondpilot/flowscope-react'; +import { useIsDarkMode } from '@flowscope-react/hooks/useColors'; import { getShortcutDisplay } from '@/lib/shortcuts'; -import { - base64UrlEncode, - formatBytes, - SHARE_URL_SOFT_LIMIT, - SHARE_URL_HARD_LIMIT, -} from '@/lib/share'; +import { SHARE_LIMITS } from '@/lib/constants'; // ============================================================================ // Types @@ -117,16 +110,21 @@ const PONDPILOT_URL = 'https://app.pondpilot.io'; * Create a PondPilot shareable URL for the given SQL content. * Uses gzip compression for efficient URL encoding. */ -function createPondPilotUrl( +async function createPondPilotUrl( name: string, sqlContent: string -): { url: string; compressedSize: number } { +): Promise<{ url: string; compressedSize: number; formattedSize: string }> { + const [{ gzipSync, strToU8 }, { base64UrlEncode, formatBytes }] = await Promise.all([ + import('fflate'), + import('@/lib/share'), + ]); const payload = JSON.stringify({ name, content: sqlContent }); const compressed = gzipSync(strToU8(payload), { level: 9 }); const encoded = base64UrlEncode(compressed); return { url: `${PONDPILOT_URL}/shared-script/${encoded}`, compressedSize: encoded.length, + formattedSize: formatBytes(encoded.length), }; } @@ -202,6 +200,7 @@ export function ExportDialog({ } try { + const { toPng } = await import('html-to-image'); const backgroundColor = isDarkMode ? '#1e293b' : '#ffffff'; const dataUrl = await toPng(graphRef.current, { backgroundColor }); const { filename } = await buildExportFilename(projectName, 'png'); @@ -287,18 +286,18 @@ export function ExportDialog({ const schema = schemaInput.trim() || undefined; const sql = await exportToDuckDbSql(result, schema); const fileName = `${sanitizeProjectName(projectName)}-lineage`; - const { url, compressedSize } = createPondPilotUrl(fileName, sql); + const { url, compressedSize, formattedSize } = await createPondPilotUrl(fileName, sql); - if (compressedSize > SHARE_URL_HARD_LIMIT) { + if (compressedSize > SHARE_LIMITS.URL_HARD_LIMIT) { setExportError( 'File is too large for URL sharing. Please download the SQL file and open it in PondPilot manually.' ); return; } - if (compressedSize > SHARE_URL_SOFT_LIMIT) { + if (compressedSize > SHARE_LIMITS.URL_SOFT_LIMIT) { toast.warning('Large export may not work in all browsers', { - description: `Compressed size: ${formatBytes(compressedSize)}`, + description: `Compressed size: ${formattedSize}`, }); } diff --git a/app/src/components/HierarchyView.tsx b/app/src/components/HierarchyView.tsx index cc29b3db..4cbc8424 100644 --- a/app/src/components/HierarchyView.tsx +++ b/app/src/components/HierarchyView.tsx @@ -24,12 +24,10 @@ import { Grid3X3, ExternalLink, } from 'lucide-react'; -import { - useLineage, - SearchAutocomplete, - type SearchSuggestion, - type SearchAutocompleteRef, -} from '@pondpilot/flowscope-react'; +import { SearchAutocomplete } from '@flowscope-react/components/SearchAutocomplete'; +import type { SearchAutocompleteRef } from '@flowscope-react/components/SearchAutocomplete'; +import type { SearchSuggestion } from '@flowscope-react/hooks/useSearchSuggestions'; +import { useLineage } from '@flowscope-react/store'; import { cn } from '@/lib/utils'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { useNavigation } from '@/lib/navigation-context'; diff --git a/app/src/components/NamespaceFilterBar.tsx b/app/src/components/NamespaceFilterBar.tsx index 50f5b31b..f246bd28 100644 --- a/app/src/components/NamespaceFilterBar.tsx +++ b/app/src/components/NamespaceFilterBar.tsx @@ -15,7 +15,7 @@ import { getNamespaceFilterStateWithDefaults, type NamespaceFilterState, } from '@/lib/view-state-store'; -import { getNamespaceColor } from '@pondpilot/flowscope-react'; +import { getNamespaceColor } from '@flowscope-react/constants'; import { useThemeStore, resolveTheme } from '@/lib/theme-store'; interface NamespaceFilterBarProps { diff --git a/app/src/components/SchemaAwareIssuesPanel.tsx b/app/src/components/SchemaAwareIssuesPanel.tsx index cc759bd8..ab86e287 100644 --- a/app/src/components/SchemaAwareIssuesPanel.tsx +++ b/app/src/components/SchemaAwareIssuesPanel.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { useLineage } from '@pondpilot/flowscope-react'; +import { useLineage } from '@flowscope-react/store'; import { AlertCircle, Database } from 'lucide-react'; import { useNavigation } from '@/lib/navigation-context'; import { useProject } from '@/lib/project-store'; diff --git a/app/src/components/SchemaEditor.tsx b/app/src/components/SchemaEditor.tsx index b860f526..aaceabe9 100644 --- a/app/src/components/SchemaEditor.tsx +++ b/app/src/components/SchemaEditor.tsx @@ -1,5 +1,5 @@ import { useState, useCallback } from 'react'; -import { SqlView } from '@pondpilot/flowscope-react'; +import { SqlView } from '@flowscope-react/components/SqlView'; import { Dialog, DialogContent, diff --git a/app/src/components/Workspace.tsx b/app/src/components/Workspace.tsx index d069e786..e30af7d2 100644 --- a/app/src/components/Workspace.tsx +++ b/app/src/components/Workspace.tsx @@ -1,7 +1,7 @@ -import { useState, useMemo, useRef, useCallback, useEffect } from 'react'; +import { lazy, Suspense, useState, useMemo, useRef, useCallback, useEffect } from 'react'; import { Share2, Github } from 'lucide-react'; import { toast } from 'sonner'; -import { useLineageActions, useLineageState } from '@pondpilot/flowscope-react'; +import { useLineageActions, useLineageState } from '@flowscope-react/store'; import { Button } from './ui/button'; import { FlowScopeLogo } from './FlowScopeLogo'; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from './ui/resizable'; @@ -10,7 +10,6 @@ import type { ImperativePanelHandle } from 'react-resizable-panels'; import { EditorArea } from './EditorArea'; import { AnalysisView } from './AnalysisView'; import { ProjectSelector } from './ProjectSelector'; -import { ShareDialog } from './ShareDialog'; import { ExportDialog } from './ExportDialog'; import { ThemeToggle } from './ThemeToggle'; import { KeyboardShortcutsDialog } from './KeyboardShortcutsDialog'; @@ -25,10 +24,19 @@ import { useThemeStore, type Theme } from '@/lib/theme-store'; import { useViewStateStore } from '@/lib/view-state-store'; import { getShortcutDisplay } from '@/lib/shortcuts'; import { useBackend } from '@/lib/backend-context'; -import { LibrarianPanel, useSyncActiveProject } from '@/features/librarian'; +import { useSyncActiveProject } from '@/features/librarian/hooks/use-sync-active-project'; import type { ChatReference } from '@/features/librarian/utils/schema-identifiers'; import { resolveLineageNodeIds } from '@/lib/lineage-node-resolver'; +const LazyShareDialog = lazy(() => + import('./ShareDialog').then(({ ShareDialog }) => ({ default: ShareDialog })) +); +const LazyLibrarianPanel = lazy(() => + import('@/features/librarian/components/librarian-panel').then(({ LibrarianPanel }) => ({ + default: LibrarianPanel, + })) +); + interface WorkspaceProps { backendReady: boolean; error: string | null; @@ -406,12 +414,14 @@ export function Workspace({ backendReady, error, onRetry, isRetrying }: Workspac {/* Share Dialog */} - {currentProject && ( - + {currentProject && shareDialogOpen && ( + + + )} {/* Keyboard Shortcuts Help Dialog */} @@ -498,7 +508,15 @@ export function Workspace({ backendReady, error, onRetry, isRetrying }: Workspac maxSize={40} data-testid="librarian-panel" > - setLibrarianOpen(false)} /> + + Loading Librarian… +
+ } + > + setLibrarianOpen(false)} /> + )} @@ -550,5 +568,7 @@ function LibrarianPanelWithNavigation({ onClose }: { onClose: () => void }) { }, [navigateTo, result, showColumnEdges, toggleColumnEdges, activeProjectId, updateViewState] ); - return ; + return ( + + ); } diff --git a/app/src/features/librarian/__tests__/librarian-panel.test.tsx b/app/src/features/librarian/__tests__/librarian-panel.test.tsx index 873fc8f7..6a34d806 100644 --- a/app/src/features/librarian/__tests__/librarian-panel.test.tsx +++ b/app/src/features/librarian/__tests__/librarian-panel.test.tsx @@ -34,7 +34,7 @@ vi.mock('../services/embedding-service', () => ({ embedTexts: vi.fn(() => Promise.resolve([[0.1, 0.2]])), })); -vi.mock('@pondpilot/flowscope-react', () => ({ +vi.mock('@flowscope-react/store', () => ({ useLineageState: () => ({ result: null }), })); diff --git a/app/src/features/librarian/__tests__/use-librarian-chat.test.ts b/app/src/features/librarian/__tests__/use-librarian-chat.test.ts index cf77fba0..b89e94e3 100644 --- a/app/src/features/librarian/__tests__/use-librarian-chat.test.ts +++ b/app/src/features/librarian/__tests__/use-librarian-chat.test.ts @@ -32,7 +32,7 @@ vi.mock('../services/vector-search', () => ({ // Mock lineage state const mockResult = { globalLineage: { nodes: [], edges: [] } }; -vi.mock('@pondpilot/flowscope-react', () => ({ +vi.mock('@flowscope-react/store', () => ({ useLineageState: () => ({ result: mockResult }), })); diff --git a/app/src/features/librarian/components/librarian-panel.tsx b/app/src/features/librarian/components/librarian-panel.tsx index 37f88e83..7ada9ca7 100644 --- a/app/src/features/librarian/components/librarian-panel.tsx +++ b/app/src/features/librarian/components/librarian-panel.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo, useState } from 'react'; import { ChevronDown, ChevronRight, HelpCircle, Settings, X } from 'lucide-react'; -import { useLineageState } from '@pondpilot/flowscope-react'; +import { useLineageState } from '@flowscope-react/store'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; diff --git a/app/src/features/librarian/hooks/use-librarian-chat.ts b/app/src/features/librarian/hooks/use-librarian-chat.ts index 9287a945..8ae1feab 100644 --- a/app/src/features/librarian/hooks/use-librarian-chat.ts +++ b/app/src/features/librarian/hooks/use-librarian-chat.ts @@ -1,5 +1,5 @@ import { useCallback, useRef } from 'react'; -import { useLineageState } from '@pondpilot/flowscope-react'; +import { useLineageState } from '@flowscope-react/store'; import { useProject } from '@/lib/project-store'; diff --git a/app/src/hooks/__tests__/useAnalysis.test.tsx b/app/src/hooks/__tests__/useAnalysis.test.tsx index ef90a086..2f2cb76d 100644 --- a/app/src/hooks/__tests__/useAnalysis.test.tsx +++ b/app/src/hooks/__tests__/useAnalysis.test.tsx @@ -17,7 +17,7 @@ const lineageActions = vi.hoisted(() => ({ let currentProject: Project | null = null; let activeProjectId: string | null = null; -vi.mock('@pondpilot/flowscope-react', () => ({ +vi.mock('@flowscope-react/store', () => ({ useLineage: () => ({ state: { hideCTEs: false }, actions: lineageActions, diff --git a/app/src/hooks/useAnalysis.ts b/app/src/hooks/useAnalysis.ts index c5a6f84a..baa229c5 100644 --- a/app/src/hooks/useAnalysis.ts +++ b/app/src/hooks/useAnalysis.ts @@ -1,6 +1,6 @@ import { useState, useCallback, useEffect, useMemo, useRef, startTransition } from 'react'; import type { AnalyzeResult } from '@pondpilot/flowscope-core'; -import { useLineage } from '@pondpilot/flowscope-react'; +import { useLineage } from '@flowscope-react/store'; import { analyzeWithWorker, getCachedAnalysis, syncAnalysisFiles } from '@/lib/analysis-worker'; import type { BackendAdapter, AnalysisPayload } from '@/lib/backend-adapter'; import { useProject } from '@/lib/project-store'; diff --git a/app/src/hooks/useDebugData.ts b/app/src/hooks/useDebugData.ts index 4c3fd706..d18279be 100644 --- a/app/src/hooks/useDebugData.ts +++ b/app/src/hooks/useDebugData.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { useLineage } from '@pondpilot/flowscope-react'; +import { useLineage } from '@flowscope-react/store'; import { useProject } from '../lib/project-store'; import { getLastParseResult } from '../lib/schema-parser'; import { useAnalysisStore } from '../lib/analysis-store'; diff --git a/app/src/hooks/useFileNavigation.ts b/app/src/hooks/useFileNavigation.ts index 43c4df85..bcfabf6c 100644 --- a/app/src/hooks/useFileNavigation.ts +++ b/app/src/hooks/useFileNavigation.ts @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { useLineageState } from '@pondpilot/flowscope-react'; +import { useLineageState } from '@flowscope-react/store'; import { useProject } from '@/lib/project-store'; /** diff --git a/app/src/hooks/useShareImport.ts b/app/src/hooks/useShareImport.ts index 2176c3a6..ad660adb 100644 --- a/app/src/hooks/useShareImport.ts +++ b/app/src/hooks/useShareImport.ts @@ -1,7 +1,6 @@ import { useEffect, useRef } from 'react'; import { toast } from 'sonner'; import { useProject } from '@/lib/project-store'; -import { getShareDataFromUrl, clearShareDataFromUrl, decodeProject } from '@/lib/share'; /** * Hook that auto-imports a project from the URL hash on mount. @@ -16,30 +15,41 @@ export function useShareImport() { if (hasChecked.current) return; hasChecked.current = true; - const encoded = getShareDataFromUrl(); + const hash = window.location.hash; + const encoded = hash.startsWith('#share=') ? hash.slice('#share='.length) : null; if (!encoded) return; // Clear URL immediately to prevent re-import on refresh - clearShareDataFromUrl(); + window.history.replaceState(null, '', window.location.pathname + window.location.search); - const payload = decodeProject(encoded); - if (!payload) { - toast.error('Failed to open shared project', { - description: 'The link may be corrupted or invalid.', - }); - return; - } + // Compression support is only needed for share URLs, not normal startup. + void import('@/lib/share') + .then(({ decodeProject }) => { + const payload = decodeProject(encoded); + if (!payload) { + toast.error('Failed to open shared project', { + description: 'The link may be corrupted or invalid.', + }); + return; + } - try { - const projectName = importProject(payload); - toast.success(`Imported "${projectName}"`, { - description: `${payload.f.length} file${payload.f.length !== 1 ? 's' : ''} loaded`, - }); - } catch (err) { - console.error('Failed to import project:', err); - toast.error('Failed to import shared project', { - description: 'An unexpected error occurred.', + try { + const projectName = importProject(payload); + toast.success(`Imported "${projectName}"`, { + description: `${payload.f.length} file${payload.f.length !== 1 ? 's' : ''} loaded`, + }); + } catch (err) { + console.error('Failed to import project:', err); + toast.error('Failed to import shared project', { + description: 'An unexpected error occurred.', + }); + } + }) + .catch((err: unknown) => { + console.error('Failed to load shared project support:', err); + toast.error('Failed to open shared project', { + description: 'An unexpected error occurred.', + }); }); - } }, [importProject]); } diff --git a/app/test.js b/app/test.js deleted file mode 100644 index d7d6a925..00000000 --- a/app/test.js +++ /dev/null @@ -1,61 +0,0 @@ -// Simple test script to verify WASM works in Node.js -import { readFile } from 'fs/promises'; -import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -async function testWasm() { - try { - console.log('Loading WASM module...'); - - // Read the WASM file - const wasmPath = join(__dirname, 'public/wasm/flowscope_wasm_bg.wasm'); - const wasmBuffer = await readFile(wasmPath); - - console.log(`WASM file size: ${(wasmBuffer.length / 1024 / 1024).toFixed(2)} MB`); - - // Import the JS wrapper - const { default: init, analyze_sql } = await import('./public/wasm/flowscope_wasm.js'); - - // Initialize WASM - await init(wasmBuffer); - console.log('✓ WASM module initialized'); - - // Test 1: Simple SELECT - console.log('\nTest 1: Simple SELECT'); - const sql1 = 'SELECT * FROM users'; - const result1 = analyze_sql(sql1); - const parsed1 = JSON.parse(result1); - console.log('Result:', parsed1); - console.log(parsed1.tables.includes('users') ? '✓ PASS' : '✗ FAIL'); - - // Test 2: JOIN query - console.log('\nTest 2: JOIN query'); - const sql2 = 'SELECT * FROM users JOIN orders ON users.id = orders.user_id'; - const result2 = analyze_sql(sql2); - const parsed2 = JSON.parse(result2); - console.log('Result:', parsed2); - console.log( - parsed2.tables.includes('users') && parsed2.tables.includes('orders') ? '✓ PASS' : '✗ FAIL' - ); - - // Test 3: Invalid SQL - console.log('\nTest 3: Invalid SQL (should error)'); - try { - const sql3 = 'SELECT * FROM'; - const result3 = analyze_sql(sql3); - console.log('✗ FAIL - Should have thrown an error'); - } catch (err) { - console.log('✓ PASS - Error caught:', err.message); - } - - console.log('\n✓ All tests completed successfully!'); - } catch (err) { - console.error('✗ Test failed:', err); - process.exit(1); - } -} - -testWasm(); diff --git a/app/tsconfig.json b/app/tsconfig.json index 401dc4c3..110271a1 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -19,7 +19,8 @@ "paths": { "@/*": ["./src/*"], "@pondpilot/flowscope-core": ["../packages/core/src/index.ts"], - "@pondpilot/flowscope-react": ["../packages/react/src/index.ts"] + "@pondpilot/flowscope-react": ["../packages/react/src/index.ts"], + "@flowscope-react/*": ["../packages/react/src/*"] } }, "include": ["src"], diff --git a/app/vite.config.ts b/app/vite.config.ts index e59a5020..1adb71e4 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ alias: { '@pondpilot/flowscope-core': path.resolve(__dirname, '../packages/core/src'), '@pondpilot/flowscope-react': path.resolve(__dirname, '../packages/react/src'), + '@flowscope-react': path.resolve(__dirname, '../packages/react/src'), '@': path.resolve(__dirname, './src'), }, }, @@ -21,6 +22,10 @@ export default defineConfig({ }, build: { target: 'esnext', + manifest: true, + // The manifest-based checker enforces tighter startup/async budgets; this + // warning ceiling matches the documented 3 MiB entry cap. + chunkSizeWarningLimit: 3072, }, worker: { format: 'es', diff --git a/app/vitest.config.ts b/app/vitest.config.ts index 77229ff6..51253535 100644 --- a/app/vitest.config.ts +++ b/app/vitest.config.ts @@ -7,12 +7,15 @@ export default defineConfig({ resolve: { alias: { '@': path.resolve(__dirname, './src'), + '@flowscope-react': path.resolve(__dirname, '../packages/react/src'), }, }, test: { environment: 'jsdom', globals: true, setupFiles: ['./src/features/librarian/__tests__/setup.ts'], + include: ['src/**/*.test.{ts,tsx}'], + exclude: ['scripts/**/*.test.mjs'], coverage: { provider: 'v8', include: ['src/**/*.{ts,tsx}'], diff --git a/packages/core/README.md b/packages/core/README.md index 42626325..1a65b47f 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -17,14 +17,19 @@ npm install @pondpilot/flowscope-core ```typescript import { initWasm, analyzeSql } from '@pondpilot/flowscope-core'; -await initWasm({ wasmUrl: '/wasm/flowscope_wasm_bg.wasm' }); +await initWasm(); const result = await analyzeSql({ sql: 'SELECT * FROM users', - dialect: 'duckdb' + dialect: 'duckdb', }); ``` +Bundlers such as Vite resolve the package-owned WASM URL automatically. Pass +`wasmUrl` only when a host deliberately serves the binary from a custom +location; application builds should not copy the package WASM into a public +asset directory as well. + ### Lint Diagnostics Enable linting via the `options.lint` field. Lint issues appear in `result.issues` with codes prefixed by `LINT_`: @@ -36,7 +41,7 @@ const result = await analyzeSql({ options: { lint: { enabled: true } }, }); -const lintIssues = result.issues.filter(i => i.code.startsWith('LINT_')); +const lintIssues = result.issues.filter((i) => i.code.startsWith('LINT_')); ``` See the root [README](../../README.md) for more details. diff --git a/packages/react/src/utils/layout.ts b/packages/react/src/utils/layout.ts index b4f2ec1f..9cc0b4be 100644 --- a/packages/react/src/utils/layout.ts +++ b/packages/react/src/utils/layout.ts @@ -1,5 +1,5 @@ import dagre from 'dagre'; -import ELK from 'elkjs/lib/elk.bundled.js'; +import type { ELK as ElkInstance } from 'elkjs/lib/elk-api'; import type { Node, Edge } from '@xyflow/react'; import { computeLayoutInWorker, @@ -27,8 +27,18 @@ export type LayoutAlgorithm = 'dagre' | 'elk'; const LAYOUT_CACHE_LIMIT = 6; -// ELK instance -const elk = new ELK(); +let elkPromise: Promise | null = null; + +/** + * Load ELK only after a user selects it. Dagre is the default, so keeping the + * 1.6 MB ELK runtime out of the startup graph materially reduces initial JS. + */ +function loadElk(): Promise { + if (!elkPromise) { + elkPromise = import('elkjs/lib/elk.bundled.js').then(({ default: ELK }) => new ELK()); + } + return elkPromise; +} interface LayoutCacheEntry { positions: Record; @@ -237,6 +247,7 @@ async function layoutWithElk[], direction: 'LR' | 'TB' ): Promise<{ nodes: Node[]; edges: Edge[] }> { + const elk = await loadElk(); const elkDirection = direction === 'LR' ? 'RIGHT' : 'DOWN'; const graph = { diff --git a/scripts/build-rust.sh b/scripts/build-rust.sh index 6611e4f7..0b681fc9 100755 --- a/scripts/build-rust.sh +++ b/scripts/build-rust.sh @@ -1,13 +1,12 @@ #!/bin/bash # Build script for FlowScope Rust/WASM components # -# This script builds the native Rust workspace and the WASM module, -# then sets up the necessary symlinks and copies for development. +# This script builds the native Rust workspace and the browser WASM module, +# then sets up the TypeScript source symlink used by package and app builds. # # Directory structure: -# packages/core/wasm/ - WASM build output (committed for npm publishing) -# packages/core/src/wasm - Symlink to ../wasm (for TypeScript imports) -# app/public/wasm/ - WASM files served by the dev server +# packages/core/wasm/ - Canonical browser WASM output (also published to npm) +# packages/core/src/wasm - Symlink to ../wasm (for package/Vite imports) # # Usage: Run from repository root: ./scripts/build-rust.sh @@ -37,6 +36,10 @@ wasm-pack build crates/flowscope-wasm --release --target web --out-dir ../../pac # (wasm-pack generates a .gitignore that ignores everything) echo "# Keep wasm artifacts available for publishing" > packages/core/wasm/.gitignore +# Remove the legacy app/public mirror, including ignored binaries left behind +# by older versions of this script. Vite now emits the package-owned WASM. +node app/scripts/remove-legacy-wasm.mjs + # Create symlink for TypeScript development imports # The symlink allows TypeScript to import from './wasm' while the actual files # live one directory up in packages/core/wasm (for cleaner npm package structure) @@ -47,23 +50,5 @@ if [ ! -L "packages/core/src/wasm" ]; then ln -s ../wasm packages/core/src/wasm fi -echo "Copying WASM to app locations..." -# Copy to app/public/wasm for the Vite dev server to serve -mkdir -p app/public/wasm -cp packages/core/wasm/flowscope_wasm_bg.wasm app/public/wasm/ -cp packages/core/wasm/flowscope_wasm.js app/public/wasm/ -cp packages/core/wasm/flowscope_wasm.d.ts app/public/wasm/ -cp packages/core/wasm/flowscope_wasm_bg.wasm.d.ts app/public/wasm/ - -# Copy to app's node_modules when using yarn workspace linking -# This ensures the app can resolve the WASM files from the linked package -if [ -d "app/node_modules/@pondpilot/flowscope-core/wasm" ]; then - echo "Copying WASM to app node_modules (workspace linking)..." - cp packages/core/wasm/flowscope_wasm_bg.wasm app/node_modules/@pondpilot/flowscope-core/wasm/ - cp packages/core/wasm/flowscope_wasm.js app/node_modules/@pondpilot/flowscope-core/wasm/ -else - echo "Skipping app node_modules copy (directory not found - expected for fresh installs)" -fi - echo "WASM build complete!" ls -la packages/core/wasm/