From a3bf420282dc80b0520679370b79e0390aa4abec Mon Sep 17 00:00:00 2001 From: Samu Date: Sat, 29 Aug 2026 17:46:52 +0200 Subject: [PATCH] feat: serve built SPA with fallback and artwork cache --- server/src/app.ts | 29 ++++++++++++++++++ server/test/app.test.ts | 10 ++----- server/test/static.test.ts | 60 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 8 deletions(-) create mode 100644 server/test/static.test.ts diff --git a/server/src/app.ts b/server/src/app.ts index 5e89d52..a042e6d 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -1,6 +1,9 @@ import Fastify, { FastifyInstance } from 'fastify' import Database from 'better-sqlite3' import cookie from '@fastify/cookie' +import fastifyStatic from '@fastify/static' +import path from 'node:path' +import { existsSync } from 'node:fs' import type { Config } from './config.js' import { SerialQueue } from './queue.js' import { SyncManager } from './sync.js' @@ -27,6 +30,8 @@ export interface AppOptions { db: Database.Database config: Config fetchImpl?: typeof fetch + /** Directory of the built SPA. Defaults to web/dist when it exists. */ + webDist?: string } export async function buildApp(opts: AppOptions): Promise { @@ -44,6 +49,30 @@ export async function buildApp(opts: AppOptions): Promise { await registerCollectionRoutes(app) await registerLibraryRoutes(app) + // artwork cache (always available) + await app.register(fastifyStatic, { + root: opts.config.artworkDir, + prefix: '/artwork/', + decorateReply: false, + }) + + const webDist = opts.webDist ?? path.resolve('web/dist') + const hasWeb = existsSync(webDist) + if (hasWeb) { + await app.register(fastifyStatic, { root: webDist, prefix: '/' }) + } + + app.setNotFoundHandler((request, reply) => { + const url = request.raw.url ?? '' + if (url.startsWith('/api')) { + return reply.code(404).send({ error: 'not_found' }) + } + if (hasWeb) { + return reply.sendFile('index.html') + } + return reply.code(404).send({ error: 'not_found' }) + }) + app.get('/api/health', async () => ({ ok: true })) return app } diff --git a/server/test/app.test.ts b/server/test/app.test.ts index a9712bb..7c5bb24 100644 --- a/server/test/app.test.ts +++ b/server/test/app.test.ts @@ -1,15 +1,9 @@ import { describe, it, expect } from 'vitest' import { buildApp } from '../src/app.js' import { openDatabase } from '../src/db.js' -import type { Config } from '../src/config.js' +import { testConfig } from './helpers.js' -const config: Config = { - dataDir: ':memory:', - artworkDir: ':memory:', - dbPath: ':memory:', - port: 0, - sessionSecret: 'test-secret-test-secret-test-secret-1234', -} +const config = testConfig() describe('GET /api/health', () => { it('returns ok', async () => { diff --git a/server/test/static.test.ts b/server/test/static.test.ts new file mode 100644 index 0000000..1594322 --- /dev/null +++ b/server/test/static.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { testConfig } from './helpers.js' +import { openDatabase } from '../src/db.js' +import { buildApp } from '../src/app.js' + +describe('static serving', () => { + let dir: string + + beforeAll(() => { + dir = mkdtempSync(path.join(tmpdir(), 'rs-static-')) + mkdirSync(path.join(dir, 'web', 'dist'), { recursive: true }) + mkdirSync(path.join(dir, 'artwork'), { recursive: true }) + writeFileSync(path.join(dir, 'web', 'dist', 'index.html'), 'record-shop') + writeFileSync(path.join(dir, 'artwork', 'abc.jpg'), 'fakejpeg') + }) + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + async function build() { + const config = { ...testConfig(), artworkDir: path.join(dir, 'artwork') } + const app = await buildApp({ + db: openDatabase(':memory:'), + config, + webDist: path.join(dir, 'web', 'dist'), + }) + return app + } + + it('serves index.html at / and SPA-falls back for client routes', async () => { + const app = await build() + const root = await app.inject({ method: 'GET', url: '/' }) + expect(root.statusCode).toBe(200) + expect(root.body).toContain('record-shop') + const spa = await app.inject({ method: 'GET', url: '/library' }) + expect(spa.statusCode).toBe(200) + expect(spa.body).toContain('record-shop') + await app.close() + }) + + it('serves artwork files', async () => { + const app = await build() + const res = await app.inject({ method: 'GET', url: '/artwork/abc.jpg' }) + expect(res.statusCode).toBe(200) + expect(res.body).toBe('fakejpeg') + await app.close() + }) + + it('unknown api routes return json 404, not the SPA', async () => { + const app = await build() + const res = await app.inject({ method: 'GET', url: '/api/nope' }) + expect(res.statusCode).toBe(404) + expect(res.json()).toEqual({ error: 'not_found' }) + await app.close() + }) +})