diff --git a/.changeset/config-domain-responses.md b/.changeset/config-domain-responses.md new file mode 100644 index 0000000..59660a2 --- /dev/null +++ b/.changeset/config-domain-responses.md @@ -0,0 +1,7 @@ +--- +'@goto/web': minor +'@goto/worker': minor +'@goto/cli': minor +--- + +Separate domain `root` responses from named links and use `link_defaults` consistently at global and domain levels. Root and fallback responses require a destination URL and support explicit redirect status and query forwarding. Domain fallbacks replace global responses as whole objects; 404 is only the default when no fallback exists. Reject `/` link entries, old defaults and scalar fallback syntax directly. diff --git a/apps/cli/src/cli.test.ts b/apps/cli/src/cli.test.ts index 25edc19..ed5a18c 100644 --- a/apps/cli/src/cli.test.ts +++ b/apps/cli/src/cli.test.ts @@ -16,8 +16,17 @@ auth: credentials: username: admin password: \${DUMMY_PASSWORD} +link_defaults: + status_code: 302 + forward_query: true +fallback: + url: \${DUMMY_DESTINATION} domains: go.example.test: + link_defaults: + status_code: 307 + root: + url: \${DUMMY_DESTINATION} links: /docs: url: \${DUMMY_DESTINATION} diff --git a/apps/web/e2e/dev-server.mjs b/apps/web/e2e/dev-server.mjs deleted file mode 100644 index bed39be..0000000 --- a/apps/web/e2e/dev-server.mjs +++ /dev/null @@ -1,185 +0,0 @@ -import { cp, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; -import { createRequire } from 'node:module'; -import { createServer as createNetServer } from 'node:net'; -import { dirname, join } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; - -const webDirectory = fileURLToPath(new URL('..', import.meta.url)); -const require = createRequire(join(webDirectory, 'package.json')); -let directory; -let vite; -let ready = false; -let closing; - -async function choosePort() { - // Vite 8 treats port 0 as its default port. - const reservation = createNetServer(); - await new Promise((resolve, reject) => { - reservation.once('error', reject); - reservation.listen(0, '127.0.0.1', resolve); - }); - const address = reservation.address(); - if (!address || typeof address === 'string') throw new Error('Missing reserved port'); - await new Promise((resolve, reject) => { - reservation.close((error) => (error ? reject(error) : resolve())); - }); - return address.port; -} - -async function prepareDependencies() { - const sourceModules = join(webDirectory, 'node_modules'); - const modules = join(directory, 'node_modules'); - await mkdir(join(modules, '@goto'), { recursive: true }); - for (const name of await readdir(sourceModules)) { - if (name !== '@goto') await symlink(join(sourceModules, name), join(modules, name)); - } - for (const name of await readdir(join(sourceModules, '@goto'))) { - if (name !== 'db') { - await symlink(join(sourceModules, '@goto', name), join(modules, '@goto', name)); - } - } - const sourceDatabase = dirname(dirname(require.resolve('@goto/db/visit-worker'))); - const database = join(directory, '.packages/db'); - await mkdir(database, { recursive: true }); - for (const path of ['src', 'dist', 'package.json', 'tsconfig.json', 'tsconfig.build.json']) { - await cp(join(sourceDatabase, path), join(database, path), { recursive: true }); - } - await cp(join(sourceDatabase, '../../tsconfig.base.json'), join(directory, 'tsconfig.base.json')); - await symlink(join(sourceDatabase, 'node_modules'), join(database, 'node_modules'), 'dir'); - await symlink(database, join(modules, '@goto/db'), 'dir'); -} - -async function prepareProject() { - const runsDirectory = join(webDirectory, '.e2e'); - await mkdir(runsDirectory, { recursive: true }); - directory = await mkdtemp(join(runsDirectory, 'hmr-')); - for (const path of ['src', 'server', 'package.json', 'vite.config.ts', 'tsconfig.json']) { - await cp(join(webDirectory, path), join(directory, path), { recursive: true }); - } - await prepareDependencies(); - - const applicationPath = join(directory, 'src/lib/server/application.ts'); - const source = await readFile(applicationPath, 'utf8'); - if (source.split('return application;').length !== 2) { - throw new Error('Expected one prepared application return for lifetime instrumentation'); - } - await writeFile( - applicationPath, - `import { randomUUID as fixtureRandomUUID } from 'node:crypto';\n` + - source.replace( - 'return application;', - `const testId = fixtureRandomUUID(); - const timer = setInterval(() => undefined, 1000); - const originalClose = Reflect.get(application, 'close') as (() => void | Promise) | undefined; - let closing: Promise | undefined; - process.send?.({ type: 'application-start', testId }); - return Object.assign(application, { - testId, - close() { - return (closing ??= (async () => { - await originalClose?.call(application); - clearInterval(timer); - process.send?.({ type: 'application-close', testId }); - })()); - } - });` - ) - ); - - const routeDirectory = join(directory, 'src/routes/(admin)/(public)/__hmr'); - await mkdir(join(routeDirectory, 'runtime'), { recursive: true }); - await writeFile( - join(routeDirectory, '+page.svelte'), - ` - - - -` - ); - await writeFile(join(routeDirectory, 'Revision.svelte'), '

revision one

\n'); - await writeFile( - join(routeDirectory, 'runtime/+server.ts'), - `import type { RequestEvent } from '@sveltejs/kit'; - -export function GET({ locals }: RequestEvent) { - return Response.json( - { testId: Reflect.get(locals.application, 'testId'), revision: 'leaf one' }, - { headers: { 'cache-control': 'no-store' } } - ); -} -` - ); -} - -async function start() { - process.env.NODE_ENV = 'development'; - await prepareProject(); - process.chdir(directory); - const { createServer } = await import(pathToFileURL(require.resolve('vite')).href); - vite = await createServer({ - root: directory, - clearScreen: false, - server: { host: '127.0.0.1', port: await choosePort(), strictPort: true } - }); - await vite.listen(); - const address = vite.httpServer?.address(); - if (!address || typeof address === 'string') throw new Error('Missing development address'); - const origin = `http://localhost:${address.port}`; - await mkdir(join(directory, 'config'), { recursive: true }); - await writeFile( - join(directory, 'config/goto.yaml'), - JSON.stringify({ - url: { app: origin }, - auth: { credentials: { username: 'hmr-test', password: 'hmr-test-only-password' } }, - domains: { - [`go.localhost:${address.port}`]: { - links: { '/': { url: 'https://example.org/hmr-test' } } - } - } - }) - ); - ready = true; - process.send?.({ type: 'ready', origin, directory }); -} - -const startup = start(); - -function shutdown() { - return (closing ??= (async () => { - await startup.catch(() => undefined); - try { - await vite?.close(); - } finally { - if (!ready && directory) await rm(directory, { recursive: true, force: true }); - if (process.connected) process.disconnect(); - } - })().catch((error) => { - console.error(error); - process.exitCode = 1; - })); -} - -process.on('SIGTERM', shutdown); -process.on('SIGINT', shutdown); -process.on('disconnect', shutdown); -process.on('message', (message) => { - if (message?.type === 'shutdown') void shutdown(); -}); - -try { - await startup; -} catch (error) { - console.error(error); - process.exitCode = 1; - await shutdown(); -} diff --git a/apps/web/e2e/hmr.e2e.ts b/apps/web/e2e/hmr.e2e.ts deleted file mode 100644 index f40fe20..0000000 --- a/apps/web/e2e/hmr.e2e.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { readFile, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { expect, request, test } from '@playwright/test'; -import { startDevelopment } from './support/development.js'; - -type RuntimeProbe = { testId: string; revision: string }; - -async function replaceSource(path: string, before: string, after: string) { - const source = await readFile(path, 'utf8'); - expect(source.split(before)).toHaveLength(2); - await writeFile(path, source.replace(before, after)); -} - -test('client HMR preserves the application while worker and core edits restart it cleanly', async ({ - browser -}, testInfo) => { - test.setTimeout(120_000); - const development = await startDevelopment(testInfo); - const { origin, directory, events, live } = development; - let context: Awaited> | undefined; - let client: Awaited> | undefined; - try { - context = await browser.newContext({ extraHTTPHeaders: {} }); - client = await request.newContext({ baseURL: origin, extraHTTPHeaders: {}, timeout: 2_000 }); - const page = await context.newPage(); - await page.goto(`${origin}/__hmr`); - await page.getByRole('button', { name: 'count 0', exact: true }).click(); - await expect(page.getByRole('button', { name: 'count 1', exact: true })).toBeVisible(); - await expect(page.getByText('revision one', { exact: true })).toBeVisible(); - - async function readRuntime(): Promise { - try { - const response = await client!.get('/__hmr/runtime'); - if (!response.ok()) return undefined; - return (await response.json()) as RuntimeProbe; - } catch { - return undefined; - } - } - - await expect.poll(readRuntime, { timeout: 20_000 }).toEqual({ - testId: expect.any(String), - revision: 'leaf one' - }); - const initial = (await readRuntime())!; - await expect - .poll(() => events) - .toEqual([{ type: 'application-start', testId: initial.testId }]); - - const routeDirectory = join(directory, 'src/routes/(admin)/(public)/__hmr'); - await replaceSource(join(routeDirectory, 'Revision.svelte'), 'revision one', 'revision two'); - await expect(page.getByText('revision two', { exact: true })).toBeVisible({ timeout: 20_000 }); - await expect(page.getByRole('button', { name: 'count 1', exact: true })).toBeVisible(); - expect(await readRuntime()).toEqual(initial); - await expect - .poll(() => events) - .toEqual([{ type: 'application-start', testId: initial.testId }]); - - await replaceSource(join(routeDirectory, 'runtime/+server.ts'), 'leaf one', 'leaf two'); - await expect.poll(readRuntime, { timeout: 20_000 }).toEqual({ - testId: initial.testId, - revision: 'leaf two' - }); - await expect - .poll(() => events) - .toEqual([{ type: 'application-start', testId: initial.testId }]); - - await replaceSource( - join(directory, '.packages/db/src/visit-worker.ts'), - 'export function ready(): void {}', - "export function ready(): void { console.info('writer revision two'); }" - ); - await expect - .poll( - async () => { - const current = await readRuntime(); - return current?.testId === initial.testId ? undefined : current; - }, - { timeout: 30_000 } - ) - .toEqual({ testId: expect.any(String), revision: 'leaf two' }); - const workerReplacement = (await readRuntime())!; - await expect.poll(development.logs, { timeout: 15_000 }).toContain('writer revision two'); - await expect - .poll(() => events) - .toEqual([ - { type: 'application-start', testId: initial.testId }, - { type: 'application-close', testId: initial.testId }, - { type: 'application-start', testId: workerReplacement.testId } - ]); - - const applicationPath = join(directory, 'src/lib/server/application.ts'); - await writeFile( - applicationPath, - (await readFile(applicationPath, 'utf8')) + '\nexport const hmrCoreRevision = 2;\n' - ); - await expect - .poll( - async () => { - const current = await readRuntime(); - return current?.testId === workerReplacement.testId ? undefined : current; - }, - { timeout: 30_000 } - ) - .toEqual({ testId: expect.any(String), revision: 'leaf two' }); - const replacement = (await readRuntime())!; - await expect - .poll(() => events) - .toEqual([ - { type: 'application-start', testId: initial.testId }, - { type: 'application-close', testId: initial.testId }, - { type: 'application-start', testId: workerReplacement.testId }, - { type: 'application-close', testId: workerReplacement.testId }, - { type: 'application-start', testId: replacement.testId } - ]); - expect(development.getpeak()).toBe(1); - expect([...live]).toEqual([replacement.testId]); - expect(development.logs()).not.toContain('Visit tracking unavailable'); - - await context.close(); - context = undefined; - await client.dispose(); - client = undefined; - await development.stop(); - expect(development.getexit()).toEqual({ code: 0, signal: null }); - expect(events).toEqual([ - { type: 'application-start', testId: initial.testId }, - { type: 'application-close', testId: initial.testId }, - { type: 'application-start', testId: workerReplacement.testId }, - { type: 'application-close', testId: workerReplacement.testId }, - { type: 'application-start', testId: replacement.testId }, - { type: 'application-close', testId: replacement.testId } - ]); - expect(live.size).toBe(0); - expect(development.getpeak()).toBe(1); - } finally { - try { - await context?.close(); - await client?.dispose(); - } finally { - await development.dispose(); - } - } -}); diff --git a/apps/web/e2e/server.mjs b/apps/web/e2e/server.js similarity index 100% rename from apps/web/e2e/server.mjs rename to apps/web/e2e/server.js diff --git a/apps/web/e2e/support/development.ts b/apps/web/e2e/support/development.ts deleted file mode 100644 index d2ce1e2..0000000 --- a/apps/web/e2e/support/development.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { fork } from 'node:child_process'; -import { rm } from 'node:fs/promises'; -import { expect, type TestInfo } from '@playwright/test'; - -type Ready = { type: 'ready'; origin: string; directory: string }; -type LifetimeEvent = { type: 'application-start' | 'application-close'; testId: string }; -type Exit = { code: number | null; signal: NodeJS.Signals | null }; - -export async function startDevelopment(testInfo: TestInfo) { - const child = fork(new URL('../dev-server.mjs', import.meta.url), [], { - env: { PATH: process.env.PATH, NODE_ENV: 'development', TZ: 'UTC' }, - execArgv: [], - stdio: ['ignore', 'pipe', 'pipe', 'ipc'] - }); - let ready: Ready | undefined; - let exit: Exit | undefined; - let childError: Error | undefined; - let output = ''; - const events: LifetimeEvent[] = []; - const live = new Set(); - let peak = 0; - const exited = new Promise((resolve) => { - child.once('exit', (code, signal) => { - exit = { code, signal }; - resolve(); - }); - }); - child.once('error', (error) => { - childError = error; - }); - child.stdout!.on('data', captureOutput); - child.stderr!.on('data', captureOutput); - child.on('message', recordMessage); - - function captureOutput(chunk: Buffer) { - output = (output + chunk.toString()).slice(-50_000); - } - - function recordMessage(message: unknown) { - if (typeof message !== 'object' || message === null || !('type' in message)) return; - if ( - message.type === 'ready' && - 'origin' in message && - typeof message.origin === 'string' && - 'directory' in message && - typeof message.directory === 'string' - ) { - ready = { type: 'ready', origin: message.origin, directory: message.directory }; - } else if ( - (message.type === 'application-start' || message.type === 'application-close') && - 'testId' in message && - typeof message.testId === 'string' - ) { - events.push({ type: message.type, testId: message.testId }); - if (message.type === 'application-start') live.add(message.testId); - else live.delete(message.testId); - peak = Math.max(peak, live.size); - } - } - - async function waitForExit(timeout: number) { - let timer: ReturnType | undefined; - try { - return await Promise.race([ - exited.then(() => true), - new Promise((resolve) => { - timer = setTimeout(() => resolve(false), timeout); - }) - ]); - } finally { - clearTimeout(timer); - } - } - - async function stop() { - if (!exit && child.connected) child.send({ type: 'shutdown' }); - else if (!exit) child.kill('SIGTERM'); - if (!(await waitForExit(10_000))) { - throw new Error('Development server did not exit within 10 seconds'); - } - expect(child.connected).toBe(false); - } - - async function dispose() { - try { - if (child.pid && !exit) { - child.kill('SIGTERM'); - if (!(await waitForExit(6_000))) { - child.kill('SIGKILL'); - if (!(await waitForExit(2_000))) { - throw new Error('Development server did not exit after SIGKILL'); - } - } - } - } finally { - try { - await testInfo.attach('hmr-server.log', { body: output, contentType: 'text/plain' }); - await testInfo.attach('hmr-lifetime.json', { - body: JSON.stringify({ events, peak, live: [...live], exit }), - contentType: 'application/json' - }); - } finally { - if (ready) await rm(ready.directory, { recursive: true, force: true }); - } - } - } - - try { - await expect - .poll( - () => { - if (childError) throw childError; - if (exit) throw new Error(`Development server exited: ${JSON.stringify(exit)}`); - return ready; - }, - { timeout: 45_000 } - ) - .toBeDefined(); - return { - origin: ready!.origin, - directory: ready!.directory, - events, - live, - getpeak: () => peak, - logs: () => output, - getexit: () => exit, - stop, - dispose - }; - } catch (error) { - await dispose(); - throw error; - } -} diff --git a/apps/web/e2e/support/test.ts b/apps/web/e2e/support/test.ts index 7483354..543fa8f 100644 --- a/apps/web/e2e/support/test.ts +++ b/apps/web/e2e/support/test.ts @@ -63,7 +63,7 @@ export const test = base.extend<{ appServer: async ({ withWorker, retentionMonths }, use, testInfo) => { const directory = process.env.GOTO_E2E_BUILD_DIR; if (!directory) throw new Error('Missing E2E build directory'); - const child = fork(new URL('../server.mjs', import.meta.url), [], { + const child = fork(new URL('../server.js', import.meta.url), [], { env: { ...serverEnvironment(), GOTO_E2E_BUILD_DIR: directory, diff --git a/apps/web/src/lib/components/admin/LinkInventory.svelte b/apps/web/src/lib/components/admin/LinkInventory.svelte index d54b3fa..9af93c3 100644 --- a/apps/web/src/lib/components/admin/LinkInventory.svelte +++ b/apps/web/src/lib/components/admin/LinkInventory.svelte @@ -1,5 +1,6 @@