diff --git a/.changeset/owned-config-reloading.md b/.changeset/owned-config-reloading.md new file mode 100644 index 0000000..93c541b --- /dev/null +++ b/.changeset/owned-config-reloading.md @@ -0,0 +1,5 @@ +--- +'@directory/web': patch +--- + +Separate configuration monitoring from application updates, retaining last-good configuration and restart-only authentication settings. Detect same-sized edits that preserve modification time. diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts index 27684b5..2694a61 100644 --- a/apps/web/src/app.d.ts +++ b/apps/web/src/app.d.ts @@ -1,7 +1,7 @@ import type { Session } from '@auth/sveltekit'; import type { DirectoryContent } from './lib/display/types.js'; import type { Authentication } from './lib/server/auth/index.js'; -import type { PreparedSnapshot } from './lib/server/config/snapshot.js'; +import type { PreparedSnapshot } from './lib/server/directory/snapshot.js'; declare global { namespace NodeJS { diff --git a/apps/web/src/hooks.server.test.ts b/apps/web/src/hooks.server.test.ts index 2bfc427..8e3f0db 100644 --- a/apps/web/src/hooks.server.test.ts +++ b/apps/web/src/hooks.server.test.ts @@ -7,8 +7,7 @@ import { requireSignIn } from './lib/server/auth/access.js'; import { readMountedImage } from './lib/server/images/read-mounted-image.js'; import { GET as mountedImage } from './routes/(protected)/(endpoints)/public/[...file]/+server.js'; import { GET as bookmarkLogo } from './routes/(protected)/(endpoints)/bookmarks/[id]/logo/+server.js'; -import type { Application } from './lib/server/lifecycle/application.js'; -import type { Directory } from './lib/server/config/load-directory.js'; +import type { Application } from './lib/server/application.js'; const adapterEvents: NodeJS.EventEmitter = process; @@ -20,7 +19,7 @@ const mocks = vi.hoisted(() => ({ report: vi.fn(), load: vi.fn(), path: vi.fn(), - prepare: vi.fn() + prepare: vi.fn() })); vi.mock('$env/dynamic/private', () => ({ env: mocks.env })); @@ -60,10 +59,10 @@ vi.mock('@directory/config', async (importOriginal) => ({ loadConfigFile: mocks.load, resolveConfigPath: mocks.path })); -vi.mock('./lib/server/config/snapshot.js', () => ({ prepareSnapshot: mocks.prepare })); +vi.mock('./lib/server/directory/snapshot.js', () => ({ prepareSnapshot: mocks.prepare })); -const realSnapshot = await vi.importActual( - './lib/server/config/snapshot.js' +const realSnapshot = await vi.importActual( + './lib/server/directory/snapshot.js' ); const sentinel = 'PRIVATE-config-password-not-for-browser'; type Resolve = Parameters[0]['resolve']; @@ -98,11 +97,8 @@ function event(): RequestEvent { function application(title: string, authentication?: Authentication) { return { - directory: { - currentSnapshot: vi.fn().mockReturnValue(snapshot(title)), - reload: vi.fn(), - stop: vi.fn() - }, + currentSnapshot: vi.fn().mockReturnValue(snapshot(title)), + onConfigChange: vi.fn().mockReturnValue(true), publicDir: '/mounted/public', authentication, stop: vi.fn() @@ -169,7 +165,7 @@ describe('server hooks', () => { expect(unavailable.headers.get('referrer-policy')).toBe('strict-origin-when-cross-origin'); expect(await unavailable.text()).toBe('Service unavailable'); expect(resolve).not.toHaveBeenCalled(); - expect(ready.directory.currentSnapshot).not.toHaveBeenCalled(); + expect(ready.currentSnapshot).not.toHaveBeenCalled(); pending.resolve(ready); await startup; expect(mocks.boot).toHaveBeenCalledExactlyOnceWith(undefined, mocks.env); @@ -264,9 +260,7 @@ describe('server hooks', () => { const authentication: Authentication = { strategy, handle: vi.fn().mockImplementation(async ({ event }) => { - expect(event.locals.snapshot).toBe( - ready.directory.currentSnapshot.mock.results[0]?.value - ); + expect(event.locals.snapshot).toBe(ready.currentSnapshot.mock.results[0]?.value); expect(event.locals.publicDir).toBe(ready.publicDir); expect(event.locals.authentication).toBe(authentication); expect(event.locals.session).toBeNull(); @@ -290,7 +284,7 @@ describe('server hooks', () => { event: expect.objectContaining({ locals: request.locals }), resolve: expect.any(Function) }); - expect(ready.directory.currentSnapshot).toHaveBeenCalledOnce(); + expect(ready.currentSnapshot).toHaveBeenCalledOnce(); expect(resolve).not.toHaveBeenCalled(); expect(authentication.signIn).not.toHaveBeenCalled(); expect(authentication.signOut).not.toHaveBeenCalled(); @@ -409,8 +403,8 @@ describe('server hooks', () => { }; } const ready = application('Initial', authentication); - const captured = ready.directory.currentSnapshot(); - ready.directory.currentSnapshot.mockClear(); + const captured = ready.currentSnapshot(); + ready.currentSnapshot.mockClear(); const before = JSON.stringify(captured); const replacement = snapshot('Replacement', '#abc', '#def'); mocks.boot.mockResolvedValueOnce(ready); @@ -420,7 +414,7 @@ describe('server hooks', () => { const resolve = vi.fn().mockImplementation(async (event, options) => { expect(event.locals).toBe(request.locals); expect(event.locals.snapshot).toBe(captured); - ready.directory.currentSnapshot.mockReturnValue(replacement); + ready.currentSnapshot.mockReturnValue(replacement); const transform = options?.transformPageChunk; if (transform === undefined) throw new Error('Missing theme transform'); if (strategy !== 'none') { @@ -446,7 +440,7 @@ describe('server hooks', () => { expect(html).not.toContain('Replacement'); expect(html).not.toContain('#abc'); expect(html).not.toContain('--directory-'); - expect(ready.directory.currentSnapshot).toHaveBeenCalledOnce(); + expect(ready.currentSnapshot).toHaveBeenCalledOnce(); expect(request.locals.snapshot).toBe(captured); expect(JSON.stringify(captured)).toBe(before); expect(mocks.load).not.toHaveBeenCalled(); @@ -465,13 +459,9 @@ describe('server hooks', () => { const initial = snapshot('Initial'); const replacement = snapshot('Replacement'); let published = initial; - const directory = { - currentSnapshot: vi.fn(() => published), - reload: vi.fn(), - stop: vi.fn() - }; const app = { - directory, + currentSnapshot: vi.fn(() => published), + onConfigChange: vi.fn().mockReturnValue(true), publicDir: '/mounted/public', authentication: undefined, stop: vi.fn() @@ -494,7 +484,7 @@ describe('server hooks', () => { }); const firstEvent = event(); const first = await hooks.handle({ event: firstEvent, resolve }); - expect(directory.currentSnapshot).toHaveBeenCalledOnce(); + expect(app.currentSnapshot).toHaveBeenCalledOnce(); expect(firstEvent.locals.snapshot).toBe(initial); expect(firstEvent.locals.authentication).toBeUndefined(); expect(firstEvent.locals.session).toBeNull(); @@ -509,7 +499,7 @@ describe('server hooks', () => { expect(capturedPaths).toEqual([app.publicDir, app.publicDir]); const secondEvent = event(); const second = await hooks.handle({ event: secondEvent, resolve }); - expect(directory.currentSnapshot).toHaveBeenCalledTimes(2); + expect(app.currentSnapshot).toHaveBeenCalledTimes(2); expect(secondEvent.locals.snapshot).toBe(replacement); expect(await second.json()).toEqual({ first: 'Replacement', second: 'Replacement' }); expect(capturedPaths).toEqual(Array(4).fill(app.publicDir)); @@ -517,6 +507,6 @@ describe('server hooks', () => { expect(mocks.path).not.toHaveBeenCalled(); expect(mocks.load).not.toHaveBeenCalled(); expect(mocks.prepare).not.toHaveBeenCalled(); - expect(directory.reload).not.toHaveBeenCalled(); + expect(app.onConfigChange).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts index 22f906f..021238c 100644 --- a/apps/web/src/hooks.server.ts +++ b/apps/web/src/hooks.server.ts @@ -3,7 +3,7 @@ import { env } from '$env/dynamic/private'; import type { Handle, ServerInit } from '@sveltejs/kit'; import { sequence } from '@sveltejs/kit/hooks'; import { themeStyleTag } from './lib/server/appearance.js'; -import type { ServerState } from './lib/server/lifecycle/application.js'; +import type { ServerState } from './lib/server/application.js'; import { startDevelopment } from './lib/server/lifecycle/development.js'; import { startProduction } from './lib/server/lifecycle/production.js'; import { reportStartupFailure } from './lib/server/logger.js'; @@ -30,7 +30,7 @@ const applicationHandle: Handle = ({ event, resolve }) => { headers: { 'cache-control': 'no-store' } }); } - event.locals.snapshot = application.directory.currentSnapshot(); + event.locals.snapshot = application.currentSnapshot(); event.locals.publicDir = application.publicDir; event.locals.authentication = application.authentication; event.locals.session = null; diff --git a/apps/web/src/lib/server/appearance.test.ts b/apps/web/src/lib/server/appearance.test.ts index bb02f9e..0bc7599 100644 --- a/apps/web/src/lib/server/appearance.test.ts +++ b/apps/web/src/lib/server/appearance.test.ts @@ -1,7 +1,7 @@ import { validateConfig } from '@directory/config'; import { describe, expect, it } from 'vitest'; import { themeStyleTag } from './appearance.js'; -import { prepareSnapshot } from './config/snapshot.js'; +import { prepareSnapshot } from './directory/snapshot.js'; function snapshot(light: string, dark: string) { return prepareSnapshot( diff --git a/apps/web/src/lib/server/application.test.ts b/apps/web/src/lib/server/application.test.ts new file mode 100644 index 0000000..19f9f79 --- /dev/null +++ b/apps/web/src/lib/server/application.test.ts @@ -0,0 +1,269 @@ +import { inspect } from 'node:util'; +import { validateConfig } from '@directory/config'; +import type { DirectoryConfiguration } from '@directory/config/types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createApplication } from './application.js'; +import type { Authentication } from './auth/index.js'; +import { DirectoryError } from './directory-error.js'; + +const mocks = vi.hoisted(() => ({ + prepare: vi.fn(), + authenticate: vi.fn(), + report: vi.fn() +})); +vi.mock('./directory/snapshot.js', async (importOriginal) => ({ + ...(await importOriginal()), + prepareSnapshot: mocks.prepare +})); +vi.mock('./auth/index.js', () => ({ createAuthentication: mocks.authenticate })); +vi.mock('./logger.js', () => ({ reportDirectoryIssue: mocks.report })); + +const realSnapshot = + await vi.importActual('./directory/snapshot.js'); +const sentinel = 'PRIVATE-password-env-filename.yml'; +const credentials = { + origin: 'https://directory.example.com', + credentials: { username: 'family', password: sentinel } +}; +const authentication: Authentication = { + strategy: 'credentials', + handle: vi.fn(), + signIn: vi.fn(), + signOut: vi.fn() +}; + +function configuration( + title = 'boot', + auth?: DirectoryConfiguration['auth'] +): DirectoryConfiguration { + return validateConfig( + { + site: { title, logo_path: `/public/${title}.svg` }, + ...(auth === undefined ? {} : { auth }), + links: { + photos: { + title: `${title} photos`, + url: 'https://photos.example.com', + logo_path: `/public/${title}-photos.svg`, + tags: [' photos ', ' backup '] + } + } + }, + { env: {} } + ); +} + +function expectRedacted(value: unknown): void { + for (const text of [String(value), JSON.stringify(value), inspect(value)]) { + expect(text).not.toContain(sentinel); + } + if (value instanceof Error) expect(value.stack).not.toContain(sentinel); + expect(value).not.toHaveProperty('cause'); + expect(value).not.toHaveProperty('value'); + expect(value).not.toHaveProperty('path'); +} + +function captureDirectoryError(action: () => unknown): DirectoryError { + try { + action(); + } catch (error) { + expect(error).toBeInstanceOf(DirectoryError); + expectRedacted(error); + return error as DirectoryError; + } + throw new Error('Expected a synchronous DirectoryError'); +} + +beforeEach(() => { + mocks.prepare.mockReset().mockImplementation(realSnapshot.prepareSnapshot); + mocks.authenticate.mockReset().mockReturnValue(authentication); + mocks.report.mockReset(); +}); + +describe('application startup', () => { + it('synchronously prepares private configuration and constructs authentication from that snapshot', () => { + const initial = configuration('boot', credentials); + const app = createApplication(initial); + const snapshot = app.currentSnapshot(); + expect(mocks.prepare).toHaveBeenCalledExactlyOnceWith(initial); + expect(snapshot).toBe(mocks.prepare.mock.results[0]!.value); + expect(mocks.authenticate).toHaveBeenCalledExactlyOnceWith(snapshot.configuration.auth); + expect(mocks.authenticate.mock.calls[0]![0]).toBe(snapshot.configuration.auth); + expect(app.authentication).toBe(authentication); + expect(snapshot.configuration.auth?.credentials?.password).toBe(sentinel); + expect(snapshot.configuration.auth).not.toHaveProperty('secret'); + expect(snapshot.display.groups[0]?.links[0]?.tags).toEqual(['photos', 'backup']); + expect(snapshot.assets.referencedPaths).toEqual([ + '/public/boot.svg', + '/public/boot-photos.svg' + ]); + expectRedacted(snapshot.display); + expect(mocks.report).not.toHaveBeenCalled(); + }); + + it('does not construct authentication when it is absent', () => { + expect(createApplication(configuration()).authentication).toBeUndefined(); + expect(mocks.authenticate).not.toHaveBeenCalled(); + }); + + it.each(['prepare_failed', 'auth_failed'] as const)('redacts %s at startup', (code) => { + const fail = () => { + throw Object.assign(new Error(sentinel), { cause: sentinel, value: sentinel }); + }; + if (code === 'prepare_failed') mocks.prepare.mockImplementationOnce(fail); + else mocks.authenticate.mockImplementationOnce(fail); + const error = captureDirectoryError(() => + createApplication(configuration('boot', credentials)) + ); + expect(error.issue).toEqual({ code }); + if (code === 'prepare_failed') expect(mocks.authenticate).not.toHaveBeenCalled(); + expect(mocks.report).not.toHaveBeenCalled(); + }); +}); + +describe('application config policy', () => { + it('replaces all snapshot views together, preserving old captures and the original authentication', () => { + const initial = configuration('boot', credentials); + const app = createApplication(initial); + const captured = app.currentSnapshot(); + const next = configuration('next', credentials); + mocks.prepare.mockImplementationOnce((config) => { + expect(app.currentSnapshot()).toBe(captured); + return realSnapshot.prepareSnapshot(config); + }); + expect(app.onConfigChange(next, initial)).toBe(true); + const current = app.currentSnapshot(); + expect(current).not.toBe(captured); + expect(current.configuration.site.title).toBe('next'); + expect(current.display.site.title).toBe('next'); + expect(current.display.groups[0]?.links[0]?.title).toBe('next photos'); + expect(current.assets.referencedPaths).toEqual(['/public/next.svg', '/public/next-photos.svg']); + expect(current.assets.bookmarkLogos.photos).not.toBe(captured.assets.bookmarkLogos.photos); + expect(current.display.snapshotId).not.toBe(captured.display.snapshotId); + expect(captured.configuration.site.title).toBe('boot'); + expect(captured.display.site.title).toBe('boot'); + expect(captured.display.groups[0]?.links[0]?.title).toBe('boot photos'); + expect(captured.assets.referencedPaths).toEqual([ + '/public/boot.svg', + '/public/boot-photos.svg' + ]); + expectRedacted(current.display); + expect(app.authentication).toBe(authentication); + expect(mocks.authenticate).toHaveBeenCalledOnce(); + expect(mocks.report).not.toHaveBeenCalled(); + }); + + it('accepts equivalent normalized values and mapping-key order without preparing or publishing', () => { + const initial = configuration('boot', credentials); + const app = createApplication(initial); + const accepted = configuration('accepted', credentials); + expect(app.onConfigChange(accepted, initial)).toBe(true); + const current = app.currentSnapshot(); + // Source formatting is gone by this boundary; independently loaded values matter. + const equivalent = configuration('accepted', credentials); + equivalent.site = Object.fromEntries( + Object.entries(equivalent.site).reverse() + ) as DirectoryConfiguration['site']; + equivalent.auth = { + credentials: { password: sentinel, username: 'family' }, + origin: credentials.origin + }; + expect(app.onConfigChange(equivalent, accepted)).toBe(true); + expect(app.onConfigChange(configuration('accepted', credentials), equivalent)).toBe(true); + expect(app.currentSnapshot()).toBe(current); + expect(mocks.prepare).toHaveBeenCalledTimes(2); + expect(mocks.authenticate).toHaveBeenCalledOnce(); + expect(mocks.report).not.toHaveBeenCalled(); + }); + + it('publishes bookmark declaration order even when mapping values are equal', () => { + const initial = validateConfig({ + links: { + zebra: { title: 'Zebra', url: 'https://zebra.example.com', group: 'Tools' }, + alpha: { title: 'Alpha', url: 'https://alpha.example.com', group: 'Family' }, + beta: { title: 'Beta', url: 'https://beta.example.com', group: 'Tools' } + } + }); + const app = createApplication(initial); + const captured = app.currentSnapshot(); + const reordered = { + ...initial, + links: { alpha: initial.links.alpha!, beta: initial.links.beta!, zebra: initial.links.zebra! } + }; + expect(reordered).toEqual(initial); + expect(app.onConfigChange(reordered, initial)).toBe(true); + const current = app.currentSnapshot(); + expect(current.display.snapshotId).not.toBe(captured.display.snapshotId); + expect(current.display.groups.map((group) => group.label)).toEqual(['Family', 'Tools']); + expect(current.display.groups.map((group) => group.links.map((link) => link.id))).toEqual([ + ['alpha'], + ['beta', 'zebra'] + ]); + expect(captured.display.groups.map((group) => group.links.map((link) => link.id))).toEqual([ + ['zebra', 'beta'], + ['alpha'] + ]); + expect(mocks.prepare).toHaveBeenCalledTimes(2); + }); + + it.each([ + { change: 'enable auth', before: undefined, after: credentials }, + { change: 'disable auth', before: credentials, after: undefined }, + { + change: 'origin', + before: credentials, + after: { ...credentials, origin: 'https://other.example.com' } + }, + { + change: 'password', + before: credentials, + after: { ...credentials, credentials: { ...credentials.credentials, password: 'changed' } } + }, + { + change: 'session secret', + before: { ...credentials, secret: `${sentinel}-session` }, + after: { ...credentials, secret: `${sentinel}-replacement` } + } + ])('rejects the whole config for $change until auth is restored', ({ before, after }) => { + const initial = configuration('boot', before); + const app = createApplication(initial); + const accepted = configuration('accepted', before); + expect(app.onConfigChange(accepted, initial)).toBe(true); + const captured = app.currentSnapshot(); + expect(app.onConfigChange(configuration('rejected', after), accepted)).toBe(false); + expect(app.currentSnapshot()).toBe(captured); + expect(mocks.prepare).toHaveBeenCalledTimes(2); + expect(mocks.report).toHaveBeenCalledExactlyOnceWith({ code: 'restart_required' }); + expectRedacted(mocks.report.mock.calls); + const restored = configuration('restored', before); + expect(app.onConfigChange(restored, accepted)).toBe(true); + expect(app.currentSnapshot().display.site.title).toBe('restored'); + expect(app.currentSnapshot().configuration.auth).toEqual(before); + expect(app.authentication).toBe(before === undefined ? undefined : authentication); + expect(mocks.authenticate).toHaveBeenCalledTimes(before === undefined ? 0 : 1); + }); + + it('throws a safe preparation failure, retains last good, and recovers using the accepted baseline', () => { + const initial = configuration(); + const app = createApplication(initial); + const accepted = configuration('accepted'); + expect(app.onConfigChange(accepted, initial)).toBe(true); + const captured = app.currentSnapshot(); + mocks.prepare.mockImplementationOnce(() => { + throw Object.assign(new Error(sentinel), { cause: sentinel, value: sentinel }); + }); + const error = captureDirectoryError(() => + app.onConfigChange(configuration('failed'), accepted) + ); + expect(error.issue).toEqual({ code: 'prepare_failed' }); + expect(app.currentSnapshot()).toBe(captured); + // The generic monitor, not this callback, reports thrown failures. + expect(mocks.report).not.toHaveBeenCalled(); + expect(app.onConfigChange(configuration('accepted'), accepted)).toBe(true); + expect(app.currentSnapshot()).toBe(captured); + expect(mocks.prepare).toHaveBeenCalledTimes(3); + expect(app.onConfigChange(configuration('recovered'), accepted)).toBe(true); + expect(app.currentSnapshot().display.site.title).toBe('recovered'); + expect(captured.display.site.title).toBe('accepted'); + }); +}); diff --git a/apps/web/src/lib/server/application.ts b/apps/web/src/lib/server/application.ts new file mode 100644 index 0000000..aaddc53 --- /dev/null +++ b/apps/web/src/lib/server/application.ts @@ -0,0 +1,49 @@ +import { isDeepStrictEqual } from 'node:util'; +import { isSameConfiguration } from '@directory/config'; +import type { DirectoryConfiguration } from '@directory/config/types'; +import { createAuthentication, type Authentication } from './auth/index.js'; +import { DirectoryError } from './directory-error.js'; +import { prepareSnapshot } from './directory/snapshot.js'; +import { reportDirectoryIssue } from './logger.js'; + +export type Application = ReturnType & { + readonly publicDir: string; + stop(): void; +}; + +export interface ServerState { + application: Application | undefined; +} + +export function createApplication(initial: DirectoryConfiguration) { + let snapshot = prepare(initial); + let authentication: Authentication | undefined; + try { + const auth = snapshot.configuration.auth; + if (auth !== undefined) authentication = createAuthentication(auth); + } catch { + throw new DirectoryError({ code: 'auth_failed' }); + } + + return { + currentSnapshot: () => snapshot, + authentication, + onConfigChange(next: DirectoryConfiguration, previous: DirectoryConfiguration): boolean { + if (!isDeepStrictEqual(next.auth, previous.auth)) { + reportDirectoryIssue({ code: 'restart_required' }); + return false; + } + if (isSameConfiguration(next, previous)) return true; + snapshot = prepare(next); + return true; + } + }; +} + +function prepare(config: DirectoryConfiguration) { + try { + return prepareSnapshot(config); + } catch { + throw new DirectoryError({ code: 'prepare_failed' }); + } +} diff --git a/apps/web/src/lib/server/auth/index.test.ts b/apps/web/src/lib/server/auth/index.test.ts index 79a5adc..1c40324 100644 --- a/apps/web/src/lib/server/auth/index.test.ts +++ b/apps/web/src/lib/server/auth/index.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { isHttpError, isRedirect, redirect, type Handle, type RequestEvent } from '@sveltejs/kit'; import type { Session, SvelteKitAuthConfig } from '@auth/sveltekit'; import { validateConfig } from '@directory/config'; -import { prepareSnapshot } from '../config/snapshot.js'; +import { prepareSnapshot } from '../directory/snapshot.js'; import { GET as mountedImage } from '../../../routes/(protected)/(endpoints)/public/[...file]/+server.js'; import { createAuthentication } from './index.js'; diff --git a/apps/web/src/lib/server/boot.process.test.ts b/apps/web/src/lib/server/boot.process.test.ts index 6f2fd7c..e5c27eb 100644 --- a/apps/web/src/lib/server/boot.process.test.ts +++ b/apps/web/src/lib/server/boot.process.test.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process'; -import { mkdtemp, rename, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, rename, rm, stat, utimes, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it, onTestFinished, vi } from 'vitest'; @@ -232,20 +232,33 @@ it('keeps application environment references and fallbacks stable across bootstr expect(await server.stop()).toEqual({ code: 0, signal: null }); }); -it('polls atomic saves, retains last good across invalid/deleted files, and shuts down cleanly', async () => { +it('polls atomic and ctime-only saves, retains last good across invalid/deleted files, and shuts down cleanly', async () => { const server = await launchServer(source('first')); const origin = await server.ready(); await expectHealthy(origin); await expectPage(origin, 'first'); const replacement = `${server.path}.replacement`; + const fixedTime = new Date('2020-01-01T00:00:00Z'); await writeFile(replacement, source('second')); + await utimes(replacement, fixedTime, fixedTime); await rename(replacement, server.path); await vi.waitFor(() => expectPage(origin, 'second'), { timeout: 5000, interval: 50 }); + // The HTTP response confirms the watcher observed the atomic save's fixed-mtime baseline. + const beforeEdit = await stat(server.path); + await writeFile(server.path, source('edited')); + await utimes(server.path, fixedTime, fixedTime); + const afterEdit = await stat(server.path); + expect(afterEdit.ino).toBe(beforeEdit.ino); + expect(afterEdit.size).toBe(beforeEdit.size); + expect(afterEdit.mtimeMs).toBe(beforeEdit.mtimeMs); + expect(afterEdit.ctimeMs).not.toBe(beforeEdit.ctimeMs); + await vi.waitFor(() => expectPage(origin, 'edited'), { timeout: 5000, interval: 50 }); + await writeFile(server.path, `links: [\n${sentinel}`); await vi.waitFor(() => expect(server.output()).toContain('load_failed'), { timeout: 5000 }); - await expectPage(origin, 'second'); + await expectPage(origin, 'edited'); await expectHealthy(origin); const beforeDeletion = server.output().split('load_failed').length; @@ -254,7 +267,7 @@ it('polls atomic saves, retains last good across invalid/deleted files, and shut () => expect(server.output().split('load_failed').length).toBeGreaterThan(beforeDeletion), { timeout: 5000 } ); - await expectPage(origin, 'second'); + await expectPage(origin, 'edited'); await expectHealthy(origin); await writeFile(server.path, source('third')); diff --git a/apps/web/src/lib/server/boot.test.ts b/apps/web/src/lib/server/boot.test.ts index 03c0e79..ae0173c 100644 --- a/apps/web/src/lib/server/boot.test.ts +++ b/apps/web/src/lib/server/boot.test.ts @@ -1,459 +1,208 @@ -import { inspect } from 'node:util'; -import { resolve } from 'node:path'; import { ConfigError, validateConfig } from '@directory/config'; import type { DirectoryConfiguration } from '@directory/config/types'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { reportConfigError } from './logger.js'; import type { Authentication } from './auth/index.js'; import { bootApp } from './boot.js'; -import type { Directory } from './config/load-directory.js'; import { DirectoryError } from './directory-error.js'; -import { prepareSnapshot } from './config/snapshot.js'; -import { logger, reportDirectoryIssue, reportStartupFailure } from './logger.js'; const mocks = vi.hoisted(() => ({ - auth: vi.fn(), path: vi.fn(), publicDir: vi.fn(), load: vi.fn(), - directory: vi.fn(), - watch: vi.fn(), - stopWatching: vi.fn<() => void>() + monitor: vi.fn(), + auth: vi.fn(), + stop: vi.fn<() => void>() })); - vi.mock('@directory/config', async (importOriginal) => ({ ...(await importOriginal()), resolveConfigPath: mocks.path, resolvePublicDir: mocks.publicDir, - loadConfigFile: mocks.load + loadConfigFile: mocks.load, + startConfigReloading: mocks.monitor })); -vi.mock('./config/load-directory.js', async (importOriginal) => ({ - ...(await importOriginal()), - loadDirectory: mocks.directory -})); -vi.mock('./config/watch.js', () => ({ watchConfig: mocks.watch })); vi.mock('./auth/index.js', () => ({ createAuthentication: mocks.auth })); -const realDirectory = await vi.importActual( - './config/load-directory.js' -); -const realConfig = await vi.importActual('@directory/config'); -const sentinel = 'PRIVATE-environment-password-filename.yaml'; +const path = '/config/directory.yaml'; +const sentinel = 'PRIVATE-config-credential'; const origin = 'https://directory.example.com'; const frameworkEnv = { ORIGIN: origin, AUTH_URL: origin }; -const authCases = [ - { - strategy: 'credentials', - auth: { origin, credentials: { username: sentinel, password: sentinel } } - }, - { - strategy: 'oidc', - auth: { - origin, - oidc: { - issuer: 'https://identity.example.com', - client_id: sentinel, - client_secret: sentinel - } - } - } -] satisfies { strategy: Authentication['strategy']; auth: DirectoryConfiguration['auth'] }[]; +const auth = { origin, credentials: { username: 'family', password: sentinel } }; +const authentication: Authentication = { + strategy: 'credentials', + handle: vi.fn(), + signIn: vi.fn(), + signOut: vi.fn() +}; -function authentication(strategy: Authentication['strategy']): Authentication { - return { - strategy, - handle: vi.fn().mockResolvedValue(new Response()), - signIn: vi.fn().mockResolvedValue(undefined), - signOut: vi.fn().mockResolvedValue(undefined) - }; -} - -function configuration(auth?: DirectoryConfiguration['auth']): DirectoryConfiguration { - const input: Record = { - site: { title: 'Family', description: 'Welcome' }, - links: { photos: { title: 'Photos', url: 'https://photos.example.com' } } - }; - if (auth !== undefined) input.auth = auth; - return validateConfig(input, { env: {} }); -} - -function loadedDirectory(config = configuration()) { - return { - currentSnapshot: vi.fn().mockReturnValue(prepareSnapshot(config)), - reload: vi.fn().mockResolvedValue(undefined), - stop: vi.fn() - }; -} - -function expectRedacted(error: unknown): void { - for (const text of [String(error), inspect(error), JSON.stringify(error)]) { - expect(text).not.toContain(sentinel); - } - if (error instanceof Error) expect(error.stack).not.toContain(sentinel); - expect(error).not.toHaveProperty('cause'); +function configuration(title = 'Home', enabled = false): DirectoryConfiguration { + return validateConfig({ site: { title }, links: {}, ...(enabled ? { auth } : {}) }); } beforeEach(() => { - vi.clearAllMocks(); + vi.resetAllMocks(); vi.stubEnv('ORIGIN', undefined); vi.stubEnv('AUTH_URL', undefined); - mocks.auth.mockReset(); - mocks.path.mockReset().mockReturnValue(`/config/${sentinel}`); - mocks.publicDir.mockReset().mockReturnValue('/mounted/public'); - mocks.load.mockReset().mockResolvedValue(configuration()); - mocks.directory.mockReset().mockResolvedValue(loadedDirectory()); - mocks.watch.mockReset().mockReturnValue(mocks.stopWatching); + mocks.path.mockReturnValue(path); + mocks.publicDir.mockReturnValue('/mounted/public'); + mocks.load.mockResolvedValue(configuration()); + mocks.monitor.mockResolvedValue(mocks.stop); + mocks.auth.mockReturnValue(authentication); }); - afterEach(() => vi.unstubAllEnvs()); -describe('bootApp', () => { - it('waits for valid startup, pins one path for loading and watching, then closes the save gap', async () => { - const pending = Promise.withResolvers(); - const directory = loadedDirectory(); - mocks.directory.mockReturnValueOnce(pending.promise); - const env = { - CONFIG_PATH: `/config/${sentinel}`, - DIRECTORY_PUBLIC_DIR: '/mounted/public', - SECRET: sentinel - }; - const boot = bootApp(env); +describe('application boot wiring', () => { + it('resolves paths once and connects the loader, application reaction and diagnostics', async () => { + const initial = configuration(); + const pending = Promise.withResolvers(); + mocks.load.mockReturnValueOnce(pending.promise); + const env = { CONFIG_PATH: path, DIRECTORY_PUBLIC_DIR: '/mounted/public' }; + const startup = bootApp(env); expect(mocks.path).toHaveBeenCalledExactlyOnceWith({ env }); - expect(mocks.publicDir).toHaveBeenCalledExactlyOnceWith(`/config/${sentinel}`, { env }); - expect(mocks.directory).toHaveBeenCalledExactlyOnceWith(`/config/${sentinel}`, { - env, - report: reportDirectoryIssue - }); - expect(mocks.watch).not.toHaveBeenCalled(); + expect(mocks.publicDir).toHaveBeenCalledExactlyOnceWith(path, { env }); + expect(mocks.load).toHaveBeenCalledExactlyOnceWith(path, { env }); + expect(mocks.monitor).not.toHaveBeenCalled(); + env.CONFIG_PATH = '/config/changed.yaml'; env.DIRECTORY_PUBLIC_DIR = '/mounted/changed'; - pending.resolve(directory); - const app = await boot; - expect(mocks.path).toHaveBeenCalledTimes(1); - expect(directory.currentSnapshot).toHaveBeenCalledTimes(1); - expect(mocks.watch).toHaveBeenCalledExactlyOnceWith( - `/config/${sentinel}`, - expect.any(Function) - ); - expect(mocks.watch.mock.invocationCallOrder[0]).toBeLessThan( - directory.reload.mock.invocationCallOrder[0]! - ); - expect(directory.reload).toHaveBeenCalledTimes(1); - expect(app.directory).toBe(directory); + pending.resolve(initial); + const app = await startup; + expect(mocks.monitor).toHaveBeenCalledExactlyOnceWith({ + path, + initial, + load: expect.any(Function), + onChange: app.onConfigChange, + onError: reportConfigError + }); expect(app.publicDir).toBe('/mounted/public'); - expect(mocks.publicDir).toHaveBeenCalledTimes(1); - expect(app.authentication).toBeUndefined(); - expect(mocks.auth).not.toHaveBeenCalled(); - expect(app.directory.currentSnapshot()).toBe(directory.currentSnapshot.mock.results[0]?.value); - await mocks.watch.mock.calls[0]![1](); - await app.directory.reload(); - expect(directory.reload).toHaveBeenCalledTimes(3); + expect(app.currentSnapshot().display.site.title).toBe('Home'); + expect(mocks.path).toHaveBeenCalledOnce(); + expect(mocks.publicDir).toHaveBeenCalledOnce(); + + const options = mocks.monitor.mock.calls[0]![0]; + mocks.load.mockResolvedValueOnce(configuration('Updated')); + const next = await options.load(options.path); + expect(mocks.load).toHaveBeenLastCalledWith(path, { env }); + expect(options.onChange(next, options.initial)).toBe(true); + expect(app.currentSnapshot().display.site.title).toBe('Updated'); + expect(app.stop).toBe(mocks.stop); app.stop(); - expect(mocks.stopWatching).toHaveBeenCalledOnce(); - expect(directory.stop).toHaveBeenCalledOnce(); + expect(mocks.stop).toHaveBeenCalledOnce(); }); - it.each([ - [{}, '/deployment/public'], - [{ DIRECTORY_PUBLIC_DIR: 'mounted/images' }, resolve('mounted/images')], - [{ DIRECTORY_PUBLIC_DIR: '/mounted/images' }, '/mounted/images'] - ])( - 'pins the resolved public directory without requiring it to exist: %j', - async (env, expected) => { - mocks.path.mockReturnValue('/deployment/config/directory.yaml'); - mocks.publicDir.mockImplementation(realConfig.resolvePublicDir); - const app = await bootApp(env); - expect(app.publicDir).toBe(expected); - expect(mocks.publicDir).toHaveBeenCalledExactlyOnceWith('/deployment/config/directory.yaml', { - env - }); - app.stop(); - } - ); - - it('passes the same default options to both path resolvers', async () => { - const app = await bootApp(); - expect(mocks.path).toHaveBeenCalledExactlyOnceWith({ env: process.env }); - expect(mocks.directory).toHaveBeenCalledExactlyOnceWith(`/config/${sentinel}`, { - env: process.env, - report: reportDirectoryIssue - }); - expect(mocks.directory.mock.calls[0]![1]!.env).toBe(process.env); - expect(mocks.publicDir).toHaveBeenCalledExactlyOnceWith(`/config/${sentinel}`, { - env: process.env - }); - app.stop(); - }); - - it('does not resolve boot until the post-watch reload finishes', async () => { - const pending = Promise.withResolvers(); - const reloading = Promise.withResolvers(); - const directory = loadedDirectory(); - directory.reload.mockImplementationOnce(() => { - reloading.resolve(); + it('waits for monitor reconciliation before returning the application', async () => { + const entered = Promise.withResolvers(); + const pending = Promise.withResolvers<() => void>(); + mocks.monitor.mockImplementationOnce(() => { + entered.resolve(); return pending.promise; }); - mocks.directory.mockResolvedValueOnce(directory); - let ready = false; - const boot = bootApp().then((app) => { - ready = true; - return app; - }); - await reloading.promise; - expect(directory.reload).toHaveBeenCalledOnce(); - expect(ready).toBe(false); - pending.resolve(); - const app = await boot; - expect(ready).toBe(true); - app.stop(); + const ready = vi.fn(); + const startup = bootApp().then(ready); + await entered.promise; + expect(ready).not.toHaveBeenCalled(); + pending.resolve(mocks.stop); + await startup; + expect(ready).toHaveBeenCalledOnce(); }); - it('propagates redacted invalid startup without starting a watcher', async () => { - const error = new DirectoryError({ - code: 'load_failed', - issues: new ConfigError([{ code: 'missing_environment', path: `/links/${sentinel}/url` }]) - .issues - }); - mocks.directory.mockRejectedValueOnce(error); - await expect(bootApp()).rejects.toBe(error); - expectRedacted(error); - expect(mocks.watch).not.toHaveBeenCalled(); - }); - - it('redacts unknown startup errors', async () => { - mocks.directory.mockRejectedValueOnce(new Error(sentinel)); - const error: unknown = await bootApp().catch((error: unknown) => error); - expect(error).toBeInstanceOf(DirectoryError); - expect((error as DirectoryError).issue).toEqual({ code: 'load_failed' }); - expectRedacted(error); - expect(mocks.watch).not.toHaveBeenCalled(); - }); - - it('redacts path resolution errors before loading or watching', async () => { - mocks.path.mockImplementationOnce(() => { - throw new Error(sentinel); - }); - const error: unknown = await bootApp().catch((error: unknown) => error); - expect(error).toBeInstanceOf(DirectoryError); - expectRedacted(error); - expect(mocks.directory).not.toHaveBeenCalled(); - expect(mocks.watch).not.toHaveBeenCalled(); - }); - - it('redacts public directory resolution errors before loading or watching', async () => { - mocks.publicDir.mockImplementationOnce(() => { - throw new Error(sentinel); - }); - const error: unknown = await bootApp({}).catch((error: unknown) => error); - expect(error).toBeInstanceOf(DirectoryError); - expect((error as DirectoryError).issue).toEqual({ code: 'load_failed' }); - expectRedacted(error); - expect(mocks.directory).not.toHaveBeenCalled(); - expect(mocks.watch).not.toHaveBeenCalled(); - }); - - it.each(authCases)( - 'creates $strategy authentication once before watching, retaining it across the post-watch reload', - async ({ strategy, auth }) => { - vi.stubEnv('ORIGIN', origin); - const config = configuration(auth); - const replacement = validateConfig( - { ...config, site: { ...config.site, title: 'Replacement' } }, - { env: {} } - ); - const ready = authentication(strategy); - const env = { - AUTH_SECRET: sentinel.repeat(2), - ORIGIN: 'https://directory.example.com', - AUTH_URL: 'https://directory.example.com' - }; - mocks.load.mockResolvedValueOnce(config).mockResolvedValue(replacement); - mocks.directory.mockImplementationOnce(realDirectory.loadDirectory); - mocks.auth.mockImplementationOnce((input) => { - expect(input).toEqual(config.auth); - expect(input).not.toBe(config.auth); - expect(Object.isFrozen(input)).toBe(true); - expect(Object.isFrozen('credentials' in input ? input.credentials : input.oidc)).toBe(true); - expect(mocks.watch).not.toHaveBeenCalled(); - return ready; - }); - const app = await bootApp(env, frameworkEnv); - expect(mocks.auth).toHaveBeenCalledExactlyOnceWith(expect.any(Object)); - expect(mocks.auth.mock.invocationCallOrder[0]).toBeLessThan( - mocks.watch.mock.invocationCallOrder[0]! - ); - expect(mocks.load).toHaveBeenCalledTimes(2); - expect(app.directory.currentSnapshot().display.site.title).toBe('Replacement'); - expect(app.authentication).toBe(ready); - await app.directory.reload(); - expect(app.authentication).toBe(ready); - expect(mocks.auth).toHaveBeenCalledOnce(); - app.stop(); - expect(mocks.stopWatching).toHaveBeenCalledOnce(); - } - ); - - it.each([ - { name: 'auth enabled after preparation', auth: authCases[0]!.auth, env: {} }, - { name: 'auth disabled after preparation', auth: undefined, env: frameworkEnv }, - { - name: 'origin changed after preparation', - auth: authCases[0]!.auth, - env: { - ORIGIN: `https://${sentinel}.example.com`, - AUTH_URL: `https://${sentinel}.example.com` - } - }, - { - name: 'Kit private ORIGIN mismatch', - auth: authCases[0]!.auth, - env: { ORIGIN: sentinel, AUTH_URL: origin } - }, - { - name: 'Kit private AUTH_URL mismatch', - auth: authCases[0]!.auth, - env: { ORIGIN: origin, AUTH_URL: sentinel } - } - ])('rejects $name before authentication, watching or reloading', async ({ auth, env }) => { - vi.stubEnv('ORIGIN', auth?.origin); - const directory = loadedDirectory(configuration(auth)); - mocks.directory.mockResolvedValueOnce(directory); - const error: unknown = await bootApp({}, env).catch((error: unknown) => error); - expect(error).toBeInstanceOf(DirectoryError); - expect((error as DirectoryError).issue).toEqual({ code: 'auth_failed' }); - expectRedacted(error); - expect(mocks.auth).not.toHaveBeenCalled(); - expect(directory.stop).toHaveBeenCalledOnce(); - expect(directory.reload).not.toHaveBeenCalled(); - expect(mocks.watch).not.toHaveBeenCalled(); - const output = vi.spyOn(logger, 'error').mockReturnValue(undefined); - try { - reportStartupFailure(error); - expect(output).toHaveBeenCalledExactlyOnceWith('Application startup failed (auth_failed).'); - } finally { - output.mockRestore(); - } - }); - - it('accepts empty framework origins when authentication is absent', async () => { - const app = await bootApp({}, { ORIGIN: '', AUTH_URL: '' }); - expect(app.authentication).toBeUndefined(); - expect(mocks.auth).not.toHaveBeenCalled(); - app.stop(); - }); - - it('passes explicit source env to the loader separately from prepared process and Kit private origins', async () => { - const source = { ORIGIN: sentinel, AUTH_URL: sentinel, PASSWORD: sentinel }; + it('constructs auth before monitoring and uses Kit private URLs, not interpolation values', async () => { vi.stubEnv('ORIGIN', origin); - vi.stubEnv('AUTH_URL', 'https://different.example.com'); - const directory = loadedDirectory(configuration(authCases[0]!.auth)); - mocks.directory.mockResolvedValueOnce(directory); - const ready = authentication('credentials'); - mocks.auth.mockReturnValueOnce(ready); - const app = await bootApp(source, frameworkEnv); - expect(mocks.directory).toHaveBeenCalledExactlyOnceWith(`/config/${sentinel}`, { - env: source, - report: reportDirectoryIssue - }); - expect(mocks.path).toHaveBeenCalledExactlyOnceWith({ env: source }); - expect(mocks.auth).toHaveBeenCalledExactlyOnceWith( - directory.currentSnapshot().configuration.auth + vi.stubEnv('AUTH_URL', 'https://not-kit.example.com'); + mocks.load.mockResolvedValueOnce(configuration('Home', true)); + const env = { ORIGIN: sentinel, AUTH_URL: sentinel }; + const app = await bootApp(env, frameworkEnv); + expect(mocks.load).toHaveBeenCalledExactlyOnceWith(path, { env }); + expect(mocks.auth).toHaveBeenCalledExactlyOnceWith(auth); + expect(mocks.auth.mock.invocationCallOrder[0]).toBeLessThan( + mocks.monitor.mock.invocationCallOrder[0]! ); - expect(app.authentication).toBe(ready); - app.stop(); + expect(app.authentication).toBe(authentication); }); - it('rejects a process origin mismatch even when Kit private env matches YAML', async () => { - vi.stubEnv('ORIGIN', `https://${sentinel}.example.com`); - const directory = loadedDirectory(configuration(authCases[0]!.auth)); - mocks.directory.mockResolvedValueOnce(directory); - const error: unknown = await bootApp({}, frameworkEnv).catch((error: unknown) => error); - expect(error).toBeInstanceOf(DirectoryError); - expect((error as DirectoryError).issue).toEqual({ code: 'auth_failed' }); - expectRedacted(error); - expect(directory.stop).toHaveBeenCalledOnce(); - expect(directory.reload).not.toHaveBeenCalled(); - expect(mocks.auth).not.toHaveBeenCalled(); - expect(mocks.watch).not.toHaveBeenCalled(); - }); - - it('checks process.env by default rather than source interpolation origins', async () => { - // Keep the adapter origin valid so only the default SDK environment can reject this boot. - vi.stubEnv('ORIGIN', origin); - const directory = loadedDirectory(configuration(authCases[0]!.auth)); - mocks.directory.mockResolvedValueOnce(directory); - const error: unknown = await bootApp(frameworkEnv).catch((error: unknown) => error); - expect(error).toBeInstanceOf(DirectoryError); - expect((error as DirectoryError).issue).toEqual({ code: 'auth_failed' }); - expectRedacted(error); - expect(directory.stop).toHaveBeenCalledOnce(); - expect(mocks.auth).not.toHaveBeenCalled(); - expect(mocks.watch).not.toHaveBeenCalled(); - }); - - it.each(authCases)( - 'redacts unknown $strategy provider initialization failures before watching or reloading', - async ({ auth }) => { - vi.stubEnv('ORIGIN', origin); - const directory = loadedDirectory(configuration(auth)); - mocks.directory.mockResolvedValueOnce(directory); - mocks.auth.mockImplementationOnce(() => { - throw new Error(sentinel, { cause: sentinel }); + it.each([ + { name: 'auth added', enabled: true, prepared: undefined, privateEnv: {} }, + { name: 'auth removed', enabled: false, prepared: origin, privateEnv: frameworkEnv }, + { + name: 'prepared process origin changed', + enabled: true, + prepared: 'https://other.example.com', + privateEnv: frameworkEnv + }, + { + name: 'Kit private ORIGIN differs', + enabled: true, + prepared: origin, + privateEnv: { ...frameworkEnv, ORIGIN: sentinel } + }, + { + name: 'Kit private AUTH_URL differs', + enabled: true, + prepared: origin, + privateEnv: { ...frameworkEnv, AUTH_URL: sentinel } + } + ])( + 'fails closed when $name before auth or monitoring', + async ({ enabled, prepared, privateEnv }) => { + vi.stubEnv('ORIGIN', prepared); + mocks.load.mockResolvedValueOnce(configuration('Home', enabled)); + await expect(bootApp({}, privateEnv)).rejects.toMatchObject({ + issue: { code: 'auth_failed' } }); - const error: unknown = await bootApp({}, frameworkEnv).catch((error: unknown) => error); - expect(mocks.auth).toHaveBeenCalledExactlyOnceWith( - directory.currentSnapshot.mock.results[0]?.value.configuration.auth - ); - expect(error).toBeInstanceOf(DirectoryError); - expect((error as DirectoryError).issue).toEqual({ code: 'auth_failed' }); - expectRedacted(error); - const output = vi.spyOn(logger, 'error').mockReturnValue(undefined); - try { - reportStartupFailure(error); - expect(output).toHaveBeenCalledExactlyOnceWith('Application startup failed (auth_failed).'); - } finally { - output.mockRestore(); - } - expect(directory.stop).toHaveBeenCalledOnce(); - expect(directory.reload).not.toHaveBeenCalled(); - expect(mocks.watch).not.toHaveBeenCalled(); + expect(mocks.auth).not.toHaveBeenCalled(); + expect(mocks.monitor).not.toHaveBeenCalled(); } ); - it('stops the loaded directory and redacts a watcher setup failure', async () => { - const directory = loadedDirectory(); - mocks.directory.mockResolvedValueOnce(directory); - mocks.watch.mockImplementationOnce(() => { - throw new Error(sentinel); - }); - const error: unknown = await bootApp().catch((error: unknown) => error); - expect(error).toBeInstanceOf(DirectoryError); - expect((error as DirectoryError).issue).toEqual({ code: 'watch_failed' }); - expectRedacted(error); - expect(directory.stop).toHaveBeenCalledOnce(); - expect(directory.reload).not.toHaveBeenCalled(); + it('uses the process environment when no environments are injected', async () => { + await bootApp(); + expect(mocks.path).toHaveBeenCalledExactlyOnceWith({ env: process.env }); + expect(mocks.load).toHaveBeenCalledExactlyOnceWith(path, { env: process.env }); }); - it('stops watching and prevents a pending real directory reload from publishing', async () => { - mocks.directory.mockImplementationOnce(realDirectory.loadDirectory); - const app = await bootApp({}); - expect(app.authentication).toBeUndefined(); - expect(mocks.auth).not.toHaveBeenCalled(); - const original = app.directory.currentSnapshot(); - const pending = Promise.withResolvers(); - const loading = Promise.withResolvers(); - mocks.load.mockImplementationOnce(() => { - loading.resolve(); - return pending.promise; + it('retains safe loader diagnostics without starting monitoring', async () => { + const failure = new ConfigError([ + { code: 'missing_environment', path: `/links/${sentinel}/url` } + ]); + mocks.load.mockRejectedValueOnce(failure); + const error: unknown = await bootApp().catch((error: unknown) => error); + expect(error).toBeInstanceOf(DirectoryError); + expect(error).toMatchObject({ issue: { code: 'load_failed', issues: failure.issues } }); + expect(JSON.stringify(error)).not.toContain(sentinel); + expect(mocks.monitor).not.toHaveBeenCalled(); + }); + + it.each(['path', 'publicDir', 'load'] as const)( + 'redacts unexpected %s failures', + async (stage) => { + mocks[stage].mockImplementationOnce(() => { + throw new Error(sentinel); + }); + const error: unknown = await bootApp().catch((error: unknown) => error); + expect(error).toMatchObject({ issue: { code: 'load_failed' } }); + expect(String(error)).not.toContain(sentinel); + expect(error).not.toHaveProperty('cause'); + expect(mocks.monitor).not.toHaveBeenCalled(); + } + ); + + it('does not start monitoring when authentication initialization fails', async () => { + vi.stubEnv('ORIGIN', origin); + mocks.load.mockResolvedValueOnce(configuration('Home', true)); + mocks.auth.mockImplementationOnce(() => { + throw new Error(sentinel); }); - const reload = mocks.watch.mock.calls[0]![1](); - await loading.promise; - app.stop(); - pending.resolve(validateConfig({ site: { title: 'Replacement' }, links: {} }, { env: {} })); - await reload; - expect(mocks.stopWatching).toHaveBeenCalledOnce(); - expect(app.directory.currentSnapshot()).toBe(original); - expect(original.display.site.title).toBe('Family'); - await app.directory.reload(); - expect(mocks.load).toHaveBeenCalledTimes(3); + await expect(bootApp({}, frameworkEnv)).rejects.toMatchObject({ + issue: { code: 'auth_failed' } + }); + expect(mocks.monitor).not.toHaveBeenCalled(); + }); + + it('redacts monitor setup errors at the application boundary', async () => { + mocks.monitor.mockRejectedValueOnce(new Error(sentinel)); + const error: unknown = await bootApp().catch((error: unknown) => error); + expect(error).toMatchObject({ issue: { code: 'watch_failed' } }); + expect(String(error)).not.toContain(sentinel); + expect(error).not.toHaveProperty('cause'); }); }); diff --git a/apps/web/src/lib/server/boot.ts b/apps/web/src/lib/server/boot.ts index d227d9d..0934929 100644 --- a/apps/web/src/lib/server/boot.ts +++ b/apps/web/src/lib/server/boot.ts @@ -1,11 +1,14 @@ -import { resolveConfigPath, resolvePublicDir } from '@directory/config'; -import type { ConfigEnvironment } from '@directory/config/types'; -import { createAuthentication, type Authentication } from './auth/index.js'; -import { loadDirectory, type Directory } from './config/load-directory.js'; +import { + ConfigError, + loadConfigFile, + resolveConfigPath, + resolvePublicDir, + startConfigReloading +} from '@directory/config'; +import type { ConfigEnvironment, DirectoryConfiguration } from '@directory/config/types'; +import { createApplication, type Application } from './application.js'; import { DirectoryError } from './directory-error.js'; -import type { Application } from './lifecycle/application.js'; -import { watchConfig } from './config/watch.js'; -import { reportDirectoryIssue } from './logger.js'; +import { reportConfigError } from './logger.js'; export async function bootApp( env: ConfigEnvironment = process.env, @@ -14,50 +17,39 @@ export async function bootApp( const options = { env }; let path: string; let publicDir: string; - let directory: Directory; + let config: DirectoryConfiguration; try { path = resolveConfigPath(options); publicDir = resolvePublicDir(path, options); - directory = await loadDirectory(path, { ...options, report: reportDirectoryIssue }); + config = await loadConfigFile(path, options); } catch (error) { - if (error instanceof DirectoryError) throw error; - throw new DirectoryError({ code: 'load_failed' }); + throw new DirectoryError({ + code: 'load_failed', + ...(error instanceof ConfigError ? { issues: error.issues } : {}) + }); } - let authentication: Authentication | undefined; - try { - const auth = directory.currentSnapshot().configuration.auth; - // Reject edits between URL preparation and Kit startup, including enabling/disabling auth. - if ( - auth?.origin !== (process.env.ORIGIN || undefined) || - auth?.origin !== (frameworkEnv.ORIGIN || undefined) || - auth?.origin !== (frameworkEnv.AUTH_URL || undefined) - ) { - throw new DirectoryError({ code: 'auth_failed' }); - } - if (auth !== undefined) authentication = createAuthentication(auth); - } catch { - directory.stop(); + // Reject changes between framework URL preparation and the initial application load. + if ( + config.auth?.origin !== (process.env.ORIGIN || undefined) || + config.auth?.origin !== (frameworkEnv.ORIGIN || undefined) || + config.auth?.origin !== (frameworkEnv.AUTH_URL || undefined) + ) { throw new DirectoryError({ code: 'auth_failed' }); } - let stopWatching: () => void; + const application = createApplication(config); + let stop: () => void; try { - stopWatching = watchConfig(path, () => directory.reload()); + stop = await startConfigReloading({ + path, + initial: config, + load: (path) => loadConfigFile(path, options), + onChange: application.onConfigChange, + onError: reportConfigError + }); } catch { - directory.stop(); throw new DirectoryError({ code: 'watch_failed' }); } - - // Catch a save between the initial read and installation of the path watcher. - await directory.reload(); - return { - directory, - publicDir, - authentication, - stop() { - stopWatching(); - directory.stop(); - } - }; + return { ...application, publicDir, stop }; } diff --git a/apps/web/src/lib/server/config/load-directory.test.ts b/apps/web/src/lib/server/config/load-directory.test.ts deleted file mode 100644 index fa97a4c..0000000 --- a/apps/web/src/lib/server/config/load-directory.test.ts +++ /dev/null @@ -1,718 +0,0 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; -import { inspect } from 'node:util'; -import { ConfigError, validateConfig } from '@directory/config'; -import type { DirectoryConfiguration } from '@directory/config/types'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { DirectoryError, type DirectoryIssue } from '../directory-error.js'; -import { loadDirectory } from './load-directory.js'; -import type { PreparedSnapshot } from './snapshot.js'; - -const mocks = vi.hoisted(() => ({ - load: vi.fn(), - prepare: vi.fn<(config: DirectoryConfiguration) => PreparedSnapshot | Promise>() -})); - -vi.mock('@directory/config', async (importOriginal) => ({ - ...(await importOriginal()), - loadConfigFile: mocks.load -})); -vi.mock('./snapshot.js', async (importOriginal) => ({ - ...(await importOriginal()), - prepareSnapshot: mocks.prepare -})); - -const realSnapshot = await vi.importActual('./snapshot.js'); -const sentinel = 'PRIVATE-password-env-filename.yml'; -const credentials = { - origin: 'https://directory.example.com', - credentials: { username: 'family', password: sentinel } -}; -const oidc = { - origin: 'https://directory.example.com', - oidc: { - issuer: 'https://identity.example.com', - client_id: 'directory', - client_secret: sentinel - } -}; - -function configuration( - title = 'boot', - auth?: DirectoryConfiguration['auth'] -): DirectoryConfiguration { - return validateConfig( - { - site: { title, logo_path: `/public/${title}.svg` }, - ...(auth === undefined ? {} : { auth }), - links: { - photos: { - title: `${title} photos`, - url: 'https://photos.example.com', - logo_path: `/public/${title}-photos.svg`, - tags: [' photos ', ' backup '] - } - } - }, - { env: {} } - ); -} - -function expectRedacted(value: unknown): void { - for (const text of [String(value), JSON.stringify(value), inspect(value)]) { - expect(text).not.toContain(sentinel); - } - if (value instanceof Error) expect(value.stack).not.toContain(sentinel); - expect(value).not.toHaveProperty('cause'); - expect(value).not.toHaveProperty('value'); - expect(value).not.toHaveProperty('path'); -} - -beforeEach(() => { - mocks.load.mockReset().mockResolvedValue(configuration()); - mocks.prepare.mockReset().mockImplementation(realSnapshot.prepareSnapshot); -}); - -describe('directory startup', () => { - it('returns a directory only after loading and preparation have completed', async () => { - const loaded = Promise.withResolvers(); - const prepared = Promise.withResolvers(); - const preparing = Promise.withResolvers(); - mocks.load.mockReturnValueOnce(loaded.promise); - mocks.prepare.mockImplementationOnce(() => { - preparing.resolve(); - return prepared.promise; - }); - let returned = false; - const startup = loadDirectory().then((directory) => { - returned = true; - return directory; - }); - expect(returned).toBe(false); - expect(mocks.prepare).not.toHaveBeenCalled(); - const config = configuration(); - loaded.resolve(config); - await preparing.promise; - expect(returned).toBe(false); - const snapshot = realSnapshot.prepareSnapshot(config); - prepared.resolve(snapshot); - const directory = await startup; - const current: PreparedSnapshot = directory.currentSnapshot(); - expect(current).toBe(snapshot); - expect(current.display.site.title).toBe('boot'); - expect(directory).not.toHaveProperty('start'); - }); - - it('rejects invalid startup load with only safe, frozen loader diagnostics', async () => { - const report = vi.fn(); - const failure = new ConfigError([ - { code: 'missing_environment', path: `/links/${sentinel}/url` } - ]); - mocks.load.mockRejectedValueOnce(failure); - const error: unknown = await loadDirectory(undefined, { report }).catch( - (error: unknown) => error - ); - expect(error).toBeInstanceOf(DirectoryError); - const issue = (error as DirectoryError).issue; - expect(issue).toEqual({ code: 'load_failed', issues: failure.issues }); - expect(Object.isFrozen(issue)).toBe(true); - expect(Object.isFrozen(issue.issues)).toBe(true); - expect(Object.isFrozen(issue.issues?.[0])).toBe(true); - expectRedacted(error); - expect(report).not.toHaveBeenCalled(); - expect(mocks.prepare).not.toHaveBeenCalled(); - }); - - it('redacts an unknown read failure at startup', async () => { - const failure = Object.assign(new Error(sentinel), { cause: sentinel, value: sentinel }); - mocks.load.mockRejectedValueOnce(failure); - const error: unknown = await loadDirectory(sentinel).catch((error: unknown) => error); - expect(error).toBeInstanceOf(DirectoryError); - expect((error as DirectoryError).issue).toEqual({ code: 'load_failed' }); - expectRedacted(error); - }); - - it('redacts a preparation failure at startup', async () => { - mocks.prepare.mockImplementationOnce(() => { - throw Object.assign(new Error(sentinel), { cause: sentinel, value: sentinel }); - }); - const error: unknown = await loadDirectory().catch((error: unknown) => error); - expect(error).toBeInstanceOf(DirectoryError); - expect((error as DirectoryError).issue).toEqual({ code: 'prepare_failed' }); - expectRedacted(error); - }); - - it('redacts path resolution failure before attempting a read', async () => { - const cwd = vi.spyOn(process, 'cwd').mockImplementationOnce(() => { - throw new Error(sentinel); - }); - const startup = loadDirectory(); - cwd.mockRestore(); - const error: unknown = await startup.catch((error: unknown) => error); - expect(error).toBeInstanceOf(DirectoryError); - expect((error as DirectoryError).issue).toEqual({ code: 'load_failed' }); - expectRedacted(error); - expect(mocks.load).not.toHaveBeenCalled(); - }); - - it('resolves explicit and default paths once and forwards the selected environment', async () => { - const report = vi.fn(); - const env = { CONFIG_PATH: 'config/first.yaml', PASSWORD: sentinel }; - const directory = await loadDirectory(undefined, { env, report }); - const pinned = resolve('config/first.yaml'); - expect(mocks.load).toHaveBeenLastCalledWith(pinned, { env }); - env.CONFIG_PATH = 'config/second.yaml'; - await directory.reload(); - expect(mocks.load).toHaveBeenLastCalledWith(pinned, { env }); - expect(mocks.load.mock.calls[1]?.[1]?.env).toBe(env); - const explicit = await loadDirectory('custom/directory.yaml', { env, report }); - await explicit.reload(); - expect(mocks.load).toHaveBeenLastCalledWith(resolve('custom/directory.yaml'), { env }); - expect(report).not.toHaveBeenCalled(); - }); -}); - -describe('filesystem integration', () => { - it('loads disposable YAML with env and defaults, retains last good after an invalid edit, and recovers', async ({ - onTestFinished - }) => { - const temporary = await mkdtemp(join(tmpdir(), 'directory-directory-')); - onTestFinished(() => rm(temporary, { recursive: true, force: true })); - const configPath = join(temporary, sentinel); - const source = ` -site: - title: \${DIRECTORY_TITLE} - logo_path: /public/boot.svg -auth: - origin: https://directory.example.com - credentials: - username: family - password: \${DIRECTORY_PASSWORD} -links: - photos: - title: Photos - url: https://photos.example.com - tags: [photos, backup] -`; - await writeFile(configPath, source); - const realConfig = - await vi.importActual('@directory/config'); - mocks.load.mockImplementation(realConfig.loadConfigFile); - const report = vi.fn<(issue: DirectoryIssue) => void>(); - const env = { - CONFIG_PATH: configPath, - DIRECTORY_TITLE: 'Filesystem boot', - DIRECTORY_PASSWORD: sentinel - }; - const directory = await loadDirectory(undefined, { env, report }); - onTestFinished(() => directory.stop()); - const boot = directory.currentSnapshot(); - expect(boot.configuration.site.title).toBe('Filesystem boot'); - expect(boot.configuration.auth?.credentials?.password).toBe(sentinel); - expect(boot.configuration.auth).not.toHaveProperty('secret'); - expect(boot.display.site).toEqual({ - title: 'Filesystem boot', - description: '', - footer: '', - logo_path: '/public/boot.svg', - favicon_path: '/favicon.svg', - open_links_in_new_tab: true - }); - expect(boot.display.groups[0]?.links[0]?.tags).toEqual(['photos', 'backup']); - expect(boot.assets.brandingPaths).toEqual(['/public/boot.svg']); - expect(JSON.stringify(boot.display)).not.toContain(sentinel); - - await writeFile( - configPath, - '# Same values, different formatting.\n' + - source.replace('tags: [photos, backup]', 'tags:\n - photos\n - backup') - ); - await directory.reload(); - expect(directory.currentSnapshot()).toBe(boot); - expect(mocks.prepare).toHaveBeenCalledOnce(); - expect(report).not.toHaveBeenCalled(); - - await writeFile(configPath, `site: [\n${sentinel}`); - await directory.reload(); - expect(directory.currentSnapshot()).toBe(boot); - expect(report).toHaveBeenCalledExactlyOnceWith({ - code: 'load_failed', - issues: [{ code: 'invalid_yaml', path: '/', message: 'Invalid YAML document.' }] - }); - expectRedacted(report.mock.calls[0]?.[0]); - - env.CONFIG_PATH = join(temporary, 'does-not-exist.yaml'); - env.DIRECTORY_TITLE = 'Filesystem recovered'; - await writeFile(configPath, source.replace('boot.svg', 'recovered.svg')); - await directory.reload(); - const recovered = directory.currentSnapshot(); - expect(recovered).not.toBe(boot); - expect(recovered.configuration.site.title).toBe('Filesystem recovered'); - expect(recovered.configuration.auth).toEqual(boot.configuration.auth); - expect(recovered.configuration.auth).not.toHaveProperty('secret'); - expect(recovered.display.site.title).toBe('Filesystem recovered'); - expect(recovered.assets.referencedPaths).toEqual(['/public/recovered.svg']); - expect(recovered.display.snapshotId).not.toBe(boot.display.snapshotId); - expect(boot.display.site.title).toBe('Filesystem boot'); - expect(boot.assets.referencedPaths).toEqual(['/public/boot.svg']); - expect(report).toHaveBeenCalledTimes(1); - - const explicit = await loadDirectory(configPath, { env, report }); - onTestFinished(() => explicit.stop()); - expect(explicit.currentSnapshot().display.site.title).toBe('Filesystem recovered'); - expect(report).toHaveBeenCalledTimes(1); - }); -}); - -describe('owned snapshot reloads', () => { - it('keeps the snapshot and revision when independently loaded values are unchanged', async () => { - const report = vi.fn(); - const directory = await loadDirectory(undefined, { report }); - const current = directory.currentSnapshot(); - mocks.load.mockResolvedValueOnce(configuration()); - await directory.reload(); - expect(mocks.load).toHaveBeenCalledTimes(2); - expect(mocks.prepare).toHaveBeenCalledOnce(); - expect(directory.currentSnapshot()).toBe(current); - expect(directory.currentSnapshot().display.snapshotId).toBe(current.display.snapshotId); - expect(report).not.toHaveBeenCalled(); - }); - - it('compares unchanged values with the latest accepted snapshot, not just boot', async () => { - const directory = await loadDirectory(); - mocks.load.mockResolvedValueOnce(configuration('next')); - await directory.reload(); - const current = directory.currentSnapshot(); - mocks.load.mockResolvedValueOnce(configuration('next')); - await directory.reload(); - expect(directory.currentSnapshot()).toBe(current); - expect(mocks.prepare).toHaveBeenCalledTimes(2); - }); - - it('ignores site mapping-key order when values are unchanged', async () => { - const directory = await loadDirectory(); - const current = directory.currentSnapshot(); - const reordered = configuration(); - reordered.site = Object.fromEntries( - Object.entries(reordered.site).reverse() - ) as DirectoryConfiguration['site']; - mocks.load.mockResolvedValueOnce(reordered); - await directory.reload(); - expect(directory.currentSnapshot()).toBe(current); - expect(mocks.prepare).toHaveBeenCalledOnce(); - }); - - it('publishes a flat bookmark reorder even when all field values are equal', async () => { - const initial = validateConfig( - { - links: { - zebra: { title: 'Zebra', url: 'https://zebra.example.com' }, - alpha: { title: 'Alpha', url: 'https://alpha.example.com' } - } - }, - { env: {} } - ); - mocks.load.mockResolvedValueOnce(initial); - const directory = await loadDirectory(); - const current = directory.currentSnapshot(); - const reordered = { - ...initial, - links: { alpha: initial.links.alpha!, zebra: initial.links.zebra! } - }; - expect(reordered).toEqual(initial); - mocks.load.mockResolvedValueOnce(reordered); - await directory.reload(); - const next = directory.currentSnapshot(); - expect(next).not.toBe(current); - expect(next.display.snapshotId).not.toBe(current.display.snapshotId); - expect(next.display.groups[0]!.links.map((link) => link.id)).toEqual(['alpha', 'zebra']); - expect(current.display.groups[0]!.links.map((link) => link.id)).toEqual(['zebra', 'alpha']); - expect(mocks.prepare).toHaveBeenCalledTimes(2); - }); - - it('publishes group and within-group order changes derived from bookmark declarations', async () => { - const initial = validateConfig( - { - links: { - zebra: { title: 'Zebra', url: 'https://zebra.example.com', group: 'Tools' }, - alpha: { title: 'Alpha', url: 'https://alpha.example.com', group: 'Family' }, - beta: { title: 'Beta', url: 'https://beta.example.com', group: 'Tools' } - } - }, - { env: {} } - ); - mocks.load.mockResolvedValueOnce(initial); - const directory = await loadDirectory(); - const current = directory.currentSnapshot(); - const reordered = { - ...initial, - links: { - alpha: initial.links.alpha!, - beta: initial.links.beta!, - zebra: initial.links.zebra! - } - }; - expect(reordered).toEqual(initial); - mocks.load.mockResolvedValueOnce(reordered); - await directory.reload(); - const next = directory.currentSnapshot(); - expect(next).not.toBe(current); - expect(next.display.groups.map((group) => group.label)).toEqual(['Family', 'Tools']); - expect(next.display.groups.map((group) => group.links.map((link) => link.id))).toEqual([ - ['alpha'], - ['beta', 'zebra'] - ]); - expect(mocks.prepare).toHaveBeenCalledTimes(2); - }); - - it('retains the last-good revision when a rejected edit is restored to the same values', async () => { - const report = vi.fn(); - const directory = await loadDirectory(undefined, { report }); - const current = directory.currentSnapshot(); - mocks.load.mockRejectedValueOnce(new ConfigError([{ code: 'invalid_yaml', path: '/' }])); - await directory.reload(); - mocks.load.mockResolvedValueOnce(configuration()); - await directory.reload(); - expect(directory.currentSnapshot()).toBe(current); - expect(mocks.prepare).toHaveBeenCalledOnce(); - expect(report).toHaveBeenCalledTimes(1); - }); - - it('serializes load and preparation, coalescing bursts into one pending follow-up', async () => { - const directory = await loadDirectory(); - const boot = directory.currentSnapshot(); - const firstLoad = Promise.withResolvers(); - const firstPrepare = Promise.withResolvers(); - const preparing = Promise.withResolvers(); - const secondLoad = Promise.withResolvers(); - const loadingAgain = Promise.withResolvers(); - mocks.load.mockReturnValueOnce(firstLoad.promise).mockImplementationOnce(() => { - loadingAgain.resolve(); - return secondLoad.promise; - }); - mocks.prepare.mockImplementationOnce((config) => { - preparing.resolve(); - return firstPrepare.promise.then(() => realSnapshot.prepareSnapshot(config)); - }); - const run = directory.reload(); - await Promise.resolve(); - for (let i = 0; i < 10; i++) expect(directory.reload()).toBe(run); - expect(mocks.load).toHaveBeenCalledTimes(2); - expect(mocks.prepare).toHaveBeenCalledTimes(1); - firstLoad.resolve(configuration('one')); - await preparing.promise; - for (let i = 0; i < 10; i++) expect(directory.reload()).toBe(run); - expect(mocks.load).toHaveBeenCalledTimes(2); - expect(directory.currentSnapshot()).toBe(boot); - firstPrepare.resolve(); - await loadingAgain.promise; - expect(directory.currentSnapshot().display.site.title).toBe('one'); - expect(mocks.prepare).toHaveBeenCalledTimes(2); - secondLoad.resolve(configuration('two')); - await run; - expect(mocks.load).toHaveBeenCalledTimes(3); - expect(mocks.prepare).toHaveBeenCalledTimes(3); - expect(directory.currentSnapshot().display.site.title).toBe('two'); - }); - - it('processes a queued unchanged follow-up without preparing or publishing again', async () => { - const directory = await loadDirectory(); - const firstLoad = Promise.withResolvers(); - const secondLoad = Promise.withResolvers(); - const loadingAgain = Promise.withResolvers(); - mocks.load.mockReturnValueOnce(firstLoad.promise).mockImplementationOnce(() => { - loadingAgain.resolve(); - return secondLoad.promise; - }); - const run = directory.reload(); - await Promise.resolve(); - expect(directory.reload()).toBe(run); - firstLoad.resolve(configuration('next')); - await loadingAgain.promise; - const current = directory.currentSnapshot(); - expect(current.display.site.title).toBe('next'); - secondLoad.resolve(configuration('next')); - await run; - expect(directory.currentSnapshot()).toBe(current); - expect(mocks.load).toHaveBeenCalledTimes(3); - expect(mocks.prepare).toHaveBeenCalledTimes(2); - }); - - it('keeps complete old captures while replacing private configuration, display and assets together', async () => { - mocks.load.mockResolvedValueOnce(configuration('boot', credentials)); - const directory = await loadDirectory(); - const captured = directory.currentSnapshot(); - mocks.load.mockResolvedValueOnce(configuration('next', credentials)); - await directory.reload(); - const current = directory.currentSnapshot(); - expect(current).not.toBe(captured); - expect(current.configuration.site.title).toBe('next'); - expect(current.display.site.title).toBe('next'); - expect(current.display.groups[0]?.links[0]?.title).toBe('next photos'); - expect(current.assets).toEqual({ - brandingPaths: ['/public/next.svg'], - referencedPaths: ['/public/next.svg', '/public/next-photos.svg'], - bookmarkLogos: realSnapshot.prepareSnapshot(configuration('next', credentials)).assets - .bookmarkLogos - }); - expect(current.display.snapshotId).not.toBe(captured.display.snapshotId); - expect(captured.configuration.site.title).toBe('boot'); - expect(captured.display.site.title).toBe('boot'); - expect(captured.assets.bookmarkLogos.photos).toBe( - realSnapshot.prepareSnapshot(configuration('boot', credentials)).assets.bookmarkLogos.photos - ); - expect(current.assets.bookmarkLogos.photos).not.toBe(captured.assets.bookmarkLogos.photos); - expect(captured.assets.referencedPaths).toEqual([ - '/public/boot.svg', - '/public/boot-photos.svg' - ]); - expect(JSON.stringify(current.display)).not.toContain(sentinel); - expect(JSON.stringify(current.configuration)).toContain(sentinel); - expect(Object.isFrozen(current)).toBe(true); - }); - - it('retains last good on load and preparation failures, then recovers', async () => { - const report = vi.fn<(issue: DirectoryIssue) => void>(); - const directory = await loadDirectory(undefined, { report }); - const captured = directory.currentSnapshot(); - const invalid = new ConfigError([{ code: 'invalid_yaml', path: '/' }]); - mocks.load.mockRejectedValueOnce(invalid); - await directory.reload(); - expect(directory.currentSnapshot()).toBe(captured); - expect(report).toHaveBeenLastCalledWith({ code: 'load_failed', issues: invalid.issues }); - mocks.load.mockResolvedValueOnce(configuration('preparation-fails')); - mocks.prepare.mockImplementationOnce(() => { - throw Object.assign(new Error(sentinel), { filename: sentinel, value: sentinel }); - }); - await directory.reload(); - expect(directory.currentSnapshot()).toBe(captured); - expect(report).toHaveBeenLastCalledWith({ code: 'prepare_failed' }); - for (const [issue] of report.mock.calls) { - expect(Object.isFrozen(issue)).toBe(true); - expectRedacted(issue); - } - mocks.load.mockResolvedValueOnce(configuration('recovered')); - await directory.reload(); - expect(directory.currentSnapshot().display.site.title).toBe('recovered'); - expect(directory.currentSnapshot()).not.toBe(captured); - }); - - it('runs a queued follow-up after failure even when synchronous reporting throws', async () => { - const report = vi.fn(() => { - throw new Error(sentinel); - }); - const directory = await loadDirectory(undefined, { report }); - const pending = Promise.withResolvers(); - mocks.load.mockReturnValueOnce(pending.promise).mockResolvedValue(configuration('recovered')); - const run = directory.reload(); - await Promise.resolve(); - expect(directory.reload()).toBe(run); - pending.reject(new Error(sentinel)); - await expect(run).resolves.toBeUndefined(); - expect(report).toHaveBeenCalledExactlyOnceWith({ code: 'load_failed' }); - expect(directory.currentSnapshot().display.site.title).toBe('recovered'); - await directory.reload(); - expect(mocks.load).toHaveBeenCalledTimes(4); - }); -}); - -describe('boot authentication', () => { - it.each([ - { - change: 'origin change', - bootAuth: credentials, - nextAuth: { ...credentials, origin: 'https://other.example.com' } - }, - { - change: 'secret rotation', - bootAuth: { ...oidc, secret: `${sentinel}-session` }, - nextAuth: { ...oidc, secret: `${sentinel}-replacement` } - } - ])( - 'requires restart for $change and retains the entire last-good snapshot', - async ({ bootAuth, nextAuth }) => { - const report = vi.fn(); - mocks.load.mockResolvedValueOnce(configuration('boot', bootAuth)); - const directory = await loadDirectory(undefined, { report }); - mocks.load.mockResolvedValueOnce(configuration('accepted', bootAuth)); - await directory.reload(); - const accepted = directory.currentSnapshot(); - const captured = structuredClone(accepted); - - mocks.load.mockResolvedValueOnce(configuration('rejected', nextAuth)); - await directory.reload(); - expect(report).toHaveBeenCalledExactlyOnceWith({ code: 'restart_required' }); - expectRedacted(report.mock.calls); - expect(directory.currentSnapshot()).toBe(accepted); - expect(directory.currentSnapshot()).toEqual(captured); - - mocks.load.mockResolvedValueOnce(configuration('restored', bootAuth)); - await directory.reload(); - const restored = directory.currentSnapshot(); - expect(restored).not.toBe(accepted); - expect(restored.configuration.auth).toEqual(bootAuth); - expect(restored.display.site.title).toBe('restored'); - expect(restored.display.groups[0]?.links[0]?.title).toBe('restored photos'); - expect(restored.assets.referencedPaths).toEqual([ - '/public/restored.svg', - '/public/restored-photos.svg' - ]); - expect(report).toHaveBeenCalledTimes(1); - } - ); - - it('rejects enabling auth on an unauthenticated boot, then accepts restored absent auth', async () => { - const report = vi.fn(); - const directory = await loadDirectory(undefined, { report }); - const boot = directory.currentSnapshot(); - mocks.load.mockResolvedValueOnce(configuration('rejected', credentials)); - await directory.reload(); - expect(directory.currentSnapshot()).toBe(boot); - expect(mocks.prepare).toHaveBeenCalledTimes(1); - expect(report).toHaveBeenCalledExactlyOnceWith({ code: 'restart_required' }); - mocks.load.mockResolvedValueOnce(configuration('restored')); - await directory.reload(); - expect(directory.currentSnapshot().display.site.title).toBe('restored'); - }); - - it('rejects changed or disabled boot auth as a whole and accepts structural restoration in a different key order', async () => { - const report = vi.fn(); - mocks.load.mockResolvedValueOnce(configuration('boot', oidc)); - const directory = await loadDirectory(undefined, { report }); - const boot = directory.currentSnapshot(); - mocks.load.mockResolvedValueOnce( - configuration('changed', { ...oidc, oidc: { ...oidc.oidc, client_secret: 'changed' } }) - ); - await directory.reload(); - expect(directory.currentSnapshot()).toBe(boot); - mocks.load.mockResolvedValueOnce(configuration('disabled')); - await directory.reload(); - expect(directory.currentSnapshot()).toBe(boot); - expect(mocks.prepare).toHaveBeenCalledTimes(1); - expect(report.mock.calls).toEqual([ - [{ code: 'restart_required' }], - [{ code: 'restart_required' }] - ]); - expectRedacted(report.mock.calls); - const restored = configuration('restored', oidc); - restored.auth = { - oidc: { - client_secret: sentinel, - client_id: 'directory', - issuer: 'https://identity.example.com' - }, - origin: oidc.origin - }; - mocks.load.mockResolvedValueOnce(restored); - await directory.reload(); - expect(directory.currentSnapshot().display.site.title).toBe('restored'); - expect(directory.currentSnapshot().assets.brandingPaths).toEqual(['/public/restored.svg']); - }); - - it('owns frozen boot auth and prepared data independently of mutable loader results', async () => { - const input = configuration('boot', credentials); - mocks.load.mockResolvedValue(input); - const report = vi.fn(); - const directory = await loadDirectory(undefined, { report }); - const boot = directory.currentSnapshot(); - input.site.title = 'mutated'; - input.site.logo_path = '/public/mutated.svg'; - input.links.photos!.tags.push('mutated'); - input.auth!.credentials!.password = 'mutated'; - expect(Object.isFrozen(input)).toBe(false); - expect(Object.isFrozen(boot.configuration.auth?.credentials)).toBe(true); - expect(Object.isFrozen(boot.configuration.links.photos!.tags)).toBe(true); - expect(boot.configuration.auth?.credentials?.password).toBe(sentinel); - expect(boot.configuration.links.photos!.tags).toEqual([' photos ', ' backup ']); - expect(boot.display.groups[0]?.links[0]?.tags).toEqual(['photos', 'backup']); - await directory.reload(); - expect(directory.currentSnapshot()).toBe(boot); - expect(boot.display.site.title).toBe('boot'); - expect(boot.assets.brandingPaths).toEqual(['/public/boot.svg']); - expect(report).toHaveBeenCalledExactlyOnceWith({ code: 'restart_required' }); - mocks.load.mockResolvedValueOnce(configuration('restored', credentials)); - await directory.reload(); - expect(directory.currentSnapshot().display.site.title).toBe('restored'); - }); -}); - -describe('terminal stop', () => { - it('discards a completed load, suppressing preparation and queued work after stop', async () => { - const report = vi.fn(); - const directory = await loadDirectory(undefined, { report }); - const boot = directory.currentSnapshot(); - const pending = Promise.withResolvers(); - mocks.load.mockReturnValueOnce(pending.promise); - const run = directory.reload(); - await Promise.resolve(); - expect(directory.reload()).toBe(run); - directory.stop(); - directory.stop(); - pending.resolve(configuration('discarded', credentials)); - await run; - await directory.reload(); - expect(directory.currentSnapshot()).toBe(boot); - expect(mocks.load).toHaveBeenCalledTimes(2); - expect(mocks.prepare).toHaveBeenCalledTimes(1); - expect(report).not.toHaveBeenCalled(); - }); - - it('discards completed preparation and queued work after stop', async () => { - const report = vi.fn(); - const directory = await loadDirectory(undefined, { report }); - const boot = directory.currentSnapshot(); - const pending = Promise.withResolvers(); - const entered = Promise.withResolvers(); - mocks.load.mockResolvedValueOnce(configuration('pending')); - mocks.prepare.mockImplementationOnce(() => { - entered.resolve(); - return pending.promise; - }); - const run = directory.reload(); - await entered.promise; - expect(directory.reload()).toBe(run); - directory.stop(); - pending.resolve(realSnapshot.prepareSnapshot(configuration('discarded'))); - await run; - await directory.reload(); - expect(directory.currentSnapshot()).toBe(boot); - expect(mocks.load).toHaveBeenCalledTimes(2); - expect(report).not.toHaveBeenCalled(); - }); - - it.each(['load', 'prepare'] as const)( - 'suppresses late %s rejection reports after stop', - async (stage) => { - const report = vi.fn(); - const directory = await loadDirectory(undefined, { report }); - const boot = directory.currentSnapshot(); - const pending = Promise.withResolvers(); - const entered = Promise.withResolvers(); - if (stage === 'prepare') mocks.load.mockResolvedValueOnce(configuration('pending')); - mocks[stage].mockImplementationOnce(() => { - entered.resolve(); - return pending.promise; - }); - const run = directory.reload(); - await entered.promise; - expect(directory.reload()).toBe(run); - directory.stop(); - pending.reject(new Error(sentinel)); - await expect(run).resolves.toBeUndefined(); - await directory.reload(); - expect(directory.currentSnapshot()).toBe(boot); - expect(mocks.load).toHaveBeenCalledTimes(2); - expect(report).not.toHaveBeenCalled(); - } - ); - - it('does not enter a scheduled reload stopped before work begins', async () => { - const directory = await loadDirectory(); - const run = directory.reload(); - directory.stop(); - await run; - await directory.reload(); - expect(mocks.load).toHaveBeenCalledTimes(1); - }); -}); diff --git a/apps/web/src/lib/server/config/load-directory.ts b/apps/web/src/lib/server/config/load-directory.ts deleted file mode 100644 index cbe1cd8..0000000 --- a/apps/web/src/lib/server/config/load-directory.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { resolve } from 'node:path'; -import { isDeepStrictEqual } from 'node:util'; -import { ConfigError, loadConfigFile, resolveConfigPath } from '@directory/config'; -import type { ConfigLoadOptions } from '@directory/config/types'; -import { DirectoryError, type DirectoryIssue } from '../directory-error.js'; -import { prepareSnapshot, type PreparedSnapshot } from './snapshot.js'; - -export interface LoadDirectoryOptions extends ConfigLoadOptions { - /** Called synchronously with safe diagnostics for rejected reloads. */ - report?: (issue: DirectoryIssue) => void; -} - -export interface Directory { - currentSnapshot(): PreparedSnapshot; - reload(): Promise; - /** Stops future work and publication without cancelling in-flight I/O. */ - stop(): void; -} - -interface DirectoryState { - currentSnapshot: PreparedSnapshot; - readonly bootAuth: PreparedSnapshot['configuration']['auth']; - readonly path: string; - readonly loadOptions: ConfigLoadOptions; - readonly report: LoadDirectoryOptions['report']; - hasPendingReload: boolean; - activeRunPromise: Promise | undefined; - stopped: boolean; -} - -export async function loadDirectory( - path?: string, - options: LoadDirectoryOptions = {} -): Promise { - const loadOptions: ConfigLoadOptions = options.env === undefined ? {} : { env: options.env }; - let configPath: string; - let initial: PreparedSnapshot; - let code: DirectoryIssue['code'] = 'load_failed'; - try { - configPath = path === undefined ? resolveConfigPath(loadOptions) : resolve(path); - const config = await loadConfigFile(configPath, loadOptions); - code = 'prepare_failed'; - initial = await prepareSnapshot(config); - } catch (error) { - throw new DirectoryError(safeIssue(code, error)); - } - - const state: DirectoryState = { - currentSnapshot: initial, - // The initial prepared configuration is already an owned, deeply frozen copy. - bootAuth: initial.configuration.auth, - path: configPath, - loadOptions, - report: options.report, - hasPendingReload: false, - activeRunPromise: undefined, - stopped: false - }; - return { - currentSnapshot: () => state.currentSnapshot, - reload() { - if (state.stopped) return Promise.resolve(); - state.hasPendingReload = true; - // Defer the run until its promise has been recorded. - state.activeRunPromise ??= Promise.resolve().then(() => runPendingReloads(state)); - return state.activeRunPromise; - }, - stop() { - state.stopped = true; - state.hasPendingReload = false; - } - }; -} - -async function runPendingReloads(state: DirectoryState): Promise { - try { - while (!state.stopped && state.hasPendingReload) { - state.hasPendingReload = false; - await reloadSnapshot(state); - } - } finally { - state.activeRunPromise = undefined; - } -} - -async function reloadSnapshot(state: DirectoryState): Promise { - let code: DirectoryIssue['code'] = 'load_failed'; - try { - const config = await loadConfigFile(state.path, state.loadOptions); - if (state.stopped) return; - if (!isDeepStrictEqual(config.auth, state.bootAuth)) { - reportIssue(state, Object.freeze({ code: 'restart_required' })); - return; - } - if (isSameConfiguration(config, state.currentSnapshot.configuration)) return; - code = 'prepare_failed'; - const snapshot = await prepareSnapshot(config); - if (state.stopped) return; - state.currentSnapshot = snapshot; - } catch (error) { - if (!state.stopped) reportIssue(state, safeIssue(code, error)); - } -} - -function isSameConfiguration( - next: PreparedSnapshot['configuration'], - current: PreparedSnapshot['configuration'] -): boolean { - const sameValues = isDeepStrictEqual(next, current); - const sameLinkOrder = isDeepStrictEqual(Object.keys(next.links), Object.keys(current.links)); - return sameValues && sameLinkOrder; -} - -function safeIssue(code: DirectoryIssue['code'], error: unknown): DirectoryIssue { - if (code === 'load_failed' && error instanceof ConfigError) { - return Object.freeze({ code, issues: error.issues }); - } - return Object.freeze({ code }); -} - -function reportIssue(state: DirectoryState, issue: DirectoryIssue): void { - if (state.stopped) return; - try { - state.report?.(issue); - } catch { - // A synchronous diagnostic failure must not poison subsequent reloads. - } -} diff --git a/apps/web/src/lib/server/config/watch.test.ts b/apps/web/src/lib/server/config/watch.test.ts deleted file mode 100644 index beae934..0000000 --- a/apps/web/src/lib/server/config/watch.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { Stats } from 'node:fs'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { logger } from '../logger.js'; -import { watchConfig } from './watch.js'; - -type Listener = (current: Stats, previous: Stats) => Promise; -const mocks = vi.hoisted(() => ({ - watchFile: vi.fn<(path: string, options: object, listener: Listener) => void>(), - unwatchFile: vi.fn() -})); -vi.mock('node:fs', () => mocks); - -function metadata(values: Partial = {}): Stats { - return { ino: 10, mtimeMs: 100, size: 50, ...values } as Stats; -} - -function listener(index = 0): Listener { - return mocks.watchFile.mock.calls[index]![2]; -} - -beforeEach(() => { - vi.clearAllMocks(); -}); -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe('watchConfig', () => { - it('registers one non-persistent one-second path poll without an initial reload', () => { - const onChange = vi.fn<() => Promise>(); - watchConfig('/config/directory.yaml', onChange); - expect(mocks.watchFile).toHaveBeenCalledExactlyOnceWith( - '/config/directory.yaml', - { interval: 1000, persistent: false }, - expect.any(Function) - ); - expect(onChange).not.toHaveBeenCalled(); - }); - - it('ignores equal metadata and differences in unrelated stat fields', async () => { - const onChange = vi.fn<() => Promise>(); - watchConfig('/config/directory.yaml', onChange); - await listener()(metadata(), metadata()); - await listener()(metadata({ atimeMs: 200, ctimeMs: 300 }), metadata()); - expect(onChange).not.toHaveBeenCalled(); - }); - - it.each([ - ['inode (atomic replacement)', { ino: 11 }], - ['modification time', { mtimeMs: 101 }], - ['size', { size: 51 }] - ])('reloads when only %s changes', async (_name, changed) => { - const onChange = vi.fn<() => Promise>().mockResolvedValue(undefined); - watchConfig('/config/directory.yaml', onChange); - await listener()(metadata(changed), metadata()); - expect(onChange).toHaveBeenCalledExactlyOnceWith(); - }); - - it('reloads on deletion and recreation, even with the original metadata', async () => { - const onChange = vi.fn<() => Promise>().mockResolvedValue(undefined); - watchConfig('/config/directory.yaml', onChange); - const missing = metadata({ ino: 0, mtimeMs: 0, size: 0 }); - await listener()(missing, metadata()); - await listener()(metadata(), missing); - expect(onChange).toHaveBeenCalledTimes(2); - }); - - it('stops once, removes only its exact listener, and ignores stale events', async () => { - const onChange = vi.fn<() => Promise>(); - const stop = watchConfig('/config/directory.yaml', onChange); - const registered = listener(); - stop(); - stop(); - expect(mocks.unwatchFile).toHaveBeenCalledExactlyOnceWith('/config/directory.yaml', registered); - await registered(metadata({ ino: 11 }), metadata()); - expect(onChange).not.toHaveBeenCalled(); - }); - - it('keeps registrations independent, including for the same path', async () => { - const first = vi.fn<() => Promise>(); - const second = vi.fn<() => Promise>().mockResolvedValue(undefined); - const stopFirst = watchConfig('/config/directory.yaml', first); - const stopSecond = watchConfig('/config/directory.yaml', second); - expect(listener(0)).not.toBe(listener(1)); - stopFirst(); - await listener(0)(metadata({ ino: 11 }), metadata()); - await listener(1)(metadata({ ino: 11 }), metadata()); - expect(first).not.toHaveBeenCalled(); - expect(second).toHaveBeenCalledOnce(); - stopSecond(); - expect(mocks.unwatchFile.mock.calls).toEqual([ - ['/config/directory.yaml', listener(0)], - ['/config/directory.yaml', listener(1)] - ]); - }); - - it.each(['reject', 'throw'])( - 'contains an unexpected callback %s with a fixed diagnostic', - async (mode) => { - const error = vi.spyOn(logger, 'error').mockReturnValue(undefined); - const sentinel = 'PRIVATE-password-file.yaml'; - const onChange = vi.fn<() => Promise>().mockResolvedValue(undefined); - if (mode === 'reject') onChange.mockRejectedValueOnce(new Error(sentinel)); - else - onChange.mockImplementationOnce(() => { - throw new Error(sentinel); - }); - watchConfig('/config/directory.yaml', onChange); - await expect(listener()(metadata({ ino: 11 }), metadata())).resolves.toBeUndefined(); - expect(error).toHaveBeenCalledExactlyOnceWith('Configuration reload callback failed.'); - await listener()(metadata({ ino: 12 }), metadata({ ino: 11 })); - expect(onChange).toHaveBeenCalledTimes(2); - expect(error).toHaveBeenCalledOnce(); - } - ); - - it.each(['resolve', 'reject'])( - 'suppresses stale in-flight %s completion after stop', - async (mode) => { - const error = vi.spyOn(logger, 'error').mockReturnValue(undefined); - const pending = Promise.withResolvers(); - const onChange = vi.fn<() => Promise>().mockReturnValue(pending.promise); - const stop = watchConfig('/config/directory.yaml', onChange); - const active = listener()(metadata({ ino: 11 }), metadata()); - expect(onChange).toHaveBeenCalledOnce(); - stop(); - if (mode === 'resolve') pending.resolve(); - else pending.reject(new Error('PRIVATE-password-file.yaml')); - await expect(active).resolves.toBeUndefined(); - await listener()(metadata({ ino: 12 }), metadata({ ino: 11 })); - expect(onChange).toHaveBeenCalledOnce(); - expect(error).not.toHaveBeenCalled(); - } - ); -}); diff --git a/apps/web/src/lib/server/config/watch.ts b/apps/web/src/lib/server/config/watch.ts deleted file mode 100644 index a84947b..0000000 --- a/apps/web/src/lib/server/config/watch.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { watchFile, unwatchFile, type Stats } from 'node:fs'; -import { logger } from '../logger.js'; - -export function watchConfig(path: string, onChange: () => Promise): () => void { - let stopped = false; - async function listener(current: Stats, previous: Stats): Promise { - if ( - stopped || - (current.ino === previous.ino && - current.mtimeMs === previous.mtimeMs && - current.size === previous.size) - ) { - return; - } - try { - await onChange(); - } catch { - if (!stopped) logger.error('Configuration reload callback failed.'); - } - } - - // Poll the path so atomic saves and deletion/recreation do not detach the watcher. - watchFile(path, { interval: 1000, persistent: false }, listener); - return () => { - if (stopped) return; - stopped = true; - unwatchFile(path, listener); - }; -} diff --git a/apps/web/src/lib/server/config/bookmark-groups.test.ts b/apps/web/src/lib/server/directory/bookmark-groups.test.ts similarity index 100% rename from apps/web/src/lib/server/config/bookmark-groups.test.ts rename to apps/web/src/lib/server/directory/bookmark-groups.test.ts diff --git a/apps/web/src/lib/server/config/bookmark-groups.ts b/apps/web/src/lib/server/directory/bookmark-groups.ts similarity index 100% rename from apps/web/src/lib/server/config/bookmark-groups.ts rename to apps/web/src/lib/server/directory/bookmark-groups.ts diff --git a/apps/web/src/lib/server/config/directory-content.test.ts b/apps/web/src/lib/server/directory/content.test.ts similarity index 99% rename from apps/web/src/lib/server/config/directory-content.test.ts rename to apps/web/src/lib/server/directory/content.test.ts index 12c63fc..e304b23 100644 --- a/apps/web/src/lib/server/config/directory-content.test.ts +++ b/apps/web/src/lib/server/directory/content.test.ts @@ -2,7 +2,7 @@ import { validateConfig } from '@directory/config'; import type { DirectoryConfiguration } from '@directory/config/types'; import { describe, expect, expectTypeOf, it } from 'vitest'; import type { DisplaySite } from '../../display/types.js'; -import { getSite, prepareDirectoryContent } from './directory-content.js'; +import { getSite, prepareDirectoryContent } from './content.js'; const destination = { title: 'Photos', url: 'https://photos.example.com' }; diff --git a/apps/web/src/lib/server/config/directory-content.ts b/apps/web/src/lib/server/directory/content.ts similarity index 100% rename from apps/web/src/lib/server/config/directory-content.ts rename to apps/web/src/lib/server/directory/content.ts diff --git a/apps/web/src/lib/server/config/immutable.test.ts b/apps/web/src/lib/server/directory/immutable.test.ts similarity index 100% rename from apps/web/src/lib/server/config/immutable.test.ts rename to apps/web/src/lib/server/directory/immutable.test.ts diff --git a/apps/web/src/lib/server/config/immutable.ts b/apps/web/src/lib/server/directory/immutable.ts similarity index 100% rename from apps/web/src/lib/server/config/immutable.ts rename to apps/web/src/lib/server/directory/immutable.ts diff --git a/apps/web/src/lib/server/config/snapshot.test.ts b/apps/web/src/lib/server/directory/snapshot.test.ts similarity index 100% rename from apps/web/src/lib/server/config/snapshot.test.ts rename to apps/web/src/lib/server/directory/snapshot.test.ts diff --git a/apps/web/src/lib/server/config/snapshot.ts b/apps/web/src/lib/server/directory/snapshot.ts similarity index 95% rename from apps/web/src/lib/server/config/snapshot.ts rename to apps/web/src/lib/server/directory/snapshot.ts index b3fe600..94c858d 100644 --- a/apps/web/src/lib/server/config/snapshot.ts +++ b/apps/web/src/lib/server/directory/snapshot.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'; import type { DirectoryConfiguration } from '@directory/config/types'; import type { DirectoryContent } from '../../display/types.js'; import { freezeData, type Immutable } from './immutable.js'; -import { prepareDirectoryContent } from './directory-content.js'; +import { prepareDirectoryContent } from './content.js'; import { renderBookmarkLogos } from '../images/render-bookmark-logos.js'; export interface PreparedSnapshot { diff --git a/apps/web/src/lib/server/lifecycle/application.ts b/apps/web/src/lib/server/lifecycle/application.ts deleted file mode 100644 index 538ca02..0000000 --- a/apps/web/src/lib/server/lifecycle/application.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Authentication } from '../auth/index.js'; -import type { Directory } from '../config/load-directory.js'; - -export interface Application { - readonly directory: Directory; - readonly publicDir: string; - readonly authentication: Authentication | undefined; - stop(): void; -} - -export interface ServerState { - application: Application | undefined; -} diff --git a/apps/web/src/lib/server/lifecycle/development.test.ts b/apps/web/src/lib/server/lifecycle/development.test.ts index 5a7631d..cfe0c25 100644 --- a/apps/web/src/lib/server/lifecycle/development.test.ts +++ b/apps/web/src/lib/server/lifecycle/development.test.ts @@ -1,8 +1,7 @@ import { validateConfig } from '@directory/config'; import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'; -import type { Directory } from '../config/load-directory.js'; -import { prepareSnapshot } from '../config/snapshot.js'; -import type { Application, ServerState } from './application.js'; +import { prepareSnapshot } from '../directory/snapshot.js'; +import type { Application, ServerState } from '../application.js'; const mocks = vi.hoisted(() => ({ boot: vi.fn() @@ -11,15 +10,12 @@ vi.mock('../boot.js', () => ({ bootApp: mocks.boot })); function application(title: string) { return { - directory: { - currentSnapshot: vi - .fn() - .mockReturnValue( - prepareSnapshot(validateConfig({ site: { title }, links: {} }, { env: {} })) - ), - reload: vi.fn(), - stop: vi.fn() - }, + currentSnapshot: vi + .fn() + .mockReturnValue( + prepareSnapshot(validateConfig({ site: { title }, links: {} }, { env: {} })) + ), + onConfigChange: vi.fn().mockReturnValue(true), publicDir: '/mounted/public', authentication: undefined, stop: vi.fn() @@ -134,7 +130,7 @@ describe('development lifecycle', () => { await startup; shutdown(); expect(stopped.stop).toHaveBeenCalledOnce(); - expect(stopped.directory.currentSnapshot).not.toHaveBeenCalled(); + expect(stopped.currentSnapshot).not.toHaveBeenCalled(); expect(server.application).toBeUndefined(); }); @@ -154,7 +150,7 @@ describe('development lifecycle', () => { expect(first.stop).toHaveBeenCalledOnce(); expect(second.stop).not.toHaveBeenCalled(); expect(server.application).toBe(second); - expect(server.application?.directory.currentSnapshot().display.site.title).toBe('Current'); + expect(server.application?.currentSnapshot().display.site.title).toBe('Current'); process.emit('directory:dev-stop'); expect(second.stop).toHaveBeenCalledOnce(); }); diff --git a/apps/web/src/lib/server/lifecycle/development.ts b/apps/web/src/lib/server/lifecycle/development.ts index 51b520f..177bd3c 100644 --- a/apps/web/src/lib/server/lifecycle/development.ts +++ b/apps/web/src/lib/server/lifecycle/development.ts @@ -1,6 +1,6 @@ import type { ConfigEnvironment } from '@directory/config/types'; import { bootApp } from '../boot.js'; -import type { Application, ServerState } from './application.js'; +import type { Application, ServerState } from '../application.js'; interface DevelopmentAttempt { readonly server: ServerState; diff --git a/apps/web/src/lib/server/lifecycle/production.test.ts b/apps/web/src/lib/server/lifecycle/production.test.ts index b630df9..ae733ad 100644 --- a/apps/web/src/lib/server/lifecycle/production.test.ts +++ b/apps/web/src/lib/server/lifecycle/production.test.ts @@ -1,8 +1,7 @@ import { validateConfig } from '@directory/config'; import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'; -import type { Directory } from '../config/load-directory.js'; -import { prepareSnapshot } from '../config/snapshot.js'; -import type { Application, ServerState } from './application.js'; +import { prepareSnapshot } from '../directory/snapshot.js'; +import type { Application, ServerState } from '../application.js'; import { startProduction } from './production.js'; const adapterEvents: NodeJS.EventEmitter = process; @@ -14,15 +13,12 @@ vi.mock('../boot.js', () => ({ bootApp: mocks.boot })); function application(title: string) { return { - directory: { - currentSnapshot: vi - .fn() - .mockReturnValue( - prepareSnapshot(validateConfig({ site: { title }, links: {} }, { env: {} })) - ), - reload: vi.fn(), - stop: vi.fn() - }, + currentSnapshot: vi + .fn() + .mockReturnValue( + prepareSnapshot(validateConfig({ site: { title }, links: {} }, { env: {} })) + ), + onConfigChange: vi.fn().mockReturnValue(true), publicDir: '/mounted/public', authentication: undefined, stop: vi.fn() @@ -72,7 +68,7 @@ describe('production lifecycle', () => { await startup; expect(server.application).toBe(ready); expect(ready.stop).not.toHaveBeenCalled(); - expect(ready.directory.currentSnapshot).not.toHaveBeenCalled(); + expect(ready.currentSnapshot).not.toHaveBeenCalled(); adapterEvents.emit('sveltekit:shutdown'); expect(ready.stop).toHaveBeenCalledOnce(); }); diff --git a/apps/web/src/lib/server/lifecycle/production.ts b/apps/web/src/lib/server/lifecycle/production.ts index f1dc9d8..5cc9f21 100644 --- a/apps/web/src/lib/server/lifecycle/production.ts +++ b/apps/web/src/lib/server/lifecycle/production.ts @@ -1,6 +1,6 @@ import type { ConfigEnvironment } from '@directory/config/types'; import { bootApp } from '../boot.js'; -import type { Application, ServerState } from './application.js'; +import type { Application, ServerState } from '../application.js'; export async function startProduction(server: ServerState, env?: ConfigEnvironment): Promise { const application = await bootApp(undefined, env); diff --git a/apps/web/src/lib/server/logger.test.ts b/apps/web/src/lib/server/logger.test.ts index a5e1a0a..aff50a8 100644 --- a/apps/web/src/lib/server/logger.test.ts +++ b/apps/web/src/lib/server/logger.test.ts @@ -1,12 +1,42 @@ import { ConfigError } from '@directory/config'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { DirectoryError } from './directory-error.js'; -import { logger, reportDirectoryIssue, reportStartupFailure } from './logger.js'; +import { logger, reportConfigError, reportDirectoryIssue, reportStartupFailure } from './logger.js'; const sentinel = 'PRIVATE-secret-environment-name-filename.yaml'; afterEach(() => vi.restoreAllMocks()); describe('safe server reporting', () => { + it('reports application reload failures without relabeling them as load failures', () => { + const output = vi.spyOn(logger, 'error').mockReturnValue(undefined); + reportConfigError(new DirectoryError({ code: 'prepare_failed' })); + expect(output).toHaveBeenCalledExactlyOnceWith( + 'Configuration update rejected (prepare_failed).' + ); + }); + + it('reports config load failures without private link identifiers', () => { + const output = vi.spyOn(logger, 'error').mockReturnValue(undefined); + reportConfigError( + new ConfigError([{ code: 'missing_environment', path: `/links/${sentinel}/url` }]) + ); + expect(output.mock.calls).toEqual([ + ['Configuration update rejected (load_failed).'], + [' /links/*/url (missing_environment)'] + ]); + }); + + it.each([new Error(sentinel), sentinel, { cause: sentinel, value: sentinel }])( + 'never inspects or prints unknown reload errors', + (error) => { + const output = vi.spyOn(logger, 'error').mockReturnValue(undefined); + reportConfigError(error); + expect(output).toHaveBeenCalledExactlyOnceWith( + 'Configuration update rejected (load_failed).' + ); + } + ); + it('reports only diagnostic codes and redacted issue paths, never whole errors or messages', () => { const output = vi.spyOn(logger, 'error').mockReturnValue(undefined); const issue = { diff --git a/apps/web/src/lib/server/logger.ts b/apps/web/src/lib/server/logger.ts index da49232..47cd9f6 100644 --- a/apps/web/src/lib/server/logger.ts +++ b/apps/web/src/lib/server/logger.ts @@ -1,3 +1,4 @@ +import { ConfigError } from '@directory/config'; import { Logger } from 'tslog'; import { DirectoryError, type DirectoryIssue } from './directory-error.js'; @@ -15,6 +16,17 @@ export function reportDirectoryIssue(issue: DirectoryIssue): void { reportIssueDetails(issue); } +export function reportConfigError(error: unknown): void { + if (error instanceof DirectoryError) { + reportDirectoryIssue(error.issue); + } else { + reportDirectoryIssue({ + code: 'load_failed', + ...(error instanceof ConfigError ? { issues: error.issues } : {}) + }); + } +} + export function reportStartupFailure(error: unknown): void { if (error instanceof DirectoryError) { logger.error(`Application startup failed (${error.issue.code}).`); diff --git a/apps/web/src/routes/(protected)/(endpoints)/config/version/server.test.ts b/apps/web/src/routes/(protected)/(endpoints)/config/version/server.test.ts index 22a747d..ac0b439 100644 --- a/apps/web/src/routes/(protected)/(endpoints)/config/version/server.test.ts +++ b/apps/web/src/routes/(protected)/(endpoints)/config/version/server.test.ts @@ -1,6 +1,6 @@ import { validateConfig } from '@directory/config'; import { describe, expect, it } from 'vitest'; -import { prepareSnapshot } from '../../../../../lib/server/config/snapshot.js'; +import { prepareSnapshot } from '../../../../../lib/server/directory/snapshot.js'; import { GET } from './+server.js'; const sentinel = 'PRIVATE-version-endpoint-config'; diff --git a/apps/web/src/routes/(protected)/page.test.ts b/apps/web/src/routes/(protected)/page.test.ts index 30ab729..c62bbdb 100644 --- a/apps/web/src/routes/(protected)/page.test.ts +++ b/apps/web/src/routes/(protected)/page.test.ts @@ -1,7 +1,7 @@ import { validateConfig } from '@directory/config'; import { describe, expect, it, vi } from 'vitest'; import type { Authentication } from '../../lib/server/auth/index.js'; -import { prepareSnapshot } from '../../lib/server/config/snapshot.js'; +import { prepareSnapshot } from '../../lib/server/directory/snapshot.js'; import { actions, load } from './+page.server.js'; function eventFor(query = '', withAuth = false) { diff --git a/apps/web/src/routes/(public)/favicon/server.test.ts b/apps/web/src/routes/(public)/favicon/server.test.ts index 859a872..f9356c9 100644 --- a/apps/web/src/routes/(public)/favicon/server.test.ts +++ b/apps/web/src/routes/(public)/favicon/server.test.ts @@ -1,7 +1,7 @@ import { validateConfig } from '@directory/config'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { readMountedImage } from '../../../lib/server/images/read-mounted-image.js'; -import { prepareSnapshot } from '../../../lib/server/config/snapshot.js'; +import { prepareSnapshot } from '../../../lib/server/directory/snapshot.js'; import { GET, HEAD } from './+server.js'; vi.mock('../../../lib/server/images/read-mounted-image.js', () => ({ readMountedImage: vi.fn() })); diff --git a/apps/web/src/routes/(public)/logo/server.test.ts b/apps/web/src/routes/(public)/logo/server.test.ts index badafa8..1718e3f 100644 --- a/apps/web/src/routes/(public)/logo/server.test.ts +++ b/apps/web/src/routes/(public)/logo/server.test.ts @@ -1,7 +1,7 @@ import { validateConfig } from '@directory/config'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { readMountedImage } from '../../../lib/server/images/read-mounted-image.js'; -import { prepareSnapshot } from '../../../lib/server/config/snapshot.js'; +import { prepareSnapshot } from '../../../lib/server/directory/snapshot.js'; import { GET, HEAD } from './+server.js'; vi.mock('../../../lib/server/images/read-mounted-image.js', () => ({ readMountedImage: vi.fn() })); diff --git a/apps/web/src/routes/(public)/signin/page.test.ts b/apps/web/src/routes/(public)/signin/page.test.ts index e4ae29a..c4fef83 100644 --- a/apps/web/src/routes/(public)/signin/page.test.ts +++ b/apps/web/src/routes/(public)/signin/page.test.ts @@ -1,7 +1,7 @@ import { validateConfig } from '@directory/config'; import { describe, expect, it, vi } from 'vitest'; import type { Authentication } from '../../../lib/server/auth/index.js'; -import { prepareSnapshot } from '../../../lib/server/config/snapshot.js'; +import { prepareSnapshot } from '../../../lib/server/directory/snapshot.js'; import { logger } from '../../../lib/server/logger.js'; import { actions, load } from './+page.server.js'; diff --git a/apps/web/src/routes/layout.test.ts b/apps/web/src/routes/layout.test.ts index 401b2e4..ac7635f 100644 --- a/apps/web/src/routes/layout.test.ts +++ b/apps/web/src/routes/layout.test.ts @@ -1,6 +1,6 @@ import { validateConfig } from '@directory/config'; import { describe, expect, it } from 'vitest'; -import { prepareSnapshot } from '../lib/server/config/snapshot.js'; +import { prepareSnapshot } from '../lib/server/directory/snapshot.js'; import { load } from './+layout.server.js'; function event(site: Record = {}) { diff --git a/packages/config/README.md b/packages/config/README.md index edb7bd6..eccc1ee 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -1,6 +1,7 @@ # @directory/config -Shared server-side YAML loading, validation and path resolution for Directory. +Shared server-side YAML loading, validation, path resolution and change monitoring +for Directory. - [Configuration](../../docs/config.md): fields, defaults and safe errors. - [Schema](src/config.schema.json). diff --git a/packages/config/src/compare.test.ts b/packages/config/src/compare.test.ts new file mode 100644 index 0000000..c54dbbe --- /dev/null +++ b/packages/config/src/compare.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { isSameConfiguration, validateConfig } from './index.js'; + +function configuration() { + return validateConfig({ + site: { title: 'Home', description: 'Household apps' }, + links: { + photos: { title: 'Photos', url: 'https://photos.example.com' }, + music: { title: 'Music', url: 'https://music.example.com' } + } + }); +} + +describe('isSameConfiguration', () => { + it('compares independently loaded values without treating ordinary mapping order as a change', () => { + const previous = configuration(); + const next = validateConfig({ + ...previous, + site: { description: previous.site.description, title: previous.site.title } + }); + expect(Object.keys(next.site)).not.toEqual(Object.keys(previous.site)); + expect(isSameConfiguration(next, previous)).toBe(true); + next.site.title = 'Changed'; + expect(isSameConfiguration(next, previous)).toBe(false); + }); + + it('treats bookmark declaration order as a change even when values match', () => { + const previous = configuration(); + const next = configuration(); + next.links = { music: next.links.music!, photos: next.links.photos! }; + expect(next).toEqual(previous); + expect(isSameConfiguration(next, previous)).toBe(false); + }); +}); diff --git a/packages/config/src/compare.ts b/packages/config/src/compare.ts new file mode 100644 index 0000000..ca7c635 --- /dev/null +++ b/packages/config/src/compare.ts @@ -0,0 +1,12 @@ +import { isDeepStrictEqual } from 'node:util'; +import type { DirectoryConfiguration } from './types.js'; + +export function isSameConfiguration( + next: DirectoryConfiguration, + previous: DirectoryConfiguration +): boolean { + const sameValues = isDeepStrictEqual(next, previous); + // Bookmark declaration order determines display order, unlike other mapping keys. + const sameLinkOrder = isDeepStrictEqual(Object.keys(next.links), Object.keys(previous.links)); + return sameValues && sameLinkOrder; +} diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index da0482f..c22b1c2 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -1,6 +1,8 @@ export { configurationVersion } from './version.js'; export { DirectoryConfigurationSchema, configurationReferences } from './schema.js'; export { loadConfigFile, validateConfig } from './load.js'; +export { startConfigReloading } from './monitor.js'; +export { isSameConfiguration } from './compare.js'; export { ConfigError } from './errors.js'; export type { ConfigIssue, ConfigIssueCode } from './errors.js'; export { resolveConfigPath, resolveDeploymentRoot, resolvePublicDir } from './paths.js'; diff --git a/packages/config/src/monitor.test.ts b/packages/config/src/monitor.test.ts new file mode 100644 index 0000000..daa29f6 --- /dev/null +++ b/packages/config/src/monitor.test.ts @@ -0,0 +1,231 @@ +import type { Stats } from 'node:fs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { startConfigReloading } from './index.js'; + +type Listener = (current: Stats, previous: Stats) => Promise; +const mocks = vi.hoisted(() => ({ + watchFile: vi.fn<(path: string, options: object, listener: Listener) => void>(), + unwatchFile: vi.fn() +})); +vi.mock('node:fs', () => mocks); + +const path = '/config/settings.json'; +function options() { + return { + path, + initial: 'initial', + load: vi.fn<(path: string) => Promise>().mockResolvedValue('reconciled'), + onChange: vi.fn<(next: string, previous: string) => boolean>().mockReturnValue(true), + onError: vi.fn<(error: unknown) => void>() + }; +} + +function metadata(values: Partial = {}): Stats { + return { ino: 10, mtimeMs: 100, ctimeMs: 100, size: 50, ...values } as Stats; +} +function listener(index = 0): Listener { + return mocks.watchFile.mock.calls[index]![2]; +} +function change(index = 0): Promise { + return listener(index)(metadata({ ctimeMs: 101 }), metadata()); +} + +beforeEach(() => vi.resetAllMocks()); + +describe('startConfigReloading', () => { + it('subscribes before reconciliation and waits for it, using initial as the baseline', async () => { + const input = options(); + const read = Promise.withResolvers(); + input.load.mockReturnValueOnce(read.promise); + const ready = vi.fn(); + const startup = startConfigReloading(input).then((stop) => { + ready(); + return stop; + }); + expect(mocks.watchFile).toHaveBeenCalledExactlyOnceWith( + path, + { interval: 1000, persistent: false }, + expect.any(Function) + ); + // Work starts in a microtask, after the active promise has been assigned. + expect(input.load).not.toHaveBeenCalled(); + await Promise.resolve(); + expect(input.load).toHaveBeenCalledExactlyOnceWith(path); + expect(ready).not.toHaveBeenCalled(); + expect(input.onChange).not.toHaveBeenCalled(); + read.resolve('saved during startup'); + const stop = await startup; + expect(input.onChange).toHaveBeenCalledExactlyOnceWith('saved during startup', 'initial'); + stop(); + }); + + it('ignores unchanged metadata and access-time-only changes', async () => { + const input = options(); + const stop = await startConfigReloading(input); + await listener()(metadata(), metadata()); + await listener()(metadata({ atimeMs: 200 }), metadata()); + expect(input.load).toHaveBeenCalledOnce(); + stop(); + }); + + it.each([ + ['inode', { ino: 11 }], + ['mtime', { mtimeMs: 101 }], + ['ctime with unchanged size and mtime', { ctimeMs: 101 }], + ['size', { size: 51 }] + ])('reloads for changed %s', async (_name, changed) => { + const input = options(); + const stop = await startConfigReloading(input); + await listener()(metadata(changed), metadata()); + expect(input.load).toHaveBeenCalledTimes(2); + stop(); + }); + + it('retains the last accepted value after rejection, callback throws and read failure, then recovers', async () => { + const input = options(); + const stop = await startConfigReloading(input); + input.load.mockResolvedValueOnce('rejected'); + input.onChange.mockReturnValueOnce(false); + await change(); + expect(input.onError).not.toHaveBeenCalled(); + + const callbackFailure = new Error('callback failed'); + input.load.mockResolvedValueOnce('throwing'); + input.onChange.mockImplementationOnce(() => { + throw callbackFailure; + }); + await change(); + const readFailure = new Error('read failed'); + input.load.mockRejectedValueOnce(readFailure); + await change(); + input.load.mockResolvedValueOnce('recovered').mockResolvedValueOnce('latest'); + await change(); + await change(); + + expect(input.onChange.mock.calls).toEqual([ + ['reconciled', 'initial'], + ['rejected', 'reconciled'], + ['throwing', 'reconciled'], + ['recovered', 'reconciled'], + ['latest', 'recovered'] + ]); + expect(input.onError.mock.calls).toEqual([[callbackFailure], [readFailure]]); + stop(); + }); + + it('reports failed reconciliation without losing the initial baseline or future monitoring', async () => { + const input = options(); + const failure = new Error('initial reconciliation failed'); + input.load.mockRejectedValueOnce(failure); + const stop = await startConfigReloading(input); + expect(input.onError).toHaveBeenCalledExactlyOnceWith(failure); + expect(input.onChange).not.toHaveBeenCalled(); + await change(); + expect(input.onChange).toHaveBeenCalledExactlyOnceWith('reconciled', 'initial'); + stop(); + }); + + it('serializes slow reads and coalesces events during reconciliation into one follow-up', async () => { + const input = options(); + const first = Promise.withResolvers(); + const second = Promise.withResolvers(); + const readingAgain = Promise.withResolvers(); + input.load.mockReturnValueOnce(first.promise).mockImplementationOnce(() => { + readingAgain.resolve(); + return second.promise; + }); + const ready = vi.fn(); + const startup = startConfigReloading(input).then((stop) => { + ready(); + return stop; + }); + await Promise.resolve(); + const active = change(); + for (let i = 0; i < 10; i++) void change(); + expect(input.load).toHaveBeenCalledOnce(); + first.resolve('first'); + await readingAgain.promise; + expect(input.onChange).toHaveBeenCalledExactlyOnceWith('first', 'initial'); + expect(ready).not.toHaveBeenCalled(); + second.resolve('second'); + const stop = await startup; + await active; + expect(input.load).toHaveBeenCalledTimes(2); + expect(input.onChange).toHaveBeenLastCalledWith('second', 'first'); + stop(); + }); + + it('keeps monitoring after a throwing reporter', async () => { + const input = options(); + const stop = await startConfigReloading(input); + const failure = new Error('read failed'); + input.load.mockRejectedValueOnce(failure).mockResolvedValue('recovered'); + input.onError.mockImplementation(() => { + throw new Error('diagnostic failure'); + }); + await change(); + await change(); + expect(input.load).toHaveBeenCalledTimes(3); + expect(input.onError).toHaveBeenCalledExactlyOnceWith(failure); + expect(input.onChange).toHaveBeenLastCalledWith('recovered', 'reconciled'); + stop(); + }); + + it.each(['resolve', 'reject'])( + 'stops a pending read before its late %s and clears queued work', + async (mode) => { + const input = options(); + const stop = await startConfigReloading(input); + const read = Promise.withResolvers(); + input.load.mockReturnValueOnce(read.promise); + const active = change(); + await Promise.resolve(); + expect(input.load).toHaveBeenCalledTimes(2); + void change(); + stop(); + stop(); + expect(mocks.unwatchFile).toHaveBeenCalledExactlyOnceWith(path, listener()); + if (mode === 'resolve') read.resolve('too late'); + else read.reject(new Error('too late')); + await active; + await change(); + expect(input.load).toHaveBeenCalledTimes(2); + expect(input.onChange).toHaveBeenCalledOnce(); + expect(input.onError).not.toHaveBeenCalled(); + } + ); + + it('stops scheduled work and removes only its own listener on a shared path', async () => { + const first = options(); + const second = options(); + const stopFirst = await startConfigReloading(first); + const stopSecond = await startConfigReloading(second); + const scheduled = change(0); + stopFirst(); + await scheduled; + await change(0); + await change(1); + expect(first.load).toHaveBeenCalledOnce(); + expect(second.load).toHaveBeenCalledTimes(2); + expect(listener(0)).not.toBe(listener(1)); + expect(mocks.unwatchFile).toHaveBeenCalledExactlyOnceWith(path, listener(0)); + stopSecond(); + expect(mocks.unwatchFile.mock.calls).toEqual([ + [path, listener(0)], + [path, listener(1)] + ]); + }); + + it('propagates registration failure without starting a load', async () => { + const input = options(); + const failure = new Error('watch registration failed'); + mocks.watchFile.mockImplementationOnce(() => { + throw failure; + }); + await expect(startConfigReloading(input)).rejects.toBe(failure); + expect(input.load).not.toHaveBeenCalled(); + expect(input.onChange).not.toHaveBeenCalled(); + expect(input.onError).not.toHaveBeenCalled(); + expect(mocks.unwatchFile).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/config/src/monitor.ts b/packages/config/src/monitor.ts new file mode 100644 index 0000000..b34143b --- /dev/null +++ b/packages/config/src/monitor.ts @@ -0,0 +1,79 @@ +import { watchFile, unwatchFile, type Stats } from 'node:fs'; + +/** Watches a path and reconciles it against the caller's last accepted configuration. */ +export async function startConfigReloading({ + path, + initial, + load, + onChange, + onError +}: { + path: string; + initial: T; + load: (path: string) => Promise; + /** Synchronously accept or reject the loaded value. */ + onChange: (next: T, previous: T) => boolean; + /** Receives raw failures; the caller is responsible for safe diagnostics. */ + onError: (error: unknown) => void; +}): Promise<() => void> { + let accepted = initial; + let stopped = false; + let pending = false; + let active: Promise | undefined; + + async function run(): Promise { + try { + while (!stopped && pending) { + pending = false; + try { + const next = await load(path); + if (stopped) return; + if (onChange(next, accepted) && !stopped) accepted = next; + } catch (error) { + if (stopped) return; + try { + onError(error); + } catch { + // Diagnostic failures must not poison subsequent reloads. + } + } + } + } finally { + active = undefined; + } + } + + function reload(): Promise { + if (stopped) return Promise.resolve(); + pending = true; + // Defer the read so same-turn events coalesce and stop can suppress scheduled work. + active ??= Promise.resolve().then(run); + return active; + } + + function listener(current: Stats, previous: Stats): Promise { + if ( + stopped || + (current.ino === previous.ino && + current.mtimeMs === previous.mtimeMs && + current.ctimeMs === previous.ctimeMs && + current.size === previous.size) + ) { + return Promise.resolve(); + } + return reload(); + } + + function stop(): void { + if (stopped) return; + stopped = true; + pending = false; + unwatchFile(path, listener); + } + + // Poll the path so atomic saves and deletion/recreation remain observable. + watchFile(path, { interval: 1000, persistent: false }, listener); + // Close the gap between the caller's initial read and watcher registration. + await reload(); + return stop; +}