---
title: "Fastify and Electron: Desktop Startup & Shutdown Boundaries"
description: Trace ChatCrystal local, cloud, and onboarding modes, verify CommonJS loading and port contention, and inspect window and shutdown boundaries.
image: "https://zengliangyi.com/og/blog/en/fastify-electron-service-lifecycle.png"
---

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](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/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](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/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](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/runtime/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](https://nodejs.org/download/release/v24.21.0/docs/api/esm.html) supports dynamic import in CommonJS. The [Electron ESM documentation](https://www.electronjs.org/docs/latest/tutorial/esm) also distinguishes main, renderer, and preload loaders and asynchronous timing.

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



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



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



```js
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](/en/blog/typescript-monorepo-configuration/).

## 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**.



```js
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](https://fastify.dev/docs/latest/Reference/Server/#listen) 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](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](/en/blog/testing-strategy/) 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.          曾亮懿       October 5, 2026      4 min read

#ChatCrystal

#Electron

#Node.js](/en/blog/electron-tray-window-state)    [##  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.          曾亮懿       October 7, 2026      4 min read

#ChatCrystal

#Node.js

#SQLite](/en/blog/ai-conversation-format-divergence)    [##  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.          曾亮懿       October 7, 2026      4 min read

#ChatCrystal

#Node.js

#SQLite](/en/blog/ai-conversation-management-options)

##  From writing to code

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

[Visit my GitHub](https://github.com/ZengLiangYi)

```json
{
  "@context": "https://schema.org",
  "@type": "WebSite",
  "name": "ZengLiangYi Blog",
  "alternateName": [
    "Zeng Liangyi",
    "ZengLiangYi",
    "zengliangyi.com"
  ],
  "url": "https://zengliangyi.com",
  "description": "曾亮懿的个人网站。记录 AI Native 探索、开发者工具与全栈产品实践。"
}
{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "Fastify and Electron: Desktop Startup & Shutdown Boundaries",
  "description": "Trace ChatCrystal local, cloud, and onboarding modes, verify CommonJS loading and port contention, and inspect window and shutdown boundaries.",
  "url": "https://zengliangyi.com/en/blog/fastify-electron-service-lifecycle/",
  "image": "https://zengliangyi.com/images/blog/fastify-electron-service-lifecycle.svg",
  "datePublished": "2026-10-07T00:00:00.000Z",
  "dateModified": "2026-10-23T00:00:00.000Z",
  "author": {
    "@type": "Person",
    "name": "曾亮懿",
    "url": "https://zengliangyi.com/en/about"
  },
  "publisher": {
    "@type": "Organization",
    "name": "ZengLiangYi Blog",
    "logo": {
      "@type": "ImageObject",
      "url": "https://zengliangyi.com/favicon-96x96.png"
    }
  },
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "https://zengliangyi.com/en/blog/fastify-electron-service-lifecycle/"
  }
}
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "Home",
      "item": "https://zengliangyi.com/en"
    },
    {
      "@type": "ListItem",
      "position": 2,
      "name": "Blog",
      "item": "https://zengliangyi.com/en/blog"
    },
    {
      "@type": "ListItem",
      "position": 3,
      "name": "Fastify and Electron: Desktop Startup & Shutdown Boundaries",
      "item": "https://zengliangyi.com/en/blog/fastify-electron-service-lifecycle/"
    }
  ]
}
```
