Skip to content
曾亮懿 ZengLiangYi Blog

Fastify production files: one port, SPA fallback, and failures

Check ChatCrystal’s build outputs and static-root discovery, then use isolated inject exercises to distinguish pages, unknown APIs, and missing assets.

曾亮懿

曾亮懿

4 min read
Table of contents Show Hide

Development runs Vite and Fastify separately. In production, Fastify can also serve pages when frontend outputs exist. One port describes the request entry; it does not establish that pages are packaged, every API is public, or shutdown safely completes every request. This article checks those boundaries against the pinned createServer implementation. For framework basics, see Fastify plugins and input validation.

Establish that the frontend output exists

The root scripts now use pnpm. build runs filtered server and client builds in order, rather than the original draft’s npm workspace command. With dependencies already installed and the required Node 24+, run these in the pinned checkout:

pnpm build
test -f client/dist/index.html

The server’s TypeScript output is under server/dist/server/src/; Vite writes client/dist/. The published package file list mainly includes dist/server and dist/shared. It does not automatically include the source repository’s client/dist. Installing the CLI does not establish that browser pages are packaged.

The entry probes two paths relative to import.meta.dirname: two levels up from the source layout, or four from the compiled layout, to client/dist. It checks the directory path with existsSync, without validating index.html or a complete asset set. The static plugin and custom fallback are registered only when that directory exists. An empty directory enters the branch while serving the homepage still fails.

wildcard can coexist with fallback

Current registration passes only root and prefix: '/'; it does not set wildcard: false. The static plugin documentation gives true as the default. The original draft’s claim that wildcard must be disabled for fallback does not describe this entry: a missing static file can still reach its not-found handler.

The handler tests the raw req.url.startsWith('/api/'): a match returns JSON 404; other requests use sendFile(‘index.html’). This is a literal prefix, rather than an abstract classification of API-like URLs. /api and /api?x=1 do not match it.

/missing.js is an easily missed case. It lacks the /api/ prefix and receives homepage HTML. HTTP 200 does not make it an executable script; a browser may reject its MIME type or syntax. Inspect status, Content-Type, and body together instead of treating a successful page refresh as a complete deployment check.

Isolated exercise without a real knowledge database

This standalone lesson uses Fastify 5.12.3 and @fastify/static 10.1.3. It omits ChatCrystal’s database, imports, authentication, and watcher. Install those exact versions in a new temporary lesson directory, save the code as lesson.mjs, and run with Node 24+. It writes only that directory’s public/index.html and uses inject without listening on a network port.

import assert from 'node:assert/strict';
import { mkdirSync, writeFileSync } from 'node:fs';
import { resolve, join } from 'node:path';
import Fastify from 'fastify';
import fastifyStatic from '@fastify/static';

const root = resolve('public');
mkdirSync(root, { recursive: true });
writeFileSync(join(root, 'index.html'), '<!doctype html><p>lesson shell</p>');
const app = Fastify();
app.get('/api/ping', async () => ({ success: true }));
await app.register(fastifyStatic, { root, prefix: '/' });
app.setNotFoundHandler((req, reply) => {
  if (req.url.startsWith('/api/')) {
    return reply.code(404).send({ success: false, error: 'Not Found' });
  }
  return reply.sendFile('index.html');
});

try {
  const api = await app.inject('/api/ping');
  assert.equal(api.statusCode, 200);
  assert.deepEqual(api.json(), { success: true });
  const unknownApi = await app.inject('/api/unknown');
  assert.equal(unknownApi.statusCode, 404);
  assert.deepEqual(unknownApi.json(), { success: false, error: 'Not Found' });
  for (const url of ['/notes/123', '/missing.js', '/api', '/api?x=1']) {
    const response = await app.inject(url);
    assert.equal(response.statusCode, 200);
    assert.match(response.headers['content-type'], /text\/html/);
    assert.match(response.body, /lesson shell/);
  }
} finally {
  await app.close();
}

Deliberately remove the handler’s API branch: unknownApi’s 404 assertion should fail. Restore it and expect a pass. For a missing-homepage failure, first comment out the initializing writeFileSync line, then remove public/index.html and run again. Otherwise the program recreates the homepage and the failure is not exercised. The lesson verifies this plugin/fallback combination; it does not establish checks of the project’s cloud hook, client routing, or real imports.

Real requests also depend on mode and bind address

The development proxy is in Vite configuration: the frontend binds 127.0.0.1:13721 and proxies /api to localhost:3721. Same-origin production API/page requests generally do not need cross-origin permission. The source still registers CORS with origin: true; it is not active only in development. Cross-origin calls follow that configuration, and CORS does not replace authentication.

The port comes from options.port or configuration, whose value is Number(PORT) || 3721. The default host is 0.0.0.0; Electron local mode passes 127.0.0.1 instead. Unknown /api/ requests reach JSON 404 locally or after passing cloud authentication. In cloud mode, incomplete setup can produce 403 first, and missing valid credentials can produce 401 first. See the pinned authentication hook.

Shutdown order does not establish drained requests

Service shutdown awaits watcher.close, calls closeDatabase, and then awaits app.close. Database closure stops autosave, performs a final export/write, and releases the in-memory database. HTTP close occurs after database closure. This sequence cannot be described as waiting for all in-flight requests before safely closing the database. Stopping the watcher also does not automatically await every queued AI task.

The standalone entry calls shutdown on SIGINT/SIGTERM; Electron calls the returned callback. Deployment acceptance should cover an absent frontend directory, an existing directory without the homepage, unknown APIs, missing JS files, unauthenticated cloud requests, and in-flight operations during shutdown, recording each response or exit result.

Share:

Related Posts

Learn Fastify from ChatCrystal: plugins, validation, and failures

Read the actual createServer and route boundaries, then use an isolated inject exercise to check success, invalid input, and missing routes.

曾亮懿 曾亮懿
3 min read
#ChatCrystal #Fastify #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.