diff --git a/examples/README.md b/examples/README.md index 8e921dc..cdf9438 100644 --- a/examples/README.md +++ b/examples/README.md @@ -13,6 +13,7 @@ These examples import Bunmaska via a relative path (`../../src/main`) because th live inside the repo. In your own project you would `import { app, BrowserWindow } from 'bunmaska'` instead — see `bunmaska init` for a scaffold. -> Note on preloads: a preload script is injected into the page's isolated world -> *verbatim*, so it must be plain JavaScript and uses the injected `contextBridge` -> and `__bunmaska` globals rather than `import`. See `ipc-demo/preload.js`. +> Note on preloads: a preload script runs in the page's isolated world. It is +> bundled before injection, so you can `import` modules — just keep it browser code +> (no Node APIs) and use the injected `contextBridge` and `__bunmaska` globals. See +> `ipc-demo/preload.js`. diff --git a/examples/ipc-demo/preload.js b/examples/ipc-demo/preload.js index 38411b3..1b7385a 100644 --- a/examples/ipc-demo/preload.js +++ b/examples/ipc-demo/preload.js @@ -1,5 +1,6 @@ // Runs in Bunmaska's isolated preload world (Electron contextIsolation). It is -// injected verbatim, so keep it plain JS. Two globals are available here: +// bundled before injection, so you can import modules — keep it browser code +// (no Node APIs). Two globals are available here: // contextBridge.exposeInMainWorld(key, api) — expose a safe surface to the page // __bunmaska.invoke(channel, ...args) — call an ipcMain.handle handler contextBridge.exposeInMainWorld('api', { diff --git a/src/cli/app-assets.ts b/src/cli/app-assets.ts index 5129fc6..b708ad2 100644 --- a/src/cli/app-assets.ts +++ b/src/cli/app-assets.ts @@ -1,5 +1,10 @@ -import { cpSync, existsSync, readdirSync } from 'node:fs'; +import { cpSync, existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, extname, join, resolve, sep } from 'node:path'; +import { + defaultPreloadBundler, + type PreloadBundler, + usesModuleSyntax, +} from '../common/preload-bundle'; const COMPILED_SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts']); @@ -39,3 +44,34 @@ export const copyAppAssets = (entry: string, destination: string): string[] => { } return copied; }; + +/** Shipped asset names treated as the renderer preload, by convention. */ +const PRELOAD_ASSET = /^preload\.(?:js|mjs|cjs)$/i; + +/** + * Bundle any shipped `preload.*` asset that uses `import`/`export` into a + * self-contained classic script, in place, so a packaged app's preload runs the + * same as it does under `bunmaska dev` — a preload is injected as a CLASSIC script + * (no module mode), so a raw `import` would throw and silently kill `window.api`. + * Plain preloads are left untouched. `names` is typically the {@link copyAppAssets} + * return value. Returns the names rewritten. + */ +export const bundlePreloadAssets = ( + destination: string, + names: readonly string[], + bundler: PreloadBundler = defaultPreloadBundler, +): string[] => { + const rewritten: string[] = []; + for (const name of names) { + if (!PRELOAD_ASSET.test(name)) { + continue; + } + const path = join(destination, name); + if (!usesModuleSyntax(readFileSync(path, 'utf8'))) { + continue; + } + writeFileSync(path, bundler.bundle(resolve(path))); + rewritten.push(name); + } + return rewritten; +}; diff --git a/src/cli/build-linux.ts b/src/cli/build-linux.ts index d5c3814..f79d053 100644 --- a/src/cli/build-linux.ts +++ b/src/cli/build-linux.ts @@ -14,7 +14,7 @@ import { chmodSync, copyFileSync, existsSync, mkdirSync, writeFileSync } from 'n import { dirname, join, posix } from 'node:path'; import { isSystemEngine, parseEngineId } from '../common/engine-id'; import { BUNMASKA_VERSION } from '../common/version'; -import { copyAppAssets } from './app-assets'; +import { bundlePreloadAssets, copyAppAssets } from './app-assets'; import { bundleIdSlug } from './build-macos'; export type LinuxLayout = { @@ -225,8 +225,10 @@ export const buildLinuxApp = async (opts: BuildLinuxAppOptions): Promise => await compileBinary(opts.entry, layout.executablePath); chmodSync(layout.executablePath, 0o755); - // Ship the entry's runtime assets (the page, the preload) beside the binary. - copyAppAssets(opts.entry, layout.macosDir); + // Ship the entry's runtime assets (the page, the preload) beside the binary, then + // bundle a module-using preload so it runs as a classic script in the packaged app. + bundlePreloadAssets(layout.macosDir, copyAppAssets(opts.entry, layout.macosDir)); let iconFile: string | undefined; if (opts.icon !== undefined) { diff --git a/src/cli/build-windows.ts b/src/cli/build-windows.ts index ef5222e..320a4fa 100644 --- a/src/cli/build-windows.ts +++ b/src/cli/build-windows.ts @@ -18,7 +18,7 @@ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { BUNMASKA_VERSION } from '../common/version'; -import { copyAppAssets } from './app-assets'; +import { bundlePreloadAssets, copyAppAssets } from './app-assets'; import { bundleIdSlug } from './build-macos'; import { buildZipArchive, type ZipEntry } from './zip'; @@ -213,8 +213,9 @@ export const buildWindowsApp = async ( }; await compileWindowsBinary(opts.entry, layout.exePath, meta); - // Ship the entry's runtime assets (the page, the preload) beside the binary. - copyAppAssets(opts.entry, layout.appDir); + // Ship the entry's runtime assets (the page, the preload) beside the binary, then + // bundle a module-using preload so it runs as a classic script in the packaged app. + bundlePreloadAssets(layout.appDir, copyAppAssets(opts.entry, layout.appDir)); // Bake the engine-id the app pins, read at launch by the engine resolver. writeFileSync(layout.engineIdPath, `${opts.engineId ?? 'system'}\n`); diff --git a/src/cli/dev.ts b/src/cli/dev.ts index 8e100bb..a31edf0 100644 --- a/src/cli/dev.ts +++ b/src/cli/dev.ts @@ -1,22 +1,25 @@ /** - * `bunmaska dev` — run the app and restart it when source files change. + * `bunmaska dev` — run the app and react to source changes. * * The entry is taken from the argument or `bunmaska.config.ts`. A recursive watch - * over the project triggers a debounced restart of the `bun run ` child, - * ignoring `node_modules`, VCS, build output and dotfiles. The restart - * supervisor takes injectable spawn/watch/timer seams so its behaviour is + * over the project CLASSIFIES each change: a TypeScript source is compiled into + * the main process, so it triggers a (debounced) restart of the `bun run ` + * child; any other watched file is a renderer asset (the page, styles, the + * preload), so it triggers a live RELOAD of the open windows instead — no restart, + * no window reopening. `node_modules`, VCS, build output and dotfiles are ignored. + * The supervisor takes injectable spawn/watch/timer seams so its behaviour is * unit-testable without real processes or the filesystem. */ import { watch as fsWatch } from 'node:fs'; -import { resolve } from 'node:path'; +import { extname, resolve } from 'node:path'; import type { BunmaskaConfig } from '../common/config-schema'; import { InvalidArgumentError } from '../common/errors'; /** The entry used when neither an argument nor a config entry is given. */ export const DEV_DEFAULT_ENTRY = 'src/main.ts'; -/** Default debounce window (ms) collapsing a burst of file changes into one restart. */ +/** Default debounce window (ms) collapsing a burst of file changes into one action. */ export const DEV_DEBOUNCE_MS = 120; /** @@ -28,22 +31,37 @@ export const resolveDevEntry = (config: BunmaskaConfig, explicit?: string): stri const IGNORED_SEGMENTS: ReadonlySet = new Set(['node_modules', '.git', 'dist']); +/** TypeScript is compiled into the main process, so a change there restarts it. */ +const MAIN_SOURCE_EXTENSIONS: ReadonlySet = new Set(['.ts', '.tsx', '.mts', '.cts']); + +/** What a watched change should trigger: a full restart, a live reload, or nothing. */ +export type ChangeAction = 'restart' | 'reload' | 'ignore'; + /** - * Whether a changed path (relative to the watched root) should trigger a - * restart. Ignores dependency/VCS/build directories and dotfiles (which catch - * editor swap files). Pure. + * Classify a changed path (relative to the watched root). Dependency/VCS/build + * directories and dotfiles (which catch editor swap files) are ignored; a + * TypeScript source restarts the main process; anything else is a renderer asset + * (page, styles, preload) and live-reloads the open windows. Pure. */ -export const shouldRestart = (relPath: string): boolean => { +export const classifyChange = (relPath: string): ChangeAction => { const parts = relPath.split(/[\\/]/).filter((p) => p.length > 0); if (parts.some((p) => IGNORED_SEGMENTS.has(p))) { - return false; + return 'ignore'; } const base = parts[parts.length - 1] ?? ''; - return base.length > 0 && !base.startsWith('.'); + if (base.length === 0 || base.startsWith('.')) { + return 'ignore'; + } + return MAIN_SOURCE_EXTENSIONS.has(extname(base).toLowerCase()) ? 'restart' : 'reload'; }; /** A running child app process. */ -export type DevChild = { readonly kill: () => void }; +export type DevChild = { + /** Terminate the child. */ + readonly kill: () => void; + /** Ask the running child to live-reload its open windows (a renderer-only change). */ + readonly reload: () => void; +}; /** A filesystem watcher that can be torn down. */ export type DevWatcher = { readonly close: () => void }; /** Minimal timer seam (defaults to global setTimeout/clearTimeout). */ @@ -62,9 +80,10 @@ export type DevDeps = { }; /** - * Supervises a `bun run ` child: spawns it on construction, restarts it - * (debounced) on a relevant file change, and tears everything down on - * {@link stop}. + * Supervises a `bun run ` child: spawns it on construction, then on a + * relevant file change either restarts it (a main-process source) or asks it to + * live-reload (a renderer asset), debounced; tears everything down on + * {@link stop}. A restart supersedes a reload coalesced into the same window. */ export class DevSupervisor { readonly #entry: string; @@ -73,9 +92,12 @@ export class DevSupervisor { #child: DevChild; readonly #watcher: DevWatcher; #pending: unknown; + #pendingAction: 'restart' | 'reload' | undefined; #stopped = false; /** Number of times the child has been (re)started, including the first spawn. */ starts = 1; + /** Number of live reloads requested. */ + reloads = 0; constructor(dir: string, entry: string, deps: DevDeps) { this.#entry = entry; @@ -88,26 +110,41 @@ export class DevSupervisor { } #onChange(relPath: string): void { - if (this.#stopped || !shouldRestart(relPath)) { + if (this.#stopped) { return; } + const action = classifyChange(relPath); + if (action === 'ignore') { + return; + } + // A restart subsumes a reload coalesced into the same debounce window. + this.#pendingAction = + this.#pendingAction === 'restart' || action === 'restart' ? 'restart' : 'reload'; if (this.#pending !== undefined) { this.#deps.timers.clear(this.#pending); } this.#pending = this.#deps.timers.set(() => { - this.#restart(); + this.#fire(); }, this.#debounceMs); } - #restart(): void { + #fire(): void { this.#pending = undefined; if (this.#stopped) { return; } - this.#child.kill(); - this.#child = this.#deps.spawn(this.#entry); - this.starts += 1; - this.#deps.log(`restarted (${this.#entry})`); + const action = this.#pendingAction ?? 'restart'; + this.#pendingAction = undefined; + if (action === 'restart') { + this.#child.kill(); + this.#child = this.#deps.spawn(this.#entry); + this.starts += 1; + this.#deps.log(`restarted (${this.#entry})`); + } else { + this.#child.reload(); + this.reloads += 1; + this.#deps.log('reloaded'); + } } /** Stop watching and kill the child. Idempotent. */ @@ -135,14 +172,27 @@ const defaultTimers: DevTimers = { /** Production seams: real `bun run` children and a recursive filesystem watch. */ export const defaultDevDeps = (cwd: string, log: (message: string) => void): DevDeps => ({ spawn: (entry) => { + // `BUNMASKA_DEV` switches on the app's stdin reload listener; a piped stdin is + // how the supervisor delivers reload requests to it. const proc = Bun.spawn(['bun', 'run', entry], { cwd, - stdio: ['inherit', 'inherit', 'inherit'], + env: { ...process.env, BUNMASKA_DEV: '1' }, + stdin: 'pipe', + stdout: 'inherit', + stderr: 'inherit', }); return { kill: () => { proc.kill(); }, + reload: () => { + try { + proc.stdin.write('reload\n'); + proc.stdin.flush(); + } catch { + // The child may be mid-exit; a dropped reload is harmless. + } + }, }; }, watch: (dir, onChange) => { diff --git a/src/cli/init.ts b/src/cli/init.ts index 3c94385..07b1396 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -87,7 +87,8 @@ app.on('window-all-closed', () => { const preloadJs = (): string => `// Runs in Bunmaska's isolated preload world (Electron contextIsolation). It is -// injected verbatim — keep it plain JS. Two globals are available here: +// bundled before injection, so you can import modules here — keep it browser code +// (no Node APIs). Two globals are available here: // contextBridge.exposeInMainWorld(key, api) — expose a safe surface to the page // __bunmaska.invoke(channel, ...args) — call an ipcMain.handle handler // The page can then call window.api.ping(); it cannot reach Node or the bridge. diff --git a/src/common/preload-bundle.ts b/src/common/preload-bundle.ts new file mode 100644 index 0000000..ee74f9f --- /dev/null +++ b/src/common/preload-bundle.ts @@ -0,0 +1,80 @@ +/** + * Shared preload bundling — used by the runtime ({@link ../main/api/preload}) and + * by `bunmaska build` ({@link ../cli/app-assets}). + * + * A preload is injected as a CLASSIC script (a `WKUserScript` and its WebKitGTK / + * WinCairo equivalents have no module mode), so a top-level `import` throws a + * `SyntaxError` that aborts the whole preload — silently taking `window.api` with + * it. The fix is to bundle the preload into a single self-contained IIFE, inlining + * its imports, before it is injected or shipped. + */ + +import { readFileSync } from 'node:fs'; +import { InvalidArgumentError } from './errors'; + +/** Read a preload file as UTF-8, naming the path on failure. */ +export const readPreloadSource = (absolutePath: string): string => { + try { + return readFileSync(absolutePath, 'utf8'); + } catch (cause) { + throw new InvalidArgumentError(`failed to read webPreferences.preload at ${absolutePath}`, { + cause, + }); + } +}; + +/** + * Whether `source` uses top-level ES-module syntax that a classic script cannot + * run (a leading `import`/`export` statement). Pure. Deliberately conservative: it + * must never miss a real top-level `import` (the breaking case); an occasional + * false positive only costs a redundant bundle pass. + */ +export const usesModuleSyntax = (source: string): boolean => + /^[ \t]*(?:import|export)\b/m.test(source); + +/** A seam that turns a preload file into a single classic-script string. */ +export type PreloadBundler = { + /** Whether a bundler can run in this process (false inside a compiled app). */ + readonly available: boolean; + /** Bundle the file at `absolutePath` into a classic IIFE. Throws on a real error. */ + readonly bundle: (absolutePath: string) => string; +}; + +/** + * The Bun executable when we are running under the Bun CLI (`bunmaska dev` / + * `bunmaska build` / `bun run`), where the bundler is reachable; `undefined` + * inside a compiled app (whose `process.execPath` is the app binary, which must + * never be re-spawned as a bundler). + */ +const bunCliPath = (): string | undefined => { + const exe = process.execPath; + return /(?:^|[\\/])bun(?:-[^\\/]*)?(?:\.exe)?$/i.test(exe) ? exe : undefined; +}; + +/** Production bundler: shells out to Bun's bundler. Available only under the Bun CLI. */ +export const defaultPreloadBundler: PreloadBundler = { + get available(): boolean { + return bunCliPath() !== undefined; + }, + bundle: (absolutePath: string): string => { + const exe = bunCliPath(); + if (exe === undefined) { + return readPreloadSource(absolutePath); + } + const result = Bun.spawnSync( + [exe, 'build', absolutePath, '--target=browser', '--format=iife'], + { + stdout: 'pipe', + stderr: 'pipe', + }, + ); + if (!result.success) { + const detail = result.stderr.toString().trim(); + throw new InvalidArgumentError( + `failed to bundle webPreferences.preload at ${absolutePath}${detail ? `\n${detail}` : ''}`, + ); + } + const out = result.stdout.toString(); + return out.trim().length > 0 ? out : readPreloadSource(absolutePath); + }, +}; diff --git a/src/main/api/browser-window.ts b/src/main/api/browser-window.ts index a72c1a1..34998f3 100644 --- a/src/main/api/browser-window.ts +++ b/src/main/api/browser-window.ts @@ -1,14 +1,13 @@ import { EventEmitter } from 'node:events'; -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; import { makeCancelableEvent } from '../../common/cancelable-event'; -import { InvalidArgumentError } from '../../common/errors'; import type { NativeWindow, WindowEventType } from '../platform/native'; import { ensureNativeStarted } from '../bootstrap'; +import { startDevReload } from '../dev-reload'; import { nativeApp } from '../native-app'; import type { Rect } from '../platform/native'; import { app } from './app'; import { installWindowResolver, type PopupTarget } from './menu'; +import { loadPreloadScript } from './preload'; import { session } from './session'; import { WebContents } from './web-contents'; @@ -51,25 +50,6 @@ export type BrowserWindowOptions = { readonly webPreferences?: WebPreferences; }; -/** - * Resolve a `webPreferences.preload` path to an absolute path and read its - * source synchronously. Returns `undefined` when no preload is configured; - * throws {@link InvalidArgumentError} naming the path when it cannot be read. - */ -const readPreloadScript = (preload: string | undefined): string | undefined => { - if (preload === undefined) { - return undefined; - } - const absolutePath = resolve(preload); - try { - return readFileSync(absolutePath, 'utf8'); - } catch (cause) { - throw new InvalidArgumentError(`failed to read webPreferences.preload at ${absolutePath}`, { - cause, - }); - } -}; - const DEFAULT_WIDTH = 800; const DEFAULT_HEIGHT = 600; const DEFAULT_TITLE = 'Bunmaska'; @@ -116,6 +96,9 @@ const registry = new Map(); const popupTargets = new WeakMap(); let nextId = 1; +/** Installed once, in dev, so a renderer change live-reloads instead of restarting. */ +let devReloadInstalled = false; + /** Reset the window registry and id counter. Test-only. */ export const resetWindowRegistryForTesting = (): void => { registry.clear(); @@ -137,11 +120,21 @@ export class BrowserWindow extends EventEmitter { constructor(options: BrowserWindowOptions = {}) { super(); ensureNativeStarted(); + // In dev, the first window installs the stdin reload listener so a renderer + // change refreshes the page in place instead of restarting the whole app. + if (process.env['BUNMASKA_DEV'] === '1' && !devReloadInstalled) { + devReloadInstalled = true; + startDevReload(() => { + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.reload(); + } + }); + } this.id = nextId; nextId += 1; this.#resizable = options.resizable ?? true; - const preloadScript = readPreloadScript(options.webPreferences?.preload); + const preloadScript = loadPreloadScript(options.webPreferences?.preload); this.#native = nativeApp().createWindow({ width: options.width ?? DEFAULT_WIDTH, height: options.height ?? DEFAULT_HEIGHT, diff --git a/src/main/api/preload.ts b/src/main/api/preload.ts new file mode 100644 index 0000000..675f923 --- /dev/null +++ b/src/main/api/preload.ts @@ -0,0 +1,49 @@ +/** + * Loading a `webPreferences.preload` into the classic script that the platform + * backends inject at document-start. + * + * A preload runs as a CLASSIC script, so a top-level `import` would throw and + * silently kill it (and any `window.api` it exposes). Plain preloads are returned + * verbatim; a preload that uses `import`/`export` is bundled into a self-contained + * IIFE when a bundler is available, and otherwise raises a clear error. See + * {@link ../../common/preload-bundle}. + */ + +import { resolve } from 'node:path'; +import { InvalidArgumentError } from '../../common/errors'; +import { + defaultPreloadBundler, + type PreloadBundler, + readPreloadSource, + usesModuleSyntax, +} from '../../common/preload-bundle'; + +/** + * Resolve and load a `webPreferences.preload` into the classic-script string + * injected at document-start. Returns `undefined` when no preload is set. + * + * Plain preloads are returned verbatim; a preload that uses `import`/`export` is + * bundled into a self-contained IIFE when a bundler is available, and otherwise + * raises a clear error (rather than silently breaking `window.api`). + */ +export const loadPreloadScript = ( + preload: string | undefined, + bundler: PreloadBundler = defaultPreloadBundler, +): string | undefined => { + if (preload === undefined) { + return undefined; + } + const absolutePath = resolve(preload); + const source = readPreloadSource(absolutePath); + if (!usesModuleSyntax(source)) { + return source; + } + if (!bundler.available) { + throw new InvalidArgumentError( + `webPreferences.preload at ${absolutePath} uses 'import'/'export', which a preload ` + + `cannot run un-bundled (it is injected as a classic script). Run it via 'bunmaska dev' ` + + `or ship it with 'bunmaska build' (both bundle the preload), or keep the preload plain JavaScript.`, + ); + } + return bundler.bundle(absolutePath); +}; diff --git a/src/main/dev-reload.ts b/src/main/dev-reload.ts new file mode 100644 index 0000000..ec77f8c --- /dev/null +++ b/src/main/dev-reload.ts @@ -0,0 +1,44 @@ +/** + * Dev live-reload (app side). Under `bunmaska dev` the supervisor sets + * `BUNMASKA_DEV=1` and, for a renderer-only change, writes a `reload` command on + * the child's stdin instead of restarting it. This module reads those commands + * and reloads the open windows in place — so editing the page, styles or preload + * refreshes the window without it being torn down and reopened. + */ + +/** The dev command the supervisor writes for a renderer-only change. */ +export const DEV_RELOAD_COMMAND = 'reload'; + +/** Split a stdin chunk into the trimmed, non-empty commands it carries. Pure. */ +export const parseDevCommands = (chunk: string): string[] => + chunk + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); + +/** Run `reloadAll` for each `reload` command found in `chunk`. Pure. */ +export const handleDevChunk = (chunk: string, reloadAll: () => void): void => { + for (const command of parseDevCommands(chunk)) { + if (command === DEV_RELOAD_COMMAND) { + reloadAll(); + } + } +}; + +/** The slice of `process.stdin` this module needs (injectable for tests). */ +export type DevStdin = { + on: (event: 'data', listener: (chunk: Buffer | string) => void) => void; + unref?: () => void; +}; + +/** + * Subscribe to reload commands on `stdin` and run `reloadAll` for each. Does not + * keep the process alive on its own (the stdin handle is unref'd). Call this once, + * only in dev — {@link ../main/api/browser-window} gates it on `BUNMASKA_DEV`. + */ +export const startDevReload = (reloadAll: () => void, stdin: DevStdin = process.stdin): void => { + stdin.on('data', (chunk) => { + handleDevChunk(chunk.toString(), reloadAll); + }); + stdin.unref?.(); +}; diff --git a/tests/integration/windows/dev-reload.test.ts b/tests/integration/windows/dev-reload.test.ts new file mode 100644 index 0000000..42bdd92 --- /dev/null +++ b/tests/integration/windows/dev-reload.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'bun:test'; +import { currentPlatform } from '../../../src/common/platform'; +import { resolveWindowsEngineDir } from '../../../src/main/platform/windows/webkit2-ffi'; + +/** + * Windows + engine only. Proves `bunmaska dev`'s live-reload end-to-end: with + * BUNMASKA_DEV=1 a `reload` command written on the child's stdin reloads the open + * window's page in place (a second navigation), with no process restart — the + * supervisor sends exactly this for a renderer-only change. + * + * Skipped unless BUNMASKA_WEBKIT_PATH points at a WinCairo engine directory. + */ +const hasEngine = currentPlatform() === 'windows' && resolveWindowsEngineDir() !== undefined; + +describe.skipIf(!hasEngine)('Windows dev live-reload', () => { + test('a reload command on stdin reloads the page in place', async () => { + const fixture = `${import.meta.dir}/fixtures/dev-reload-probe.ts`; + const proc = Bun.spawn([process.execPath, 'run', fixture], { + env: { ...process.env, BUNMASKA_DEV: '1' }, + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + }); + + const reader = proc.stdout.getReader(); + const decoder = new TextDecoder(); + let out = ''; + let sentReload = false; + const deadline = Date.now() + 35000; + while (Date.now() < deadline) { + const { value, done } = await reader.read(); + if (done) { + break; + } + out += decoder.decode(value); + if (!sentReload && out.includes('DEV_RELOAD_READY')) { + sentReload = true; + proc.stdin.write('reload\n'); + proc.stdin.flush(); + } + if (out.includes('DEV_RELOAD_OK') || out.includes('DEV_RELOAD_FAIL')) { + break; + } + } + await proc.exited; + expect(out).toContain('DEV_RELOAD_READY'); + expect(out).toContain('DEV_RELOAD_OK'); + }, 45000); +}); diff --git a/tests/integration/windows/fixtures/dev-reload-probe.ts b/tests/integration/windows/fixtures/dev-reload-probe.ts new file mode 100644 index 0000000..aa0b08c --- /dev/null +++ b/tests/integration/windows/fixtures/dev-reload-probe.ts @@ -0,0 +1,30 @@ +/** + * Subprocess fixture: with BUNMASKA_DEV=1 the BrowserWindow constructor installs + * the stdin reload listener. The probe loads a page, prints DEV_RELOAD_READY, and + * when the test writes `reload` on stdin the page reloads (did-finish-load fires + * again) and it prints DEV_RELOAD_OK — proving the live-reload chain end to end. + * Requires BUNMASKA_WEBKIT_PATH. + */ +import { app, BrowserWindow } from '../../../../src/index'; + +const finish = (line: string, code: number): never => { + process.stdout.write(`${line}\n`); + process.exit(code); +}; +setTimeout(() => finish('DEV_RELOAD_FAIL timeout', 1), 25000); + +let loads = 0; +app.whenReady().then(() => { + const win = new BrowserWindow({ width: 480, height: 320, show: false }); + win.webContents.on('did-finish-load', () => { + loads += 1; + if (loads === 1) { + // First load done — ask the test to send a reload command on stdin. + process.stdout.write('DEV_RELOAD_READY\n'); + } else { + // A second navigation means the stdin `reload` reloaded the page in place. + finish('DEV_RELOAD_OK', 0); + } + }); + win.loadURL('data:text/html,dev reload'); +}); diff --git a/tests/integration/windows/fixtures/preload-import-probe.ts b/tests/integration/windows/fixtures/preload-import-probe.ts new file mode 100644 index 0000000..a038a5c --- /dev/null +++ b/tests/integration/windows/fixtures/preload-import-probe.ts @@ -0,0 +1,50 @@ +/** + * Subprocess fixture: a preload that IMPORTS a sibling module still exposes + * `window.api`. A preload is injected as a classic script (no module mode), so + * without bundling the `import` would throw and silently kill the whole preload. + * `loadPreloadScript` bundles it into a self-contained IIFE first. Prints + * `PRELOAD_IMPORT_OK ""` on success. Requires BUNMASKA_WEBKIT_PATH. + */ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { app, BrowserWindow } from '../../../../src/index'; + +const finish = (line: string, code: number): never => { + process.stdout.write(`${line}\n`); + process.exit(code); +}; +setTimeout(() => finish('PRELOAD_IMPORT_FAIL timeout', 1), 25000); + +// A preload that pulls a value out of an imported sibling module and exposes it. +const dir = mkdtempSync(join(tmpdir(), 'bunmaska-preload-import-')); +writeFileSync( + join(dir, 'helper.js'), + 'export const greet = (name) => "hi " + name + " from helper";\n', +); +const preloadPath = join(dir, 'preload.js'); +writeFileSync( + preloadPath, + "import { greet } from './helper.js';\ncontextBridge.exposeInMainWorld('api', { greet: (n) => greet(n) });\n", +); + +app.whenReady().then(() => { + const win = new BrowserWindow({ + width: 640, + height: 480, + show: false, + webPreferences: { preload: preloadPath }, + }); + win.webContents.once('did-finish-load', () => { + win.webContents + .executeJavaScript("typeof window.api === 'object' ? window.api.greet('bun') : 'NO_API'") + .then((result) => + finish( + `PRELOAD_IMPORT_OK ${JSON.stringify(result)}`, + result === 'hi bun from helper' ? 0 : 1, + ), + ) + .catch((error) => finish(`PRELOAD_IMPORT_FAIL ${String(error)}`, 1)); + }); + win.loadURL('data:text/html,preload import'); +}); diff --git a/tests/integration/windows/preload-import.test.ts b/tests/integration/windows/preload-import.test.ts new file mode 100644 index 0000000..9c35c9f --- /dev/null +++ b/tests/integration/windows/preload-import.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'bun:test'; +import { currentPlatform } from '../../../src/common/platform'; +import { resolveWindowsEngineDir } from '../../../src/main/platform/windows/webkit2-ffi'; + +/** + * Windows + engine only. Proves the preload-bundling fix end-to-end: a preload + * that uses `import` still exposes `window.api` on a real WinCairo WebView, + * because `loadPreloadScript` bundles it into a classic IIFE before injection + * (an un-bundled `import` would throw and silently break the bridge). + * + * Driven in a spawned Bun subprocess (so the preload is bundled under the Bun + * CLI, matching `bunmaska dev`). Skipped unless BUNMASKA_WEBKIT_PATH is set. + */ +const hasEngine = currentPlatform() === 'windows' && resolveWindowsEngineDir() !== undefined; + +describe.skipIf(!hasEngine)('Windows preload bundling', () => { + test('a preload that uses import still exposes window.api', async () => { + const fixture = `${import.meta.dir}/fixtures/preload-import-probe.ts`; + const proc = Bun.spawn([process.execPath, 'run', fixture], { + env: { ...process.env }, + stdout: 'pipe', + stderr: 'pipe', + }); + const stdout = await new Response(proc.stdout).text(); + const code = await proc.exited; + expect(stdout).toContain('PRELOAD_IMPORT_OK "hi bun from helper"'); + expect(code).toBe(0); + }, 40000); +}); diff --git a/tests/unit/cli/app-assets.test.ts b/tests/unit/cli/app-assets.test.ts index 9ccc8ee..0a46f97 100644 --- a/tests/unit/cli/app-assets.test.ts +++ b/tests/unit/cli/app-assets.test.ts @@ -1,8 +1,9 @@ -import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, test } from 'bun:test'; -import { copyAppAssets, isRuntimeAsset } from '../../../src/cli/app-assets'; +import type { PreloadBundler } from '../../../src/common/preload-bundle'; +import { bundlePreloadAssets, copyAppAssets, isRuntimeAsset } from '../../../src/cli/app-assets'; describe('isRuntimeAsset', () => { test('keeps page, preload, styles, images and data', () => { @@ -50,3 +51,43 @@ describe('copyAppAssets', () => { expect(copyAppAssets('/no/such/dir/main.ts', tmpdir())).toEqual([]); }); }); + +describe('bundlePreloadAssets', () => { + const fakeBundler = (out: string): PreloadBundler => ({ available: true, bundle: () => out }); + + test('bundles a module-using preload.js in place and returns it', () => { + const dir = mkdtempSync(join(tmpdir(), 'bunmaska-prebundle-')); + writeFileSync( + join(dir, 'preload.js'), + "import './x.js';\ncontextBridge.exposeInMainWorld('api', {});\n", + ); + writeFileSync(join(dir, 'index.html'), ''); + + const rewritten = bundlePreloadAssets( + dir, + ['preload.js', 'index.html'], + fakeBundler('(() => {})();'), + ); + + expect(rewritten).toEqual(['preload.js']); + expect(readFileSync(join(dir, 'preload.js'), 'utf8')).toBe('(() => {})();'); + }); + + test('leaves a plain preload and any non-preload asset untouched', () => { + const dir = mkdtempSync(join(tmpdir(), 'bunmaska-prebundle-')); + const plain = "contextBridge.exposeInMainWorld('api', {});\n"; + writeFileSync(join(dir, 'preload.js'), plain); + // A page script that uses import is NOT a preload — it must not be rewritten. + writeFileSync(join(dir, 'app.js'), "import './x.js';\n"); + + const rewritten = bundlePreloadAssets( + dir, + ['preload.js', 'app.js'], + fakeBundler('SHOULD-NOT-APPEAR'), + ); + + expect(rewritten).toEqual([]); + expect(readFileSync(join(dir, 'preload.js'), 'utf8')).toBe(plain); + expect(readFileSync(join(dir, 'app.js'), 'utf8')).toBe("import './x.js';\n"); + }); +}); diff --git a/tests/unit/cli/dev.test.ts b/tests/unit/cli/dev.test.ts index 5245c0f..ea646fc 100644 --- a/tests/unit/cli/dev.test.ts +++ b/tests/unit/cli/dev.test.ts @@ -1,10 +1,10 @@ import { describe, expect, test } from 'bun:test'; import { + classifyChange, DEV_DEFAULT_ENTRY, type DevDeps, DevSupervisor, resolveDevEntry, - shouldRestart, } from '../../../src/cli/dev'; describe('resolveDevEntry', () => { @@ -18,18 +18,25 @@ describe('resolveDevEntry', () => { }); }); -describe('shouldRestart', () => { - test('restarts on a source change', () => { - expect(shouldRestart('src/main.ts')).toBe(true); - expect(shouldRestart('index.html')).toBe(true); +describe('classifyChange', () => { + test('restarts on a TypeScript (main-process) change', () => { + expect(classifyChange('src/main.ts')).toBe('restart'); + expect(classifyChange('src/window.tsx')).toBe('restart'); + expect(classifyChange('bunmaska.config.ts')).toBe('restart'); + }); + + test('live-reloads on a renderer asset change', () => { + expect(classifyChange('src/index.html')).toBe('reload'); + expect(classifyChange('src/styles.css')).toBe('reload'); + expect(classifyChange('src/preload.js')).toBe('reload'); }); test('ignores dependency/VCS/build dirs and dotfiles', () => { - expect(shouldRestart('node_modules/x/index.js')).toBe(false); - expect(shouldRestart('.git/HEAD')).toBe(false); - expect(shouldRestart('dist/app.js')).toBe(false); - expect(shouldRestart('src/.main.ts.swp')).toBe(false); - expect(shouldRestart('')).toBe(false); + expect(classifyChange('node_modules/x/index.js')).toBe('ignore'); + expect(classifyChange('.git/HEAD')).toBe('ignore'); + expect(classifyChange('dist/app.js')).toBe('ignore'); + expect(classifyChange('src/.main.ts.swp')).toBe('ignore'); + expect(classifyChange('')).toBe('ignore'); }); }); @@ -38,6 +45,7 @@ const makeHarness = (): { deps: DevDeps; spawns: string[]; kills: number; + reloads: number; watcherClosed: () => boolean; fireChange: (relPath: string) => void; runTimer: () => void; @@ -45,6 +53,7 @@ const makeHarness = (): { } => { const spawns: string[] = []; let kills = 0; + let reloads = 0; let closed = false; let onChange: ((relPath: string) => void) | undefined; let timerFn: (() => void) | undefined; @@ -56,6 +65,9 @@ const makeHarness = (): { kill: () => { kills += 1; }, + reload: () => { + reloads += 1; + }, }; }, watch: (_dir, cb) => { @@ -83,6 +95,9 @@ const makeHarness = (): { get kills() { return kills; }, + get reloads() { + return reloads; + }, watcherClosed: () => closed, fireChange: (relPath) => onChange?.(relPath), runTimer: () => timerFn?.(), @@ -97,7 +112,7 @@ describe('DevSupervisor', () => { expect(h.spawns).toEqual(['src/main.ts']); }); - test('a relevant change restarts the child after the debounce fires', () => { + test('a TypeScript change restarts the child after the debounce fires', () => { const h = makeHarness(); const sup = new DevSupervisor('/proj', 'src/main.ts', h.deps); h.fireChange('src/main.ts'); @@ -105,16 +120,38 @@ describe('DevSupervisor', () => { h.runTimer(); expect(h.spawns).toEqual(['src/main.ts', 'src/main.ts']); expect(sup.starts).toBe(2); + expect(sup.reloads).toBe(0); + }); + + test('a renderer asset change live-reloads instead of restarting', () => { + const h = makeHarness(); + const sup = new DevSupervisor('/proj', 'src/main.ts', h.deps); + h.fireChange('src/index.html'); + h.runTimer(); + expect(h.spawns).toHaveLength(1); // no respawn — the window stays open + expect(h.reloads).toBe(1); + expect(sup.reloads).toBe(1); + expect(sup.starts).toBe(1); + }); + + test('a restart supersedes a reload coalesced into the same window', () => { + const h = makeHarness(); + const sup = new DevSupervisor('/proj', 'src/main.ts', h.deps); + h.fireChange('src/index.html'); // would reload + h.fireChange('src/main.ts'); // but a TS change wins + h.runTimer(); + expect(sup.starts).toBe(2); + expect(h.reloads).toBe(0); }); - test('an ignored change never schedules a restart', () => { + test('an ignored change never schedules anything', () => { const h = makeHarness(); new DevSupervisor('/proj', 'src/main.ts', h.deps); h.fireChange('node_modules/x.js'); expect(h.pendingTimers()).toBe(0); }); - test('rapid changes coalesce into a single restart', () => { + test('rapid changes coalesce into a single action', () => { const h = makeHarness(); new DevSupervisor('/proj', 'src/main.ts', h.deps); h.fireChange('src/a.ts'); diff --git a/tests/unit/common/preload-bundle.test.ts b/tests/unit/common/preload-bundle.test.ts new file mode 100644 index 0000000..3473806 --- /dev/null +++ b/tests/unit/common/preload-bundle.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'bun:test'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { defaultPreloadBundler, usesModuleSyntax } from '../../../src/common/preload-bundle'; + +describe('usesModuleSyntax', () => { + test('detects a top-level import or export', () => { + expect(usesModuleSyntax("import x from './x.js';")).toBe(true); + expect(usesModuleSyntax("import './side-effect.js';")).toBe(true); + expect(usesModuleSyntax('export const a = 1;')).toBe(true); + expect(usesModuleSyntax(" import { a } from 'b';")).toBe(true); + }); + + test('does not flag plain preloads or the word "import" mid-line', () => { + expect(usesModuleSyntax("contextBridge.exposeInMainWorld('api', { ok: () => 1 });")).toBe( + false, + ); + expect(usesModuleSyntax('const important = 1;')).toBe(false); + expect(usesModuleSyntax('foo.import = 1;')).toBe(false); + expect(usesModuleSyntax('// import x from "y";')).toBe(false); + }); +}); + +describe('defaultPreloadBundler (real Bun bundler)', () => { + test('inlines an imported module into a single classic IIFE', () => { + const dir = mkdtempSync(join(tmpdir(), 'bunmaska-preload-real-')); + writeFileSync(join(dir, 'helper.js'), "export const greet = () => 'hi from helper';\n"); + const path = join(dir, 'preload.js'); + writeFileSync( + path, + "import { greet } from './helper.js';\ncontextBridge.exposeInMainWorld('api', { hi: () => greet() });\n", + ); + // bun test runs under the Bun CLI, so the bundler is available here. + expect(defaultPreloadBundler.available).toBe(true); + const out = defaultPreloadBundler.bundle(path); + expect(out).toContain('hi from helper'); + // No surviving top-level module syntax, and the `contextBridge` global is left free. + expect(usesModuleSyntax(out)).toBe(false); + expect(out).toContain('contextBridge.exposeInMainWorld'); + }); +}); diff --git a/tests/unit/main/api/preload.test.ts b/tests/unit/main/api/preload.test.ts new file mode 100644 index 0000000..b35eea6 --- /dev/null +++ b/tests/unit/main/api/preload.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from 'bun:test'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { PreloadBundler } from '../../../../src/common/preload-bundle'; +import { loadPreloadScript } from '../../../../src/main/api/preload'; + +const tmpPreload = (contents: string, name = 'preload.js'): string => { + const dir = mkdtempSync(join(tmpdir(), 'bunmaska-preload-')); + const path = join(dir, name); + writeFileSync(path, contents); + return path; +}; + +const fakeBundler = (available: boolean, out: string, onBundle?: () => void): PreloadBundler => ({ + available, + bundle: () => { + onBundle?.(); + return out; + }, +}); + +describe('loadPreloadScript', () => { + test('returns undefined when no preload is configured', () => { + expect(loadPreloadScript(undefined)).toBeUndefined(); + }); + + test('returns a plain-JS preload verbatim, without invoking the bundler', () => { + let bundled = false; + const path = tmpPreload("contextBridge.exposeInMainWorld('api', {});\n"); + const result = loadPreloadScript( + path, + fakeBundler(true, 'BUNDLED', () => { + bundled = true; + }), + ); + expect(result).toBe("contextBridge.exposeInMainWorld('api', {});\n"); + expect(bundled).toBe(false); + }); + + test('bundles a preload that uses import when a bundler is available', () => { + const path = tmpPreload( + "import { greet } from './h.js';\ncontextBridge.exposeInMainWorld('api', { hi: greet });\n", + ); + expect(loadPreloadScript(path, fakeBundler(true, '(() => {})();'))).toBe('(() => {})();'); + }); + + test('throws a clear, non-silent error for an import-using preload with no bundler', () => { + const path = tmpPreload("import './x.js';\n"); + expect(() => loadPreloadScript(path, fakeBundler(false, ''))).toThrow(/import/i); + }); + + test('throws naming the path when the preload cannot be read', () => { + expect(() => loadPreloadScript('/no/such/preload.js')).toThrow(/preload/); + }); +}); diff --git a/tests/unit/main/dev-reload.test.ts b/tests/unit/main/dev-reload.test.ts new file mode 100644 index 0000000..2383f1e --- /dev/null +++ b/tests/unit/main/dev-reload.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'bun:test'; +import { + DEV_RELOAD_COMMAND, + type DevStdin, + handleDevChunk, + parseDevCommands, + startDevReload, +} from '../../../src/main/dev-reload'; + +describe('parseDevCommands', () => { + test('splits into trimmed, non-empty lines', () => { + expect(parseDevCommands('reload\n')).toEqual(['reload']); + expect(parseDevCommands(' reload \n\n')).toEqual(['reload']); + expect(parseDevCommands('reload\nreload\n')).toEqual(['reload', 'reload']); + expect(parseDevCommands('')).toEqual([]); + }); +}); + +describe('handleDevChunk', () => { + test('runs reloadAll once per reload command', () => { + let count = 0; + handleDevChunk('reload\nreload\n', () => { + count += 1; + }); + expect(count).toBe(2); + }); + + test('ignores unknown commands', () => { + let count = 0; + handleDevChunk('nope\n\n', () => { + count += 1; + }); + expect(count).toBe(0); + }); + + test('DEV_RELOAD_COMMAND is the trigger', () => { + let count = 0; + handleDevChunk(`${DEV_RELOAD_COMMAND}\n`, () => { + count += 1; + }); + expect(count).toBe(1); + }); +}); + +describe('startDevReload', () => { + test('reloads when a reload chunk arrives on stdin, and unrefs the handle', () => { + let listener: ((chunk: Buffer | string) => void) | undefined; + let unrefed = false; + let reloads = 0; + const stdin: DevStdin = { + on: (_event, cb) => { + listener = cb; + }, + unref: () => { + unrefed = true; + }, + }; + + startDevReload(() => { + reloads += 1; + }, stdin); + + expect(unrefed).toBe(true); + listener?.('reload\n'); + expect(reloads).toBe(1); + // Buffers arrive too — they must be handled the same as strings. + listener?.(Buffer.from('reload\n')); + expect(reloads).toBe(2); + }); +}); diff --git a/website/src/content/docs/concepts/ipc.md b/website/src/content/docs/concepts/ipc.md index 3408de6..e8731c4 100644 --- a/website/src/content/docs/concepts/ipc.md +++ b/website/src/content/docs/concepts/ipc.md @@ -21,7 +21,7 @@ ipcMain.handle("add", (_event, a: number, b: number) => a + b); ## Preload: expose a safe surface -The preload runs in an **isolated world** and is injected verbatim, so keep it plain JavaScript. Two globals are available to it: `contextBridge` and `__bunmaska`. +The preload runs in an **isolated world** and is **bundled before injection**, so you can `import` modules — just keep it browser code (no Node APIs). Two globals are available to it: `contextBridge` and `__bunmaska`. ```js // preload.js diff --git a/website/src/content/docs/quickstart.md b/website/src/content/docs/quickstart.md index 1ded7f8..7e6a71a 100644 --- a/website/src/content/docs/quickstart.md +++ b/website/src/content/docs/quickstart.md @@ -49,7 +49,7 @@ ipcMain.handle("add", (_event, a, b) => a + b); ``` ```ts -// preload.js (injected into the isolated world - keep it plain JS) +// preload.js (isolated world, bundled before injection - imports work) contextBridge.exposeInMainWorld("api", { add: (a, b) => __bunmaska.invoke("add", a, b), });