feat: serve built SPA with fallback and artwork cache
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
import Fastify, { FastifyInstance } from 'fastify'
|
import Fastify, { FastifyInstance } from 'fastify'
|
||||||
import Database from 'better-sqlite3'
|
import Database from 'better-sqlite3'
|
||||||
import cookie from '@fastify/cookie'
|
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 type { Config } from './config.js'
|
||||||
import { SerialQueue } from './queue.js'
|
import { SerialQueue } from './queue.js'
|
||||||
import { SyncManager } from './sync.js'
|
import { SyncManager } from './sync.js'
|
||||||
@@ -27,6 +30,8 @@ export interface AppOptions {
|
|||||||
db: Database.Database
|
db: Database.Database
|
||||||
config: Config
|
config: Config
|
||||||
fetchImpl?: typeof fetch
|
fetchImpl?: typeof fetch
|
||||||
|
/** Directory of the built SPA. Defaults to web/dist when it exists. */
|
||||||
|
webDist?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildApp(opts: AppOptions): Promise<FastifyInstance> {
|
export async function buildApp(opts: AppOptions): Promise<FastifyInstance> {
|
||||||
@@ -44,6 +49,30 @@ export async function buildApp(opts: AppOptions): Promise<FastifyInstance> {
|
|||||||
await registerCollectionRoutes(app)
|
await registerCollectionRoutes(app)
|
||||||
await registerLibraryRoutes(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 }))
|
app.get('/api/health', async () => ({ ok: true }))
|
||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { buildApp } from '../src/app.js'
|
import { buildApp } from '../src/app.js'
|
||||||
import { openDatabase } from '../src/db.js'
|
import { openDatabase } from '../src/db.js'
|
||||||
import type { Config } from '../src/config.js'
|
import { testConfig } from './helpers.js'
|
||||||
|
|
||||||
const config: Config = {
|
const config = testConfig()
|
||||||
dataDir: ':memory:',
|
|
||||||
artworkDir: ':memory:',
|
|
||||||
dbPath: ':memory:',
|
|
||||||
port: 0,
|
|
||||||
sessionSecret: 'test-secret-test-secret-test-secret-1234',
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('GET /api/health', () => {
|
describe('GET /api/health', () => {
|
||||||
it('returns ok', async () => {
|
it('returns ok', async () => {
|
||||||
|
|||||||
60
server/test/static.test.ts
Normal file
60
server/test/static.test.ts
Normal file
@@ -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'), '<html>record-shop</html>')
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user