Skip to content
曾亮懿 ZengLiangYi Blog

Fastify and Electron: Desktop Startup & Shutdown Boundaries

Trace ChatCrystal local, cloud, and onboarding modes, verify CommonJS loading and port contention, and inspect window and shutdown boundaries.

曾亮懿

曾亮懿

5 min read
Table of contents Show Hide

Reusing a Web service in a desktop application requires clear lifetimes for windows, listening ports, and data resources. The pinned ChatCrystal implementation includes local, cloud, and onboarding modes. A single “startup always creates a local service” sequence no longer describes it. This article checks startup, loading, and exit, using independent samples for module loading and port contention.

The service factory and desktop entry manage different resources

server/src/index.ts exports createServer, initializes the database and autosave, configures cloud/auth, routes, and a frontend directory when present, then awaits app.listen and returns app, port, and shutdown. Standalone execution uses the configured port and defaults host to 0.0.0.0. Listening on every interface and a desktop loopback binding expose different scopes.

electron/main.ts acquires a single-instance lock and sets ELECTRON and DATA_DIR at runtime, plus ELECTRON_PACKAGED when packaged. The production local path loads the compiled service with host=127.0.0.1 and startWatcher=false. The embedded desktop service does not necessarily start a file watcher; watcherPolicy.ts also disables it in cloud mode.

Mode or condition Current path
Production local mode When no embedded service exists, ensureLocalCoreStarted probes a port and creates the service before loading the local window
VITE_DEV_URL development mode Loads the development page with an external development core API address; this path does not create an embedded core
Cloud mode with saved URL/token Loads the cloud address, checks the window origin, and injects authentication state; this opening path does not create a local core
No selected mode, or failure to open a mode Opens onboarding, with error information available on failure paths

“This cloud opening does not start a core” does not establish that a previously started local core has stopped. Resource lifetimes require following mode transitions and exit, beyond the window-opening function.

CommonJS can dynamically load ESM

Current importServerModule checks the compiled path, uses pathToFileURL, and loads it through import(specifier) inside Function. That is the repository’s chosen form, without proving that CommonJS lacks native dynamic ESM loading. The Node module documentation supports dynamic import in CommonJS. The Electron ESM documentation also distinguishes main, renderer, and preload loaders and asynchronous timing.

Use Node 24+ for an exercise without Electron or ChatCrystal. Save fixture.mjs first:

export async function ready() {
  return { ready: true };
}

Save lesson.cjs beside it and run node lesson.cjs:

const assert = require('node:assert/strict');
const path = require('node:path');
const { pathToFileURL } = require('node:url');

(async () => {
  const url = pathToFileURL(path.join(__dirname, 'fixture.mjs')).href;
  const service = await import(url);
  assert.deepEqual(await service.ready(), { ready: true });
  console.log('CommonJS dynamic ESM import passes');
})().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Change the fixture’s ready value to false and the assertion fails with a nonzero exit. Restore it to pass. This verifies Node module loading and awaiting, without running the desktop main process, preload, or packaged artifacts. Check compilation settings and emitted code separately using the TypeScript configuration guide.

A free-port probe does not reserve a port

FindFreePort first tries listening on the preferred port at 127.0.0.1, closes the probe server, and returns the port. On a probe error it tries port=0. This does not reserve the port between closure and binding again: another process can claim it, and any preferred-probe error triggers fallback, beyond address-in-use errors alone.

In an isolated directory, install npm install --ignore-scripts --save-exact fastify@5.12.3, save port-lesson.mjs, and run with Node 24+. It starts only teaching services on loopback, without starting the application or reading/writing user data.

import assert from 'node:assert/strict';
import net from 'node:net';
import { once } from 'node:events';
import Fastify from 'fastify';

async function listen(server, port) {
  server.listen({ host: '127.0.0.1', port });
  await once(server, 'listening');
  return server.address().port;
}
function close(server) {
  return new Promise((resolve, reject) =>
    server.close((error) => (error ? reject(error) : resolve()))
  );
}

