From 24ed665e21577dc500dfcaf9cd02d3871c0a92dd Mon Sep 17 00:00:00 2001 From: Dmitry Bolotin Date: Wed, 20 May 2026 13:32:39 +0200 Subject: [PATCH 1/4] feat(ts-builder): write dev-server sidecar from `serve --target block-ui` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switches the block-ui branch of `ts-builder serve` from a child-process Vite spawn to a programmatic `createServer` call. After Vite reports its resolved local URL, write `/dist/.dev-server` (a JSON sidecar with schema, url, pid) so the desktop app can pick up the live dev URL on the next block load. Cleanup on SIGINT / SIGTERM / exit / programmatic close. `browser` and `browser-lib` targets keep the existing spawn behavior — they don't need a sidecar. Adds `--sidecar-out ` for unusual block layouts. --- tools/ts-builder/src/commands/serve.ts | 92 +++++++++++++++++-- .../src/commands/utils/dev-server-sidecar.ts | 46 ++++++++++ tools/ts-builder/src/commands/utils/index.ts | 1 + 3 files changed, 132 insertions(+), 7 deletions(-) create mode 100644 tools/ts-builder/src/commands/utils/dev-server-sidecar.ts diff --git a/tools/ts-builder/src/commands/serve.ts b/tools/ts-builder/src/commands/serve.ts index 95b12504b6..efcdcb412a 100644 --- a/tools/ts-builder/src/commands/serve.ts +++ b/tools/ts-builder/src/commands/serve.ts @@ -1,18 +1,26 @@ import { Command } from "commander"; +import * as path from "node:path"; import { executeCommand, getConfigInfo, getGlobalOptions, getValidatedConfigPath, + installSidecarCleanup, + removeSidecar, requireTarget, resolveVite, validateTargetForBrowser, + writeSidecar, } from "./utils/index"; export const serveCommand = new Command("serve") .description("Start development server") .option("-p, --port ", "Port number") .option("--host ", "Host address") + .option( + "--sidecar-out ", + "Directory the dev-server sidecar is written into (default: ./dist for block-ui)", + ) .action(async (options, command) => { const globalOpts = getGlobalOptions(command); const target = requireTarget(globalOpts); @@ -25,17 +33,26 @@ export const serveCommand = new Command("serve") `Starting dev server for ${target} project${useSources ? " with sources condition" : ""}...`, ); - try { - const viteCommand = resolveVite(); - const viteArgs = ["dev"]; - const configInfo = getConfigInfo(target); - const configPath = getValidatedConfigPath(customServeConfig, configInfo!.filename); + const configInfo = getConfigInfo(target); + const configPath = getValidatedConfigPath(customServeConfig, configInfo!.filename); - viteArgs.push("--config", configPath); + if (target === "block-ui") { + await runBlockUiServe({ + configPath, + port: options.port ? Number(options.port) : undefined, + host: options.host, + useSources, + sidecarOut: options.sidecarOut, + }); + return; + } + // Other targets keep the existing CLI-spawn behavior — no sidecar is written. + try { + const viteCommand = resolveVite(); + const viteArgs = ["dev", "--config", configPath]; if (options.port) viteArgs.push("--port", options.port); if (options.host) viteArgs.push("--host", options.host); - const env = useSources ? { USE_SOURCES: "1" } : undefined; await executeCommand(viteCommand, viteArgs, env); } catch (error) { @@ -43,3 +60,64 @@ export const serveCommand = new Command("serve") process.exit(1); } }); + +interface BlockUiServeOptions { + configPath: string; + port?: number; + host?: string; + useSources?: boolean; + sidecarOut?: string; +} + +async function runBlockUiServe(opts: BlockUiServeOptions): Promise { + // Vite's config file reads USE_SOURCES from process.env, so set it before + // we createServer. + if (opts.useSources) { + process.env.USE_SOURCES = "1"; + } + + // Vite is a peer dep of ts-builder; resolve it from the consumer package + // so we use the same version the project's config is written against. + let vite: typeof import("vite"); + try { + vite = await import("vite"); + } catch (error) { + console.error("Failed to load `vite`. Ensure it's installed as a dependency.", error); + process.exit(1); + return; + } + + const server = await vite.createServer({ + configFile: opts.configPath, + server: { + port: opts.port, + host: opts.host, + }, + }); + + await server.listen(); + server.printUrls(); + + const url = server.resolvedUrls?.local?.[0]; + if (!url) { + console.warn("vite did not report a local URL; skipping dev-server sidecar."); + return; + } + + const sidecarDir = opts.sidecarOut + ? path.resolve(opts.sidecarOut) + : path.resolve(process.cwd(), "dist"); + const sidecarPath = path.join(sidecarDir, ".dev-server"); + + writeSidecar(sidecarPath, { schema: 1, url, pid: process.pid }); + installSidecarCleanup(sidecarPath); + console.log(`Wrote dev-server sidecar: ${sidecarPath}`); + + // Make Vite's own close path remove the sidecar too — covers programmatic + // shutdowns (e.g. test harnesses) where signal-based cleanup won't fire. + const origClose = server.close.bind(server); + server.close = async () => { + removeSidecar(sidecarPath); + return origClose(); + }; +} diff --git a/tools/ts-builder/src/commands/utils/dev-server-sidecar.ts b/tools/ts-builder/src/commands/utils/dev-server-sidecar.ts new file mode 100644 index 0000000000..18101ff1bf --- /dev/null +++ b/tools/ts-builder/src/commands/utils/dev-server-sidecar.ts @@ -0,0 +1,46 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +export interface DevServerSidecar { + schema: 1; + url: string; + pid: number; +} + +/** + * Atomically write the sidecar JSON file. The desktop app polls this file + * when loading a dev-v2 block; an atomic rename avoids the consumer reading + * a half-written file. + */ +export function writeSidecar(sidecarPath: string, payload: DevServerSidecar): void { + fs.mkdirSync(path.dirname(sidecarPath), { recursive: true }); + const tmp = sidecarPath + ".tmp"; + fs.writeFileSync(tmp, JSON.stringify(payload, null, 2)); + fs.renameSync(tmp, sidecarPath); +} + +export function removeSidecar(sidecarPath: string): void { + try { + fs.unlinkSync(sidecarPath); + } catch { + // ignore — already gone + } +} + +/** + * Install best-effort cleanup hooks. SIGINT/SIGTERM trigger an explicit + * unlink and then a clean process exit so any downstream `process.on("exit")` + * handlers also run. + */ +export function installSidecarCleanup(sidecarPath: string): void { + const cleanup = () => removeSidecar(sidecarPath); + process.on("exit", cleanup); + process.on("SIGINT", () => { + cleanup(); + process.exit(0); + }); + process.on("SIGTERM", () => { + cleanup(); + process.exit(0); + }); +} diff --git a/tools/ts-builder/src/commands/utils/index.ts b/tools/ts-builder/src/commands/utils/index.ts index 9998c0ef51..63a5198ae6 100644 --- a/tools/ts-builder/src/commands/utils/index.ts +++ b/tools/ts-builder/src/commands/utils/index.ts @@ -1,5 +1,6 @@ export * from "./command-runner"; export * from "./common-options"; export * from "./config-manager"; +export * from "./dev-server-sidecar"; export * from "./executable-resolver"; export * from "./path-utils"; From a3965ae2547419a345342716a843d49605de8ddf Mon Sep 17 00:00:00 2001 From: Dmitry Bolotin Date: Wed, 20 May 2026 21:21:39 +0200 Subject: [PATCH 2/4] fix(ts-builder): make `vite dev` actually load block UIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two `vite dev`-only relaxations so the dev-server-sidecar workflow can load the block at all over http: 1. `transformIndexHtml` plugin appends `'wasm-unsafe-eval'` to the `script-src` in the block's CSP meta tag. Production block UIs are served via `block-ui://` which is registered with `bypassCSP: true`, so the meta CSP never bites; http origin enforces it normally and the in-page WASM driver (PFrameSpec) cannot compile without this. CSP merging is by intersection — a response-header CSP cannot loosen a meta-tag CSP, so rewriting the HTML at serve time is the only mechanism. 2. `define: { "process.env": "({})" }` so transitive imports that touch `process.env.X` at module top level (e.g. pf-spec-driver/logging.ts) don't throw `process is not defined` in the browser. Rolldown strips these in production builds; `vite dev` does not. Both are dev-only (the plugin is `apply: "serve"`, the define is applied universally but the production rolldown pipeline overrides it). Documented inline with the migration plan: drop the bypassCSP hack on `block-ui://`, ship a canonical CSP in block templates that includes `'wasm-unsafe-eval'`, replace the blunt `process.env` substitution with a precise allowlist, and do a security review of the block runtime end-to-end before treating either as steady state. --- .../src/configs/utils/createViteDevConfig.ts | 119 +++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/tools/ts-builder/src/configs/utils/createViteDevConfig.ts b/tools/ts-builder/src/configs/utils/createViteDevConfig.ts index f888b7fad2..8b0f39cf8f 100644 --- a/tools/ts-builder/src/configs/utils/createViteDevConfig.ts +++ b/tools/ts-builder/src/configs/utils/createViteDevConfig.ts @@ -1,8 +1,112 @@ import vue from "@vitejs/plugin-vue"; import sourcemaps from "rollup-plugin-sourcemaps2"; -import type { ConfigEnv, UserConfig } from "vite"; +import type { ConfigEnv, Plugin, UserConfig } from "vite"; import commonjs from "vite-plugin-commonjs"; +/* + * ─────────────────────────────────────────────────────────────────────────── + * TEMPORARY DEV-MODE SECURITY RELAXATIONS — TRACK AND REMOVE + * ─────────────────────────────────────────────────────────────────────────── + * + * This file contains two `vite dev`-only hacks that weaken the security + * posture of the block UI runtime relative to production. They exist so the + * dev-server sidecar workflow (block author runs `ts-builder serve` and the + * desktop hot-loads the block from `http://localhost:/`) functions at + * all. Both are documented at their respective sites below. + * + * 1. `relaxCspForDevWasm` — rewrites the block's CSP meta tag to add + * `'wasm-unsafe-eval'` so in-page WebAssembly (PFrameSpec / SpecDriver) + * can compile. + * 2. `define: { "process.env": "({})" }` — masks all `process.env.X` + * references (including `NODE_ENV`) to `undefined` so transitive Node + * imports don't blow up on `process is not defined`. + * + * Both are dev-only — production block UIs are served via the `block-ui://` + * custom protocol, registered with `bypassCSP: true`, so the CSP is + * effectively absent in production, and rolldown strips `process.env.X` + * references at build time. + * + * FOLLOW-UP — DO NOT LEAVE THIS AS THE STEADY STATE: + * - Migrate the block template CSP (currently `script-src 'self' blob:`) + * to a canonical policy that lists every source the runtime legitimately + * uses (including `'wasm-unsafe-eval'`), so dev and prod share one CSP. + * - Once block-ui:// no longer needs `bypassCSP: true`, drop the bypass on + * the protocol registration in + * `core/platforma-desktop-app/packages/main/src/protocols/block-ui.ts`. + * - Re-enable strict CSP enforcement in dev by removing + * `relaxCspForDevWasm`. + * - Replace the blunt `process.env: ({})` substitution with a precise + * allowlist of vars the SDK genuinely reads (or eliminate the references + * entirely from browser-bound code paths — most are debug toggles like + * `MI_LOG_PFRAMES` and can move to a constructor option or a + * `globalThis` flag). + * - Run a security review of the block-ui runtime end-to-end: CSP, preload + * surface, IPC method allowlist, `webRequest` headers, WASM source set, + * `webSecurity` / `nodeIntegration` / `contextIsolation` settings on + * `webPreferencesForBlock`, and the dev-server sidecar trust gate + * (currently dev-v2 only — confirm sufficient for prod hardening). + * + * Tracking ticket: TODO — create one in MILAB before this lands. + * ─────────────────────────────────────────────────────────────────────────── + */ + +/** + * Rewrite the block's index.html CSP meta tag during `vite dev` so WebAssembly + * modules (e.g. `@milaboratories/pframes-rs-wasm`) can compile in the browser. + * + * Why this is necessary, and why no smaller fix works: + * + * - In production the block UI is served via the `block-ui://` custom + * protocol, which Electron registers with `bypassCSP: true`. The strict + * CSP in `index.html` (`script-src 'self' blob:`) never applies and the + * in-page WASM driver (PFrameSpec / `pf-spec-driver`) loads fine. + * + * - In `vite dev` the UI is served over `http://localhost:`. We have + * no equivalent privilege escape for `http:` — Electron honors the + * standard browser CSP semantics for that scheme. + * + * - The CSP that wins is the *intersection* of every source (meta tag + + * response header). Adding a permissive `Content-Security-Policy` + * response header via Vite middleware or + * `session.webRequest.onHeadersReceived` does not loosen the meta — it + * only tightens further. So a header-side fix cannot work. + * + * - CSP is parse-time baked. DOM mutation of the meta tag after load is + * ignored. Preload-side intervention runs after parse — too late. + * + * - That leaves rewriting the served HTML. Vite's `transformIndexHtml` hook + * is the canonical place. Scoped to `apply: "serve"` so production builds + * are unaffected. + * + * - WASM is in-page by deliberate architectural choice (see + * `sdk/ui-vue/src/internal/service_factories.ts`: `PFrameSpec` is the + * synchronous spec driver used inside Vue computed props; moving it to + * IPC would break the sync reactive chain). So we cannot side-step the + * CSP by relocating the WASM to preload/main. + * + * Narrowest fix: add `'wasm-unsafe-eval'` (WASM only, NOT JS `eval`). Do not + * use `'unsafe-eval'` here — that would also allow `eval()`. See the + * top-of-file follow-up block for the migration plan to remove this entirely. + */ +function relaxCspForDevWasm(): Plugin { + return { + name: "ts-builder:relax-csp-for-dev-wasm", + apply: "serve", + transformIndexHtml(html) { + const cspMetaRegex = + //i; + return html.replace(cspMetaRegex, (match, content: string) => { + if (content.includes("'wasm-unsafe-eval'")) return match; + const relaxed = content.replace( + /script-src([^;]*)/, + (m, srcs: string) => `script-src${srcs} 'wasm-unsafe-eval'`, + ); + return ``; + }); + }, + }; +} + export function createViteDevConfig({ mode, command }: ConfigEnv): UserConfig { const isProd = mode === "production"; const isServe = command === "serve"; @@ -17,6 +121,7 @@ export function createViteDevConfig({ mode, command }: ConfigEnv): UserConfig { plugins: [ vue(), ...(isServe ? [commonjs({ filter: (id) => id.includes("node_modules") })] : []), + relaxCspForDevWasm(), ], build: { target: ["chrome140"], @@ -32,6 +137,18 @@ export function createViteDevConfig({ mode, command }: ConfigEnv): UserConfig { }, define: { "import.meta.vitest": "undefined", + // `vite dev` does not strip `process.env.X` references the way the + // production rolldown build does. Transitive imports (e.g. + // `pf-spec-driver/logging.ts`, `pf-driver/logging.ts`) hit top-level + // `process.env.MI_LOG_PFRAMES` lookups and throw + // `ReferenceError: process is not defined` in the browser at module + // load, leaving the block view white. + // + // Inlining an empty object makes every `process.env.X` resolve to + // `undefined`. Side effect: `process.env.NODE_ENV` is also `undefined` + // in dev — fine for our current consumers, but flagged in the + // top-of-file follow-up block as a thing to tighten later. + "process.env": "({})", }, }; } From ebded7669b1bf07ed82201aa1bddae59b218b587 Mon Sep 17 00:00:00 2001 From: Dmitry Bolotin Date: Wed, 20 May 2026 21:53:27 +0200 Subject: [PATCH 3/4] fix(ts-builder): CSP meta regex was eating its own delimiter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first version of the `'wasm-unsafe-eval'` injector excluded both quote types from the content character class, so the capture group ended at the first single quote inside `content="script-src 'self' blob:"`. The replace never fired and the block view still hit `CompileError` on WebAssembly compile. CSP source keywords are always single-quoted inside a double-quoted content attribute. Restrict the regex to that shape — exclude only the delimiter (`[^"]*`). Verified end-to-end against `enter-numbers-v3` over `vite dev`: PFrameSpec / SpecDriver compiles, block renders, HMR confirmed by live-editing a .vue file with no reload. --- tools/ts-builder/src/configs/utils/createViteDevConfig.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/ts-builder/src/configs/utils/createViteDevConfig.ts b/tools/ts-builder/src/configs/utils/createViteDevConfig.ts index 8b0f39cf8f..e79d6c7cd7 100644 --- a/tools/ts-builder/src/configs/utils/createViteDevConfig.ts +++ b/tools/ts-builder/src/configs/utils/createViteDevConfig.ts @@ -93,8 +93,11 @@ function relaxCspForDevWasm(): Plugin { name: "ts-builder:relax-csp-for-dev-wasm", apply: "serve", transformIndexHtml(html) { + // CSP source keywords (`'self'`, `'wasm-unsafe-eval'`, ...) are + // single-quoted inside the double-quoted content attribute, so the + // content character class must exclude only the delimiter. const cspMetaRegex = - //i; + //i; return html.replace(cspMetaRegex, (match, content: string) => { if (content.includes("'wasm-unsafe-eval'")) return match; const relaxed = content.replace( From 54036b8b3cd4ea56d4a2862e8d84fc149bf5a586 Mon Sep 17 00:00:00 2001 From: Dmitry Bolotin Date: Wed, 20 May 2026 21:58:55 +0200 Subject: [PATCH 4/4] fix(ts-builder): register sidecar cleanup before Vite starts Node fires same-signal handlers in registration order. When the cleanup hooks were registered AFTER `vite.createServer().listen()`, Vite's own internal handlers (and anyone else hooking signals during init) ran first and could exit the process before our unlink ran, leaving stale sidecars behind. Restructure into a `setupSidecarLifecycle(path)` factory that: - Registers SIGINT/SIGTERM/SIGHUP/exit/uncaughtException up-front (before Vite has a chance to attach its own handlers). - Exposes `publish(payload)` for after the server URL is known. - Tracks a `live` flag so signal-triggered cleanup is idempotent and a no-op when nothing was written. - Still proxies through `server.close` so HMR-driven restarts and programmatic shutdowns also unlink. Verified end-to-end: direct `kill -TERM ` against the node process running `ts-builder serve` now removes the sidecar reliably. --- tools/ts-builder/src/commands/serve.ts | 25 ++++---- .../src/commands/utils/dev-server-sidecar.ts | 59 +++++++++++++++---- 2 files changed, 61 insertions(+), 23 deletions(-) diff --git a/tools/ts-builder/src/commands/serve.ts b/tools/ts-builder/src/commands/serve.ts index efcdcb412a..7a6f459a05 100644 --- a/tools/ts-builder/src/commands/serve.ts +++ b/tools/ts-builder/src/commands/serve.ts @@ -5,12 +5,10 @@ import { getConfigInfo, getGlobalOptions, getValidatedConfigPath, - installSidecarCleanup, - removeSidecar, requireTarget, resolveVite, + setupSidecarLifecycle, validateTargetForBrowser, - writeSidecar, } from "./utils/index"; export const serveCommand = new Command("serve") @@ -76,6 +74,14 @@ async function runBlockUiServe(opts: BlockUiServeOptions): Promise { process.env.USE_SOURCES = "1"; } + // Resolve sidecar path BEFORE starting Vite so lifecycle hooks register + // first — see `setupSidecarLifecycle` for the ordering rationale. + const sidecarDir = opts.sidecarOut + ? path.resolve(opts.sidecarOut) + : path.resolve(process.cwd(), "dist"); + const sidecarPath = path.join(sidecarDir, ".dev-server"); + const sidecar = setupSidecarLifecycle(sidecarPath); + // Vite is a peer dep of ts-builder; resolve it from the consumer package // so we use the same version the project's config is written against. let vite: typeof import("vite"); @@ -104,20 +110,15 @@ async function runBlockUiServe(opts: BlockUiServeOptions): Promise { return; } - const sidecarDir = opts.sidecarOut - ? path.resolve(opts.sidecarOut) - : path.resolve(process.cwd(), "dist"); - const sidecarPath = path.join(sidecarDir, ".dev-server"); - - writeSidecar(sidecarPath, { schema: 1, url, pid: process.pid }); - installSidecarCleanup(sidecarPath); + sidecar.publish({ schema: 1, url, pid: process.pid }); console.log(`Wrote dev-server sidecar: ${sidecarPath}`); // Make Vite's own close path remove the sidecar too — covers programmatic - // shutdowns (e.g. test harnesses) where signal-based cleanup won't fire. + // shutdowns (e.g. test harnesses) and HMR-driven server restarts where + // signal-based cleanup wouldn't fire. const origClose = server.close.bind(server); server.close = async () => { - removeSidecar(sidecarPath); + sidecar.remove(); return origClose(); }; } diff --git a/tools/ts-builder/src/commands/utils/dev-server-sidecar.ts b/tools/ts-builder/src/commands/utils/dev-server-sidecar.ts index 18101ff1bf..9096237d21 100644 --- a/tools/ts-builder/src/commands/utils/dev-server-sidecar.ts +++ b/tools/ts-builder/src/commands/utils/dev-server-sidecar.ts @@ -27,20 +27,57 @@ export function removeSidecar(sidecarPath: string): void { } } +export interface SidecarLifecycle { + /** Write the sidecar and mark it as live for cleanup. */ + publish(payload: DevServerSidecar): void; + /** Remove the sidecar explicitly (idempotent). */ + remove(): void; +} + /** - * Install best-effort cleanup hooks. SIGINT/SIGTERM trigger an explicit - * unlink and then a clean process exit so any downstream `process.on("exit")` - * handlers also run. + * Set up lifecycle hooks for a sidecar file BEFORE the server that owns it + * is started. + * + * Registration order matters. Node fires same-signal handlers in registration + * order. Vite's `createServer` / `listen` registers its own SIGINT/SIGHUP/etc. + * handlers that exit the process — if we wait until after `createServer` to + * register ours, Vite's may run first, exit synchronously, and our cleanup + * is missed. Install everything up-front, then `publish()` once the URL is + * known. `remove()` and the signal-triggered cleanup are idempotent. */ -export function installSidecarCleanup(sidecarPath: string): void { - const cleanup = () => removeSidecar(sidecarPath); +export function setupSidecarLifecycle(sidecarPath: string): SidecarLifecycle { + let live = false; + + const cleanup = () => { + if (!live) return; + live = false; + removeSidecar(sidecarPath); + }; + + const exitOn = (signal: NodeJS.Signals) => { + process.on(signal, () => { + cleanup(); + // Re-raise the default behavior with a clean exit code so callers + // (shell, pnpm) see the expected termination. + process.exit(0); + }); + }; + + exitOn("SIGINT"); + exitOn("SIGTERM"); + exitOn("SIGHUP"); process.on("exit", cleanup); - process.on("SIGINT", () => { - cleanup(); - process.exit(0); - }); - process.on("SIGTERM", () => { + process.on("uncaughtException", (err) => { cleanup(); - process.exit(0); + console.error(err); + process.exit(1); }); + + return { + publish(payload) { + writeSidecar(sidecarPath, payload); + live = true; + }, + remove: cleanup, + }; }