From b0e36e76e777a1d4a55e8d253ef9b3c9ec87a6da Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Thu, 30 Jul 2026 12:23:12 -0500 Subject: [PATCH 01/61] studio: add the document-viewer wasm and build plumbing The renderer runs from file:// in packaged builds, where fetch() of a bundled asset is blocked, so the four viewer engines' wasm binaries are copied into resources/wasm/ at build time and served over the app protocol. That scheme is never the renderer's own origin, so every such fetch is cross-origin and needs corsEnabled on the scheme itself, independent of response headers. The parser workers are module workers whose entries code-split, so the renderer switches to the ES worker format and excludes the three libraries from dev pre-bundling, which would otherwise rewrite import.meta.url to a cache directory where the sibling worker file does not exist. --- apps/studio/.gitignore | 3 + apps/studio/electron.vite.config.ts | 93 +- apps/studio/package.json | 25 +- .../studio/src/client/lib/document-viewers.ts | 72 ++ apps/studio/src/electron-main/index.ts | 5 + .../src/electron-main/lib/app-protocol.ts | 46 + .../src/electron-main/lib/resource-path.ts | 20 + .../electron-main/lib/setup-bin-directory.ts | 29 +- apps/studio/src/index.html | 7 +- docs/plans/active/document-viewers.md | 218 +++++ patches/@extend-ai__react-docx@0.8.1.patch | 70 ++ pnpm-lock.yaml | 882 +++++++++++++++++- pnpm-workspace.yaml | 9 + 13 files changed, 1438 insertions(+), 41 deletions(-) create mode 100644 apps/studio/src/client/lib/document-viewers.ts create mode 100644 apps/studio/src/electron-main/lib/resource-path.ts create mode 100644 docs/plans/active/document-viewers.md create mode 100644 patches/@extend-ai__react-docx@0.8.1.patch diff --git a/apps/studio/.gitignore b/apps/studio/.gitignore index ed5e01c61..74ee29a92 100644 --- a/apps/studio/.gitignore +++ b/apps/studio/.gitignore @@ -15,6 +15,9 @@ dist/ # Vendored uv binary (downloaded by scripts/download-uv.ts) resources/uv/ +# Document-viewer WASM binaries (copied from node_modules by the main build) +resources/wasm/ + # Turbo .turbo diff --git a/apps/studio/electron.vite.config.ts b/apps/studio/electron.vite.config.ts index d5dc3dbd7..1008bfba2 100644 --- a/apps/studio/electron.vite.config.ts +++ b/apps/studio/electron.vite.config.ts @@ -44,21 +44,79 @@ const resolve = { }, }; +// `buildStart` fires on every watch rebuild, so re-copying ~11MB of WASM each +// time is skipped when the destination already holds the current bytes. The +// mtime has to match exactly rather than merely be newer: pnpm hard links from +// its store, which carries the store's timestamps, so a reinstall can drop in a +// same-size asset that is older than what was copied before. Both sides are +// truncated to whole milliseconds because the stamp below goes through a `Date`, +// which drops the sub-millisecond precision a source file can carry. +async function copyVendorAsset({ from, to }: { from: string; to: string }) { + const source = await fs.stat(from); + try { + const target = await fs.stat(to); + if ( + target.size === source.size && + Math.trunc(target.mtimeMs) === Math.trunc(source.mtimeMs) + ) { + return; + } + } catch { + // No destination yet. + } + await fs.mkdir(path.dirname(to), { recursive: true }); + // Stage then rename so an interrupted or concurrent build cannot leave a torn + // binary in place, and stamp the source mtime so the skip check above holds. + const staging = `${to}.tmp`; + await fs.copyFile(from, staging); + await fs.utimes(staging, source.atime, source.mtime); + await fs.rename(staging, to); +} + +// Registered on the main build alone. Every `electron-vite dev` and `build` +// invocation builds main, and the destinations are read by the main process, so +// a second registration would only add copies racing each other. function copyVendorAssets(): Plugin { const require = createRequire(import.meta.url); + const resourcesDir = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "resources", + ); + const wasmDir = path.join(resourcesDir, "wasm"); + // @embedpdf/pdfium is a transitive dependency of @embedpdf/engines, so under + // pnpm's isolated layout it is only resolvable from the engines package. + const embedPdfEnginesDir = path.dirname(require.resolve("@embedpdf/engines")); const assets = [ { from: require.resolve("@tailwindcss/browser"), - to: path.join( - path.dirname(fileURLToPath(import.meta.url)), - "resources/tailwind-browser.js", - ), + to: path.join(resourcesDir, "tailwind-browser.js"), + }, + // The renderer runs from `file://` in production, where `fetch()` of + // bundled assets is blocked, so these are served over the app protocol + // from `resources/` instead of being emitted into the renderer bundle. + { + from: require.resolve("@embedpdf/pdfium/pdfium.wasm", { + paths: [embedPdfEnginesDir], + }), + to: path.join(wasmDir, "pdfium.wasm"), + }, + { + from: require.resolve("@extend-ai/react-docx/docx_wasm_bg.wasm"), + to: path.join(wasmDir, "docx.wasm"), + }, + { + from: require.resolve("@extend-ai/react-pptx/pptx_wasm_bg.wasm"), + to: path.join(wasmDir, "pptx.wasm"), + }, + { + from: require.resolve("@extend-ai/react-xlsx/duke_sheets_wasm_bg.wasm"), + to: path.join(wasmDir, "xlsx.wasm"), }, ]; return { async buildStart() { for (const { from, to } of assets) { - await fs.copyFile(from, to); + await copyVendorAsset({ from, to }); } }, name: "copy-vendor-assets", @@ -216,6 +274,27 @@ export default defineConfig(({ command }) => { sourcemap: isProduction, watch: {}, // Enable hot reloading }, + // Each document viewer spawns its parser worker with `new Worker(new + // URL("./x.js", import.meta.url), { type: "module" })`. Pre-bundling + // rewrites `import.meta.url` to the dependency cache, where the sibling + // worker file does not exist, so the dev server answers with the SPA + // fallback HTML and the worker dies on load. Serving these unbundled + // lets Vite's worker transform resolve the real entry. + optimizeDeps: { + exclude: [ + "@extend-ai/react-docx", + "@extend-ai/react-pptx", + "@extend-ai/react-xlsx", + ], + // Excluding a package stops its CommonJS-only imports from being + // converted to ESM, so those are pre-bundled on their own. + include: [ + "@extend-ai/react-docx > utif", + "@extend-ai/react-pptx > regl", + "@extend-ai/react-xlsx > regl", + "react-dom/server", + ], + }, plugins: [ ...(isAnalyzing ? [analyzer({ analyzerMode: "json" })] : []), createValidateProductionEnv("renderer"), @@ -235,6 +314,10 @@ export default defineConfig(({ command }) => { ], resolve, root: path.resolve("src"), + // The document viewers' parser workers are module workers whose entries + // code-split, which the default IIFE worker format cannot express, so the + // build fails without this. + worker: { format: "es" }, }, }; }); diff --git a/apps/studio/package.json b/apps/studio/package.json index f3e60ada6..efccd7733 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -14,15 +14,15 @@ "main": "./out/main/index.js", "files": [], "scripts": { - "build": "electron-vite build", + "build": "node --max-old-space-size=8192 ./node_modules/electron-vite/bin/electron-vite.js build", "build:analyze": "cross-env ANALYZE_BUILD=true pnpm run build", - "build:dev": "electron-vite build --mode development --sourcemap", + "build:dev": "node --max-old-space-size=8192 ./node_modules/electron-vite/bin/electron-vite.js build --mode development --sourcemap", "build:env:unsigned": "pnpm run build:vite && electron-builder --config.mac.identity=null --config.win.signAndEditExecutable=false --dir", "build:install-app-deps": "electron-builder install-app-deps", "build:mac:dev": "pnpm run build:install-app-deps && pnpm run build:dev && electron-builder --mac", "build:mac:local": "op run --no-masking --env-file=./.env.build --env-file=./.env.production -- pnpm run build:vite && electron-builder --mac", "build:mac:unsigned": "cross-env TARGET_PLATFORM=darwin pnpm run build:vite && electron-builder --mac --config.mac.identity=null --dir", - "build:test": "electron-vite build --mode test", + "build:test": "node --max-old-space-size=8192 ./node_modules/electron-vite/bin/electron-vite.js build --mode test", "build:unpack": "pnpm run build:vite && electron-builder --dir", "build:vite": "pnpm run build:install-app-deps && pnpm run uv:download && pnpm run build", "build:win:unsigned": "cross-env TARGET_PLATFORM=win32 pnpm run build:vite && electron-builder --win --config.win.signAndEditExecutable=false --dir", @@ -99,6 +99,23 @@ "zod": "catalog:" }, "devDependencies": { + "@embedpdf/core": "^2.14.4", + "@embedpdf/engines": "^2.14.4", + "@embedpdf/models": "^2.14.4", + "@embedpdf/plugin-document-manager": "^2.14.4", + "@embedpdf/plugin-interaction-manager": "^2.14.4", + "@embedpdf/plugin-render": "^2.14.4", + "@embedpdf/plugin-rotate": "^2.14.4", + "@embedpdf/plugin-scroll": "^2.14.4", + "@embedpdf/plugin-search": "^2.14.4", + "@embedpdf/plugin-selection": "^2.14.4", + "@embedpdf/plugin-thumbnail": "^2.14.4", + "@embedpdf/plugin-tiling": "^2.14.4", + "@embedpdf/plugin-viewport": "^2.14.4", + "@embedpdf/plugin-zoom": "^2.14.4", + "@extend-ai/react-docx": "0.8.1", + "@extend-ai/react-pptx": "0.1.2", + "@extend-ai/react-xlsx": "0.15.0", "@instrument-org/eslint-config": "workspace:*", "@instrument-org/typescript-config": "workspace:*", "@julr/vite-plugin-validate-env": "^2.2.0", @@ -144,6 +161,7 @@ "@testing-library/react": "^16.3.2", "@types/ms": "^2.1.0", "@types/node": "catalog:", + "@types/papaparse": "^5.5.2", "@types/react": "catalog:", "@types/react-dom": "catalog:", "@types/semver": "^7.7.0", @@ -166,6 +184,7 @@ "jsdom": "^29.1.1", "katex": "^0.16.27", "motion": "^12.35.2", + "papaparse": "^5.5.4", "playwright": "^1.57.0", "posthog-js": "^1.374.2", "prosemirror-commands": "^1.7.1", diff --git a/apps/studio/src/client/lib/document-viewers.ts b/apps/studio/src/client/lib/document-viewers.ts new file mode 100644 index 000000000..2aa91acea --- /dev/null +++ b/apps/studio/src/client/lib/document-viewers.ts @@ -0,0 +1,72 @@ +import { APP_PROTOCOL } from "@instrument-org/shared"; +import { lazy } from "react"; + +// The renderer is served from `file://` in production, where `fetch()` of a +// bundled asset is blocked. The binaries are copied into `resources/wasm/` at +// build time and served from the privileged app protocol instead. +const wasmUrl = (filename: string) => `${APP_PROTOCOL}://wasm/${filename}`; + +export const PDFIUM_WASM_URL = wasmUrl("pdfium.wasm"); + +// Every host that mounts a viewer goes through these handles, so a viewer can +// never reach its parser with the library's default wasm source: that one +// resolves against `import.meta.url` and is loaded through a dynamic `import()` +// of a `file:` URL, which the renderer's CSP rejects. +// +// Each viewer library is reached through a dynamic import so that it stays in +// its own chunk; a static import here would pull all of them into the entry +// chunk, since this module is loaded during renderer startup. The libraries +// read the configured source when they first parse a document, so setting it +// any time before the viewer mounts is enough. +export const LazyDocxViewer = lazy(async () => { + const [, module] = await Promise.all([ + configureDocxWasmSource(), + import("@/client/components/document-viewers/docx-viewer"), + ]); + return { default: module.DocxViewer }; +}); + +// The PDF engine takes its wasm URL as an argument rather than through global +// configuration, so `pdf-engine.ts` passes `PDFIUM_WASM_URL` directly. +export const LazyPdfViewer = lazy(async () => { + const module = await import("@/client/components/document-viewers/pdf-viewer"); + return { default: module.PdfViewer }; +}); + +export const LazyPptxViewer = lazy(async () => { + const [, module] = await Promise.all([ + configurePptxWasmSource(), + import("@/client/components/document-viewers/pptx-viewer"), + ]); + return { default: module.PptxViewer }; +}); + +export const LazyXlsxViewer = lazy(async () => { + const [, module] = await Promise.all([ + configureXlsxWasmSource(), + import("@/client/components/document-viewers/xlsx-viewer"), + ]); + return { default: module.XlsxViewer }; +}); + +// CSV parses in-process with no wasm, but stays lazy so papaparse and the grid +// only load when a delimited file is actually opened. +export const LazyCsvViewer = lazy(async () => { + const module = await import("@/client/components/document-viewers/csv-viewer"); + return { default: module.CsvViewer }; +}); + +async function configureDocxWasmSource() { + const { setWasmSource } = await import("@extend-ai/react-docx"); + setWasmSource(wasmUrl("docx.wasm")); +} + +async function configurePptxWasmSource() { + const { setWasmSource } = await import("@extend-ai/react-pptx"); + setWasmSource(wasmUrl("pptx.wasm")); +} + +async function configureXlsxWasmSource() { + const { setWasmSource } = await import("@extend-ai/react-xlsx"); + setWasmSource(wasmUrl("xlsx.wasm")); +} diff --git a/apps/studio/src/electron-main/index.ts b/apps/studio/src/electron-main/index.ts index 2057112f1..4249eef11 100644 --- a/apps/studio/src/electron-main/index.ts +++ b/apps/studio/src/electron-main/index.ts @@ -55,6 +55,11 @@ if (gotTheLock) { protocol.registerSchemesAsPrivileged([ { privileges: { + // The renderer's origin is never this scheme, so the document viewers + // fetching their WASM from it is always a cross-origin request. + // Chromium rejects those for custom schemes unless the scheme itself + // opts in, regardless of the response's CORS headers. + corsEnabled: true, secure: true, standard: true, supportFetchAPI: true, diff --git a/apps/studio/src/electron-main/lib/app-protocol.ts b/apps/studio/src/electron-main/lib/app-protocol.ts index 7f404ebbf..3ed8eed32 100644 --- a/apps/studio/src/electron-main/lib/app-protocol.ts +++ b/apps/studio/src/electron-main/lib/app-protocol.ts @@ -4,9 +4,20 @@ import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; +import { getResourcePath } from "./resource-path"; + const FILE_OPEN_ICON_HOST = "file-open-icon"; +const WASM_HOST = "wasm"; const ICON_SIZE = 64; const ICON_FILENAME_PATTERN = /^[a-f0-9]{64}\.png$/; +// The renderer runs from `file://` in production, where bundled assets cannot +// be fetched, so the document viewers load their WASM from here instead. +const WASM_FILENAMES = new Set([ + "docx.wasm", + "pdfium.wasm", + "pptx.wasm", + "xlsx.wasm", +]); const IMMUTABLE_CACHE_SECONDS = 365 * 24 * 60 * 60; export function registerAppProtocol() { @@ -16,6 +27,9 @@ export function registerAppProtocol() { case FILE_OPEN_ICON_HOST: { return handleFileOpenIconRequest({ request, url }); } + case WASM_HOST: { + return handleWasmRequest({ request, url }); + } default: { return new Response(null, { status: 404 }); } @@ -72,6 +86,38 @@ async function handleFileOpenIconRequest({ } } +async function handleWasmRequest({ + request, + url, +}: { + request: Request; + url: URL; +}) { + const filename = url.pathname.slice(1); + if (request.method !== "GET" || !WASM_FILENAMES.has(filename)) { + return new Response(null, { status: 404 }); + } + + try { + const wasm = await fs.readFile(getResourcePath(WASM_HOST, filename)); + return new Response(wasm, { + headers: { + // The renderer's own origin is `file://` (or the dev server) and never + // this scheme, so every request here is cross-origin and `fetch()` + // would fail CORS without this. The bytes ship with the app and the + // scheme is only reachable from the app's own web contents. + "Access-Control-Allow-Origin": "*", + // The binaries ship with the app build, so they only change when the + // app itself is replaced and the renderer is reloaded from scratch. + "Cache-Control": `public, max-age=${IMMUTABLE_CACHE_SECONDS}, immutable`, + "Content-Type": "application/wasm", + }, + }); + } catch { + return new Response(null, { status: 404 }); + } +} + // Icons are content-addressed and tiny (one 64px PNG per distinct app icon on // the machine), shared across every file type that resolves to that app, so the // store stays small in practice and is intentionally never evicted. diff --git a/apps/studio/src/electron-main/lib/resource-path.ts b/apps/studio/src/electron-main/lib/resource-path.ts new file mode 100644 index 000000000..f029afb3c --- /dev/null +++ b/apps/studio/src/electron-main/lib/resource-path.ts @@ -0,0 +1,20 @@ +import { app } from "electron"; +import path from "node:path"; + +// electron-builder unpacks `resources/**` and the native parts of +// `node_modules` via `asarUnpack`, so in a packaged app those trees live next +// to the asar rather than inside it. In dev both sit in the package root. +export function getAppUnpackedPath(...parts: string[]): string { + const appPath = app.getAppPath(); + const fullPath = path.join(appPath, ...parts); + + if (app.isPackaged && appPath.endsWith(".asar")) { + return fullPath.replace(/app\.asar([/\\])/, "app.asar.unpacked$1"); + } + + return fullPath; +} + +export function getResourcePath(...parts: string[]): string { + return getAppUnpackedPath("resources", ...parts); +} diff --git a/apps/studio/src/electron-main/lib/setup-bin-directory.ts b/apps/studio/src/electron-main/lib/setup-bin-directory.ts index 1197d3de9..0633d643e 100644 --- a/apps/studio/src/electron-main/lib/setup-bin-directory.ts +++ b/apps/studio/src/electron-main/lib/setup-bin-directory.ts @@ -4,6 +4,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { captureServerException } from "./capture-server-exception"; +import { getAppUnpackedPath, getResourcePath } from "./resource-path"; const BIN_DIR_NAME = "bin"; @@ -21,20 +22,11 @@ export function getPNPMBinPath(): string { return getNodeModulePath("pnpm", "bin", "pnpm.mjs"); } -// The uv binary is vendored into `resources/uv/` (see scripts/download-uv.ts), -// which electron-builder bundles and unpacks via `asarUnpack: ["resources/**"]`, -// so it is deep-signed alongside the app. Resolve it from the unpacked tree in -// prod and from the repo `resources/` dir in dev. Mirrors getNodeModulePath. +// The uv binary is vendored into `resources/uv/` (see scripts/download-uv.ts) +// so it is deep-signed alongside the app. export function getUvBinPath(): string { const binaryName = process.platform === "win32" ? "uv.exe" : "uv"; - const appPath = app.getAppPath(); - const uvPath = path.join(appPath, "resources", "uv", binaryName); - - if (app.isPackaged && appPath.endsWith(".asar")) { - return uvPath.replace(/app\.asar([/\\])/, "app.asar.unpacked$1"); - } - - return uvPath; + return getResourcePath("uv", binaryName); } // Added to PATH so that child processes (the users's apps) can access the binaries @@ -148,18 +140,7 @@ function getBinDirectoryPath(): string { } function getNodeModulePath(...parts: string[]): string { - const appPath = app.getAppPath(); - const modulePath = path.join(appPath, "node_modules", ...parts); - - if (app.isPackaged && appPath.endsWith(".asar")) { - const unpackedPath = modulePath.replace( - /app\.asar([/\\])/, - "app.asar.unpacked$1", - ); - return unpackedPath; - } - - return modulePath; + return getAppUnpackedPath("node_modules", ...parts); } // Since @vscode/ripgrep 1.18.0 the binary ships in a per-platform package diff --git a/apps/studio/src/index.html b/apps/studio/src/index.html index 64c080a70..4b349ab20 100644 --- a/apps/studio/src/index.html +++ b/apps/studio/src/index.html @@ -5,14 +5,15 @@