const probe = net.createServer();
const port = await listen(probe, 0);
await close(probe);
const other = net.createServer();
const conflict = Fastify();
const retained = Fastify();
try {
  await listen(other, port);
  await assert.rejects(conflict.listen({ host: '127.0.0.1', port }), { code: 'EADDRINUSE' });
  retained.get('/health', async () => ({ ready: true }));
  const address = await retained.listen({ host: '127.0.0.1', port: 0 });
  const response = await fetch(address + '/health');
  assert.equal(response.status, 200);
  assert.deepEqual(await response.json(), { ready: true });
  console.log('released probe and retained listener boundaries pass');
} finally {
  await conflict.close();
  await retained.close();
  await close(other);
}

Another listener deliberately claims the released probe port, producing an actual EADDRINUSE rejection. Fastify then binds port=0 directly and retains that listener; the actual address is used to request health. Change the expected ready response to false and the check fails; restore it to pass. Retaining the listener is a teaching alternative, without changing ChatCrystal’s current path. The Fastify listen documentation describes port=0 and its Promise behavior.

Check windows and response headers by environment

CreateWindow selects preload by mode and currently uses contextIsolation=true, nodeIntegration=false, and sandbox=true. Navigation restrictions and origin checks have additional responsibilities; these settings do not prove every input is safe.

ConfigureLocalContentSecurityPolicy skips VITE_DEV_URL development and selects responses by whether their URL is local HTTP, without checking window mode or port. Cloud responses outside local HTTP do not receive this override; a cloud mode configured with http://localhost can still match. The policy includes unsafe-inline for styles and specified local connection rules. Having CSP does not establish elimination of XSS. Markdown rendering, external links, IPC, and cloud origins retain their own input boundaries.

Closing a window saves state, then prevents closure and hides it unless exiting. Window-all-closed also keeps the process alive. A second instance activates the window. A saved position outside the checks for all displays is cleared. Size and visibility rules still need real multi-display acceptance; reading them is not a completed desktop test.

Shutdown has ordering, failure exits, and a timeout

Service shutdown awaits watcher.close, calls closeDatabase, then awaits app.close. It has no finally ensuring later steps execute after an earlier throw. Desktop before-quit prevents the first exit, sets an exit flag, starts a ten-second forced-exit timer, and attempts to terminate the remote-import worker, shut down an existing local service, and destroy the tray. Finally it clears the timer and calls app.quit; a timeout calls app.exit(1).

Graceful-exit code does not prove saving succeeds under every exception. Check listening failure, service-load failure, window hiding, normal exit, a throwing close step, and shutdown timeout separately, including actual persisted data. The failure-path approach in the testing guide applies here. These Node and loopback exercises do not replace acceptance of an actual Electron installer.

Share:

Related Posts

Electron tray and window state: hide, restore, and quit

Read ChatCrystal’s mode-aware tray, saved window bounds, and shutdown flow, including what persistence restores and which failures still need testing.

曾亮懿 曾亮懿
4 min read
#ChatCrystal #Electron #Node.js

AI Conversation Formats: JSONL Events vs SQLite Snapshots

Compare 5 AI adapter formats: separate JSONL events from SQLite keys, and inspect indexed, orphan, and unknown-version chat transcripts in memory stores.

曾亮懿 曾亮懿
4 min read
#ChatCrystal #Node.js #SQLite

How Claude Code, Cursor, Copilot, and Trae Store Chats on Disk

Compare Claude Code, Cursor, Copilot, and Trae storage paths. Distinguish raw copies from exports, and verify migration retention with test manifests.

曾亮懿 曾亮懿
4 min read
#ChatCrystal #Node.js #SQLite

From writing to code

Explore ChatCrystal, developer tools and daily learning notes on my GitHub. Questions about the implementation are welcome.