Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
3 changes: 2 additions & 1 deletion examples/ipc-demo/preload.js
Original file line number Diff line number Diff line change
@@ -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', {
Expand Down
38 changes: 37 additions & 1 deletion src/cli/app-assets.ts
Original file line number Diff line number Diff line change
@@ -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']);

Expand Down Expand Up @@ -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;
};
8 changes: 5 additions & 3 deletions src/cli/build-linux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -225,8 +225,10 @@ export const buildLinuxApp = async (opts: BuildLinuxAppOptions): Promise<BuildLi
await compileLinuxBinary(opts.entry, layout.binPath);
chmodSync(layout.binPath, 0o755);

// Ship the entry's runtime assets (the page, the preload) beside the binary.
copyAppAssets(opts.entry, dirname(layout.binPath));
// 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.
const assetsDir = dirname(layout.binPath);
bundlePreloadAssets(assetsDir, copyAppAssets(opts.entry, assetsDir));

writeFileSync(
layout.desktopPath,
Expand Down
7 changes: 4 additions & 3 deletions src/cli/build-macos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
import { tmpdir } from 'node:os';
import { join, posix } from 'node:path';
import { BUNMASKA_VERSION } from '../common/version';
import { copyAppAssets } from './app-assets';
import { bundlePreloadAssets, copyAppAssets } from './app-assets';

/** Minimum macOS the bundle declares it supports. */
const MINIMUM_SYSTEM_VERSION = '11.0';
Expand Down Expand Up @@ -436,8 +436,9 @@ export const buildMacApp = async (opts: BuildMacAppOptions): Promise<string> =>
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) {
Expand Down
7 changes: 4 additions & 3 deletions src/cli/build-windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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`);
Expand Down
98 changes: 74 additions & 24 deletions src/cli/dev.ts
Original file line number Diff line number Diff line change
@@ -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 <entry>` 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 <entry>`
* 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;

/**
Expand All @@ -28,22 +31,37 @@ export const resolveDevEntry = (config: BunmaskaConfig, explicit?: string): stri

const IGNORED_SEGMENTS: ReadonlySet<string> = new Set(['node_modules', '.git', 'dist']);

/** TypeScript is compiled into the main process, so a change there restarts it. */
const MAIN_SOURCE_EXTENSIONS: ReadonlySet<string> = 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). */
Expand All @@ -62,9 +80,10 @@ export type DevDeps = {
};

/**
* Supervises a `bun run <entry>` child: spawns it on construction, restarts it
* (debounced) on a relevant file change, and tears everything down on
* {@link stop}.
* Supervises a `bun run <entry>` 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;
Expand All @@ -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;
Expand All @@ -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. */
Expand Down Expand Up @@ -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) => {
Expand Down
3 changes: 2 additions & 1 deletion src/cli/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading