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.