Migrate to SvelteKit 3 #71
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@directory/web': patch
|
||||
---
|
||||
|
||||
Upgrade the web app to SvelteKit 3, preserving runtime `site.url`, native sign-in, bookmark navigation and container workflows.
|
||||
+3
-1
@@ -22,9 +22,11 @@ COPY . .
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm install --offline --frozen-lockfile
|
||||
# Only generated output and caches are writable; source binds stay read-only.
|
||||
RUN mkdir -p /m/packages/config/dist /m/apps/web/.svelte-kit \
|
||||
'/m/apps/web/node_modules/$app' \
|
||||
/m/apps/web/node_modules/.vite /m/apps/web/node_modules/.vite-temp \
|
||||
&& chown -R ${LOCAL_UID}:${LOCAL_GID} /home/app /m/packages/config/dist \
|
||||
/m/apps/web/.svelte-kit /m/apps/web/node_modules/.vite /m/apps/web/node_modules/.vite-temp
|
||||
/m/apps/web/.svelte-kit '/m/apps/web/node_modules/$app' \
|
||||
/m/apps/web/node_modules/.vite /m/apps/web/node_modules/.vite-temp
|
||||
USER ${LOCAL_UID}:${LOCAL_GID}
|
||||
WORKDIR /m/apps/web
|
||||
EXPOSE 5173
|
||||
|
||||
@@ -26,7 +26,6 @@ services:
|
||||
- ./static:/m/apps/web/static:ro
|
||||
- ./config:/m/apps/web/config:ro
|
||||
- ./public:/m/apps/web/public:ro
|
||||
- ./svelte.config.js:/m/apps/web/svelte.config.js:ro
|
||||
- ./vite.config.ts:/m/apps/web/vite.config.ts:ro
|
||||
- ./tsconfig.json:/m/apps/web/tsconfig.json:ro
|
||||
- ../../packages/config/src:/m/packages/config/src:ro
|
||||
|
||||
@@ -99,7 +99,9 @@ for (const view of ['card', 'details'] as const) {
|
||||
await page.goto(`${server.url}/${view === 'details' ? '?link=broken' : ''}`, {
|
||||
waitUntil: 'commit'
|
||||
});
|
||||
const cardImage = page.locator('a[href="/?link=broken"] img');
|
||||
const cardImage = page
|
||||
.getByRole('link', { name: 'Broken primary', exact: true })
|
||||
.locator('img');
|
||||
await expect(cardImage).toHaveAttribute('src', '/public/primary-a.svg');
|
||||
await expectBroken(cardImage);
|
||||
await expect.poll(() => heldJavaScript).toBe(true);
|
||||
@@ -266,16 +268,23 @@ test('without JavaScript, good images decode and broken images leave card and de
|
||||
['default', 'Omitted primary'],
|
||||
['other', 'Other broken primary']
|
||||
]) {
|
||||
const card = page.locator(`a[href="/?link=${id}"]`);
|
||||
await expect(card).toContainText(title!);
|
||||
await expect(card).toHaveAttribute('data-sveltekit-preload-data', 'off');
|
||||
const card = page.getByRole('link', { name: title!, exact: true });
|
||||
await expect(card).toHaveJSProperty('href', `${server.url}/?link=${id}`);
|
||||
await expect(card).toHaveAttribute('data-sveltekit-preload-data', 'false');
|
||||
}
|
||||
const broken = page.locator('a[href="/?link=broken"] img');
|
||||
const brokenLink = page.getByRole('link', { name: 'Broken primary', exact: true });
|
||||
const broken = brokenLink.locator('img');
|
||||
await expect(broken).toHaveAttribute('src', '/public/primary-a.svg');
|
||||
await expectBroken(broken);
|
||||
await expectDecoded(page.locator('a[href="/?link=valid"] img'), '/public/valid.svg');
|
||||
await expectDecoded(page.locator('a[href="/?link=default"] img'), '/bookmarks/default/logo');
|
||||
await page.locator('a[href="/?link=broken"]').click();
|
||||
await expectDecoded(
|
||||
page.getByRole('link', { name: 'Valid primary', exact: true }).locator('img'),
|
||||
'/public/valid.svg'
|
||||
);
|
||||
await expectDecoded(
|
||||
page.getByRole('link', { name: 'Omitted primary', exact: true }).locator('img'),
|
||||
'/bookmarks/default/logo'
|
||||
);
|
||||
await brokenLink.click();
|
||||
const details = page.getByRole('region', { name: 'Broken primary', exact: true });
|
||||
await expect(details.locator('img')).toHaveAttribute('src', '/public/primary-a.svg');
|
||||
await expectBroken(details.locator('img'));
|
||||
@@ -299,9 +308,9 @@ test('without JavaScript, good images decode and broken images leave card and de
|
||||
await expect(url).toBeVisible();
|
||||
await expect(url).toHaveValue('https://broken.example.test');
|
||||
await expect(url).toHaveJSProperty('readOnly', true);
|
||||
await page.locator('a[href="/?link=valid"]').click();
|
||||
await page.getByRole('link', { name: 'Valid primary', exact: true }).click();
|
||||
await expectDecoded(page.locator('section.details img'), '/public/valid.svg');
|
||||
await page.locator('a[href="/?link=default"]').click();
|
||||
await page.getByRole('link', { name: 'Omitted primary', exact: true }).click();
|
||||
await expectDecoded(page.locator('section.details img'), '/bookmarks/default/logo');
|
||||
await page.getByRole('link', { name: 'Back to directory' }).click();
|
||||
await expect(page).toHaveURL(`${server.url}/`);
|
||||
|
||||
@@ -26,14 +26,15 @@ test('bookmark logos render on cards and details without JavaScript and follow a
|
||||
await server.ready();
|
||||
const page = await context.newPage();
|
||||
await page.goto(server.url);
|
||||
const card = page.locator('a[href="/?link=photos"] img');
|
||||
const photos = page.getByRole('link', { name: 'Family Photos', exact: true });
|
||||
const card = photos.locator('img');
|
||||
await expect(card).toHaveAttribute('src', '/bookmarks/photos/logo');
|
||||
await expect(card).toHaveAttribute('width', '64');
|
||||
await expect(card).toHaveAttribute('height', '64');
|
||||
await expect
|
||||
.poll(() => card.evaluate((image) => (image as HTMLImageElement).naturalWidth))
|
||||
.toBeGreaterThan(0);
|
||||
await page.locator('a[href="/?link=photos"]').click();
|
||||
await photos.click();
|
||||
const details = page.getByRole('region', { name: 'Family Photos', exact: true });
|
||||
await expect(details.locator('img')).toHaveAttribute('src', '/bookmarks/photos/logo');
|
||||
expect(await (await server.fetch('/bookmarks/photos/logo')).text()).toContain('>FP</text>');
|
||||
|
||||
@@ -281,7 +281,11 @@ test('open mode serves contained images regardless of references, with MIME and
|
||||
expectPrivateHeaders(response);
|
||||
}
|
||||
}
|
||||
expect((await server.fetch('/public/photo.svg', { method: 'POST' })).status).toBe(405);
|
||||
const unsupported = await server.fetch('/public/photo.svg', {
|
||||
method: 'POST',
|
||||
headers: { origin: server.origin }
|
||||
});
|
||||
expect(unsupported.status).toBe(405);
|
||||
const generated = await server.fetch('/bookmarks/photos/logo');
|
||||
expect(generated.status).toBe(200);
|
||||
expectPrivateHeaders(generated);
|
||||
@@ -413,13 +417,13 @@ test('existing SSR cards and details render mounted logos and omitted-path bookm
|
||||
['router', '/bookmarks/router/logo'],
|
||||
['thumbnail', '/public/photo.png']
|
||||
] as const) {
|
||||
const image = page.locator(`a[href="/?link=${id}"] img`);
|
||||
const image = page.locator(`a[href$="?link=${id}"] img`);
|
||||
await expect(image).toHaveAttribute('src', path);
|
||||
await expect
|
||||
.poll(() => image.evaluate((node) => (node as HTMLImageElement).naturalWidth))
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
await page.locator('a[href="/?link=photos"]').click();
|
||||
await page.getByRole('link', { name: 'Photos', exact: true }).click();
|
||||
const details = page
|
||||
.getByRole('region', { name: 'Photos', exact: true })
|
||||
.locator('img[src="/public/photo.svg"]');
|
||||
|
||||
@@ -172,10 +172,12 @@ test('blocked client boot keeps controls disabled and native grid navigation usa
|
||||
await page.goto(server.url);
|
||||
await expect(page.getByRole('searchbox', { name: 'Search', exact: true })).toBeDisabled();
|
||||
await expect(page.getByRole('button', { name: /theme/ })).toBeDisabled();
|
||||
await page.locator('a[href="/?link=beta"]').click();
|
||||
const beta = page.getByRole('link', { name: 'Beta service', exact: true });
|
||||
await expect(beta).toHaveJSProperty('href', `${server.url}/?link=beta`);
|
||||
await beta.click();
|
||||
await expect(page).toHaveURL(`${server.url}/?link=beta`);
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
await page.locator('a[href="/?link=alpha"]').click();
|
||||
await page.getByRole('link', { name: 'Alpha service', exact: true }).click();
|
||||
await expect(page).toHaveURL(`${server.url}/?link=alpha`);
|
||||
await expect(page.locator('.tile')).toHaveCount(2);
|
||||
});
|
||||
|
||||
@@ -49,15 +49,13 @@ test('groups, bookmark titles and plain details work without JavaScript', async
|
||||
['router', 'Router'],
|
||||
['notes', 'Household notes']
|
||||
]) {
|
||||
const link = page.locator(`a[href="/?link=${id}"]`);
|
||||
await expect(link).toContainText(label!);
|
||||
await expect(link).toHaveAttribute('data-sveltekit-preload-data', 'off');
|
||||
const link = page.getByRole('link', { name: label!, exact: true });
|
||||
await expect(link).toHaveJSProperty('href', `${server.url}/?link=${id}`);
|
||||
await expect(link).toHaveAttribute('data-sveltekit-preload-data', 'false');
|
||||
}
|
||||
await expect(page.locator('a[href="/?link=photos"] img')).toHaveAttribute(
|
||||
'src',
|
||||
'/bookmarks/photos/logo'
|
||||
);
|
||||
await page.locator('a[href="/?link=photos"]').click();
|
||||
const photos = page.getByRole('link', { name: 'Photos', exact: true });
|
||||
await expect(photos.locator('img')).toHaveAttribute('src', '/bookmarks/photos/logo');
|
||||
await photos.click();
|
||||
await expect(page).toHaveURL(`${server.url}/?link=photos`);
|
||||
await expect(page.getByRole('heading', { name: 'Photos', exact: true })).toBeVisible();
|
||||
const details = page.getByRole('region', { name: 'Photos', exact: true });
|
||||
@@ -80,7 +78,7 @@ test('groups, bookmark titles and plain details work without JavaScript', async
|
||||
await expect(tags).toHaveCount(1);
|
||||
await expect(tags.getByRole('listitem')).toHaveText(['login', 'internal', 'admin']);
|
||||
await expect(tags.getByRole('listitem').last()).toHaveAccessibleName('admin only');
|
||||
await expect(page.locator('a[href="/?link=photos"]')).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Photos', exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await context.close();
|
||||
expect(await server.stop()).toEqual({ code: 0, signal: null });
|
||||
|
||||
@@ -47,6 +47,18 @@ test('search finds a bookmark, opens its details and clears back to browsing', a
|
||||
await expect(results.getByRole('link')).toHaveCount(1);
|
||||
await expect(results.getByRole('link')).toContainText('Family photos');
|
||||
await expect(page).toHaveURL(`${server.url}/?q=photos`);
|
||||
// Async shallow routing must not rewrite an in-place edit or move its caret to the end.
|
||||
await search.press('End');
|
||||
await search.press('ArrowLeft');
|
||||
await search.press('x');
|
||||
await expect(search).toHaveValue('photoxs');
|
||||
await expect(page).toHaveURL(`${server.url}/?q=photoxs`);
|
||||
await expect(search).toBeFocused();
|
||||
await expect(search).toHaveJSProperty('selectionStart', 6);
|
||||
await expect(search).toHaveJSProperty('selectionEnd', 6);
|
||||
await search.press('Backspace');
|
||||
await expect(search).toHaveValue('photos');
|
||||
await expect(page).toHaveURL(`${server.url}/?q=photos`);
|
||||
await page.reload();
|
||||
await expect(search).toHaveValue('photos');
|
||||
await expect(results.getByRole('link')).toHaveCount(1);
|
||||
|
||||
@@ -122,7 +122,7 @@ test('without JavaScript a broken configured header leaves title and native navi
|
||||
await expect(page.locator('header img')).toHaveAttribute('src', sourceA);
|
||||
await expectBroken(page.locator('header img'));
|
||||
await expect(page.locator('header h1')).toHaveText(title);
|
||||
await page.locator('a[href="/?link=book"]').click();
|
||||
await page.locator('a[href$="?link=book"]').click();
|
||||
await expect(page).toHaveURL(`${app.url}/?link=book`);
|
||||
await expect(page.locator('header .description')).toHaveText(siteDescription);
|
||||
} finally {
|
||||
|
||||
@@ -71,8 +71,8 @@ export async function startServer(options: ServerOptions) {
|
||||
const script = `
|
||||
import { once } from 'node:events';
|
||||
const { server } = await import(${JSON.stringify(serverModule)});
|
||||
if (!server.server.listening) await once(server.server, 'listening');
|
||||
process.send({ ready: server.server.address().port });
|
||||
if (!server.listening) await once(server, 'listening');
|
||||
process.send({ ready: server.address().port });
|
||||
`;
|
||||
const child = spawn(process.execPath, ['--input-type=module', '--eval', script], {
|
||||
cwd: temporary,
|
||||
|
||||
@@ -40,5 +40,8 @@
|
||||
"unplugin-icons": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"imports": {
|
||||
"#lib/*": "./src/lib/*"
|
||||
}
|
||||
}
|
||||
Vendored
-1
@@ -15,7 +15,6 @@ declare global {
|
||||
}
|
||||
|
||||
interface PageState {
|
||||
directoryQuery?: string;
|
||||
directoryDetail?: { id: string; fromGrid: boolean } | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Handle } from '@sveltejs/kit/hooks';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { validateConfig } from '@directory/config';
|
||||
import type { Handle, RequestEvent } from '@sveltejs/kit';
|
||||
import { themeStyleTag } from './lib/server/appearance.js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
|
||||
import type { Authentication } from './lib/server/auth/index.js';
|
||||
@@ -14,7 +15,6 @@ const adapterEvents: NodeJS.EventEmitter = process;
|
||||
const mocks = vi.hoisted(() => ({
|
||||
building: false,
|
||||
dev: false,
|
||||
env: { ORIGIN: 'https://directory.example.com' },
|
||||
boot: vi.fn<typeof import('./lib/server/boot.js').bootApp>(),
|
||||
report: vi.fn<typeof import('./lib/server/logger.js').reportStartupFailure>(),
|
||||
load: vi.fn<typeof import('@directory/config').loadConfigFile>(),
|
||||
@@ -22,8 +22,7 @@ const mocks = vi.hoisted(() => ({
|
||||
prepare: vi.fn<typeof import('./lib/server/directory/snapshot.js').prepareSnapshot>()
|
||||
}));
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({ env: mocks.env }));
|
||||
vi.mock('$app/environment', () => ({
|
||||
vi.mock('$app/env', () => ({
|
||||
get building() {
|
||||
return mocks.building;
|
||||
},
|
||||
@@ -172,7 +171,7 @@ describe('server hooks', () => {
|
||||
expect(ready.currentSnapshot).not.toHaveBeenCalled();
|
||||
pending.resolve(ready);
|
||||
await startup;
|
||||
expect(mocks.boot).toHaveBeenCalledExactlyOnceWith(undefined, mocks.env);
|
||||
expect(mocks.boot).toHaveBeenCalledExactlyOnceWith();
|
||||
expect((await hooks.handle({ event: event(), resolve })).status).toBe(200);
|
||||
adapterEvents.emit('sveltekit:shutdown');
|
||||
expect((await hooks.handle({ event: event(), resolve })).status).toBe(503);
|
||||
@@ -185,7 +184,7 @@ describe('server hooks', () => {
|
||||
await hooks.init();
|
||||
expect(development).toHaveBeenCalledOnce();
|
||||
expect(production).not.toHaveBeenCalled();
|
||||
expect(mocks.boot).toHaveBeenCalledExactlyOnceWith(undefined, mocks.env);
|
||||
expect(mocks.boot).toHaveBeenCalledExactlyOnceWith();
|
||||
process.emit('directory:dev-stop');
|
||||
});
|
||||
|
||||
@@ -329,9 +328,13 @@ describe('server hooks', () => {
|
||||
for (const cookie of ['', 'unrelated-cookie=opaque']) {
|
||||
for (const [path, routeId, params] of [
|
||||
['/public/photo.svg', '/(public)/public/[...file]', { file: 'photo.svg' }],
|
||||
|
||||
['/public/site.svg', '/(public)/public/[...file]', { file: 'site.svg' }],
|
||||
|
||||
['/%70ublic/photo.svg', '/(public)/public/[...file]', { file: 'photo.svg' }],
|
||||
|
||||
['/public/unknown.svg', '/(public)/public/[...file]', { file: 'unknown.svg' }],
|
||||
|
||||
[
|
||||
'/bookmarks/photos/logo',
|
||||
'/(protected)/(endpoints)/bookmarks/[id]/logo',
|
||||
@@ -423,10 +426,12 @@ describe('server hooks', () => {
|
||||
const themedHead = await transform({ html: head, done: false });
|
||||
expect(themedHead).toBe(`\t${themeStyleTag(captured.display.appearance)}\n${head}`);
|
||||
expect(await transform({ html: ending, done: true })).toBe(ending);
|
||||
|
||||
return new Response(opening + themedHead + ending, {
|
||||
headers: { 'content-type': 'text/html' }
|
||||
});
|
||||
});
|
||||
|
||||
const response = await hooks.handle({ event: request, resolve });
|
||||
const html = await response.text();
|
||||
expect(html.match(/<style>/g)).toHaveLength(1);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { building, dev } from '$app/environment';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { Handle, ServerInit } from '@sveltejs/kit';
|
||||
import { sequence } from '@sveltejs/kit/hooks';
|
||||
import { building, dev } from '$app/env';
|
||||
import { sequence, type Handle, type ServerInit } from '@sveltejs/kit/hooks';
|
||||
import { themeStyleTag } from './lib/server/appearance.js';
|
||||
import type { ServerState } from './lib/server/application.js';
|
||||
import { startDevelopment } from './lib/server/lifecycle/development.js';
|
||||
@@ -13,8 +11,8 @@ const server: ServerState = { application: undefined };
|
||||
export const init: ServerInit = async () => {
|
||||
if (building) return;
|
||||
try {
|
||||
if (dev) await startDevelopment(server, env);
|
||||
else await startProduction(server, env);
|
||||
if (dev) await startDevelopment(server);
|
||||
else await startProduction(server);
|
||||
} catch (error) {
|
||||
reportStartupFailure(error);
|
||||
// eslint-disable-next-line preserve-caught-error -- Raw startup errors may contain configuration secrets.
|
||||
|
||||
@@ -15,10 +15,7 @@
|
||||
let retryDelay = checkInterval;
|
||||
|
||||
$effect(() => {
|
||||
// Kit shallow navigation updates page.state, not page.url.
|
||||
void page.state;
|
||||
void page.url;
|
||||
const currentURL = new SvelteURL(window.location.href);
|
||||
const currentURL = new SvelteURL((page.shallow?.url ?? page.url).href);
|
||||
currentURL.hash = '';
|
||||
refreshHref = currentURL.href;
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { DocumentMetadata } from '$lib/server/document.js';
|
||||
import type { DocumentMetadata } from '#lib/server/document.js';
|
||||
|
||||
let { metadata, faviconPath }: { metadata: DocumentMetadata; faviconPath: string } = $props();
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Button from '#lib/components/ui/Button.svelte';
|
||||
import IconMoon from 'virtual:icons/ph/moon';
|
||||
import IconSunDim from 'virtual:icons/ph/sun-dim';
|
||||
import IconFaders from 'virtual:icons/ph/faders';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import type { DisplayLink } from '$lib/display/types.js';
|
||||
import type { DisplayLink } from '#lib/display/types.js';
|
||||
import BookmarkLogo from './BookmarkLogo.svelte';
|
||||
import ButtonLink from '../ui/ButtonLink.svelte';
|
||||
import Chip from '../ui/Chip.svelte';
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import IconX from 'virtual:icons/ph/x';
|
||||
import IconCaretLeft from 'virtual:icons/ph/caret-left';
|
||||
import IconCaretRight from 'virtual:icons/ph/caret-right';
|
||||
import type { DisplayLink } from '$lib/display/types.js';
|
||||
import type { DisplayLink } from '#lib/display/types.js';
|
||||
import BookmarkDetails from './BookmarkDetails.svelte';
|
||||
import Button from '../ui/Button.svelte';
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
openLinksInNewTab: boolean;
|
||||
hasPrevious: boolean;
|
||||
hasNext: boolean;
|
||||
onNavigate: (step: -1 | 1) => void;
|
||||
onNavigate: (step: -1 | 1) => Promise<void>;
|
||||
onClose: () => void;
|
||||
onClosed: () => void;
|
||||
onFilterSelect: (query: string) => void;
|
||||
@@ -51,8 +51,13 @@
|
||||
async function navigate(event: MouseEvent, step: -1 | 1) {
|
||||
const button = event.currentTarget as HTMLButtonElement;
|
||||
const hadFocus = button === document.activeElement;
|
||||
onNavigate(step);
|
||||
try {
|
||||
await onNavigate(step);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await tick();
|
||||
if (!link || !button.isConnected) return;
|
||||
if (scrollContent) scrollContent.scrollTop = 0;
|
||||
if (hadFocus && button.disabled) {
|
||||
(step === 1 ? previousButton : nextButton)?.focus({ preventScroll: true });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Logo from '../Logo.svelte';
|
||||
import type { DisplayLink } from '$lib/display/types.js';
|
||||
import type { DisplayLink } from '#lib/display/types.js';
|
||||
|
||||
interface Props {
|
||||
readonly link: DisplayLink;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script module lang="ts">
|
||||
let engineImport: Promise<typeof import('$lib/search/engine.js')> | undefined;
|
||||
let engineImport: Promise<typeof import('#lib/search/engine.js')> | undefined;
|
||||
|
||||
function loadEngine() {
|
||||
engineImport ??= import('$lib/search/engine.js').catch((error: unknown) => {
|
||||
engineImport ??= import('#lib/search/engine.js').catch((error: unknown) => {
|
||||
engineImport = undefined;
|
||||
throw error;
|
||||
});
|
||||
@@ -17,8 +17,8 @@
|
||||
import Button from '../ui/Button.svelte';
|
||||
import Input from '../ui/Input.svelte';
|
||||
import { beforeNavigate, afterNavigate } from '$app/navigation';
|
||||
import type { DirectoryContent } from '$lib/display/types.js';
|
||||
import type { SearchResult } from '$lib/search/engine.js';
|
||||
import type { DirectoryContent } from '#lib/display/types.js';
|
||||
import type { SearchResult } from '#lib/search/engine.js';
|
||||
|
||||
let {
|
||||
directory,
|
||||
@@ -41,7 +41,7 @@
|
||||
let navigating = $state(false);
|
||||
let activated = $state(false);
|
||||
let completedQuery: string | undefined;
|
||||
let searchBookmarks: ReturnType<typeof import('$lib/search/engine.js').createSearch> | undefined;
|
||||
let searchBookmarks: ReturnType<typeof import('#lib/search/engine.js').createSearch> | undefined;
|
||||
let indexedSnapshotId: string | undefined;
|
||||
let searchRequestVersion = 0;
|
||||
let searchUnavailable = false;
|
||||
@@ -112,11 +112,16 @@
|
||||
};
|
||||
});
|
||||
|
||||
beforeNavigate(() => {
|
||||
beforeNavigate(({ shallow, type }) => {
|
||||
if (shallow && type === 'goto') return;
|
||||
|
||||
navigating = true;
|
||||
searchRequestVersion++;
|
||||
});
|
||||
afterNavigate(() => {
|
||||
|
||||
afterNavigate(({ shallow, type }) => {
|
||||
if (shallow && type === 'goto') return;
|
||||
|
||||
navigating = false;
|
||||
if (query.trim()) activated = true;
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { DirectoryContent, DisplayGroup, DisplayLink } from '$lib/display/types.js';
|
||||
import type { DirectoryContent, DisplayGroup, DisplayLink } from '#lib/display/types.js';
|
||||
import { createSearch } from './engine.js';
|
||||
|
||||
function link(id: string, fields: Partial<DisplayLink> = {}): DisplayLink {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Fuse from 'fuse.js';
|
||||
import type { DirectoryContent, DisplayLink } from '$lib/display/types.js';
|
||||
import type { DirectoryContent, DisplayLink } from '#lib/display/types.js';
|
||||
|
||||
export interface SearchResult {
|
||||
readonly link: DisplayLink;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Handle } from '@sveltejs/kit/hooks';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { beforeEach, expect, it, vi } from 'vitest';
|
||||
import type { Handle, RequestEvent } from '@sveltejs/kit';
|
||||
import { protectAuthRequest, requireSignIn, type AccessPolicy } from './access.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
@@ -98,9 +99,11 @@ it('rejects either inconsistent request origin before resolving a route', async
|
||||
for (const replaceRequest of [false, true]) {
|
||||
const input = requestFor('/');
|
||||
const foreign = new URL('https://another.example/');
|
||||
if (replaceRequest) input.event.request = new Request(foreign);
|
||||
else input.event.url = foreign;
|
||||
expect((await protectAuthRequest(input, policy)).status).toBe(403);
|
||||
const event = {
|
||||
...input.event,
|
||||
...(replaceRequest ? { request: new Request(foreign) } : { url: foreign })
|
||||
};
|
||||
expect((await protectAuthRequest({ ...input, event }, policy)).status).toBe(403);
|
||||
expect(input.resolve).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isRedirect, type Handle, type RequestEvent } from '@sveltejs/kit';
|
||||
import { isRedirect, type RequestEvent } from '@sveltejs/kit';
|
||||
import type { Handle } from '@sveltejs/kit/hooks';
|
||||
import { logger } from '../logger.js';
|
||||
import { isSignInError, localRedirect, signInLocation } from './redirect.js';
|
||||
import { hasExpectedOrigin } from './request.js';
|
||||
@@ -64,7 +65,7 @@ export async function requireSignIn(
|
||||
): Promise<Response> {
|
||||
event.locals.session = null;
|
||||
const routeId = event.route.id ?? '';
|
||||
const publicRoute = routeId === '/(public)' || routeId.startsWith('/(public)/');
|
||||
const publicRoute = isInGroup(routeId, '/(public)');
|
||||
if (publicRoute) return resolve(event);
|
||||
try {
|
||||
event.locals.session = await event.locals.auth();
|
||||
@@ -73,8 +74,7 @@ export async function requireSignIn(
|
||||
return denied(401);
|
||||
}
|
||||
if (event.locals.session?.user) return resolve(event);
|
||||
const endpoint =
|
||||
routeId === '/(protected)/(endpoints)' || routeId.startsWith('/(protected)/(endpoints)/');
|
||||
const endpoint = isInGroup(routeId, '/(protected)/(endpoints)');
|
||||
if (!endpoint && isHtmlNavigation(event)) {
|
||||
const callbackUrl = localRedirect(event.url.pathname + event.url.search, policy.origin);
|
||||
return new Response(null, {
|
||||
@@ -85,6 +85,10 @@ export async function requireSignIn(
|
||||
return denied(401);
|
||||
}
|
||||
|
||||
function isInGroup(routeId: string, group: string): boolean {
|
||||
return routeId === group || routeId.startsWith(`${group}/`);
|
||||
}
|
||||
|
||||
function isUnsafeMethod(event: RequestEvent): boolean {
|
||||
return !['GET', 'HEAD', 'OPTIONS'].includes(event.request.method);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function signInAction(
|
||||
if (!['https:', 'http:'].includes(destination.protocol)) {
|
||||
throw new Error('Invalid authentication destination.');
|
||||
}
|
||||
redirect(303, destination.href);
|
||||
redirect(303, destination.href, { external: true });
|
||||
}
|
||||
redirect(303, callbackUrl);
|
||||
} catch (failure) {
|
||||
@@ -86,10 +86,10 @@ async function submitAuth(
|
||||
);
|
||||
// The same native cookie helpers used by sveltekitCookies, without bypassing the router.
|
||||
for (const [name, attributes] of parseSetCookieHeader(response.headers.get('set-cookie') ?? '')) {
|
||||
event.cookies.set(name, attributes.value, {
|
||||
...toCookieOptions(attributes),
|
||||
path: attributes.path || '/'
|
||||
});
|
||||
const options = Object.fromEntries(
|
||||
Object.entries(toCookieOptions(attributes)).filter(([, value]) => value !== undefined)
|
||||
);
|
||||
event.cookies.set(name, attributes.value, { ...options, path: attributes.path || '/' });
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { betterAuth, type BetterAuthOptions } from 'better-auth/minimal';
|
||||
import { isAuthPath } from 'better-auth/svelte-kit';
|
||||
import { sequence } from '@sveltejs/kit/hooks';
|
||||
import type { Handle, RequestEvent } from '@sveltejs/kit';
|
||||
import { sequence, type Handle } from '@sveltejs/kit/hooks';
|
||||
import type { DirectoryAuth } from '@directory/config/types';
|
||||
import { logger } from '../logger.js';
|
||||
import { protectAuthRequest, requireSignIn } from './access.js';
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest';
|
||||
import { applySetCookies } from 'better-auth/cookies';
|
||||
import { PUBLIC_AUTHORITY_HEADER, PUBLIC_PROTOCOL_HEADER } from './runtime-environment.js';
|
||||
|
||||
const serverModule = new URL('../../../build/bootstrap/start.js', import.meta.url).href;
|
||||
const sentinel = 'PRIVATE_CONFIG_CREDENTIAL_LINK';
|
||||
@@ -38,8 +39,8 @@ async function launchServer(contents: string | undefined, environment: NodeJS.Pr
|
||||
const script = `
|
||||
import { once } from 'node:events';
|
||||
const { server } = await import(${JSON.stringify(serverModule)});
|
||||
if (!server.server.listening) await once(server.server, 'listening');
|
||||
process.send(server.server.address().port);
|
||||
if (!server.listening) await once(server, 'listening');
|
||||
process.send(server.address().port);
|
||||
`;
|
||||
const child = spawn(process.execPath, ['--input-type=module', '--eval', script], {
|
||||
cwd: temporary,
|
||||
@@ -202,7 +203,9 @@ it.each([
|
||||
host: 'foreign.example.com',
|
||||
'x-forwarded-host': 'foreign.example.com',
|
||||
'x-forwarded-proto': 'http',
|
||||
'x-forwarded-port': '80'
|
||||
'x-forwarded-port': '80',
|
||||
[PUBLIC_PROTOCOL_HEADER]: 'http',
|
||||
[PUBLIC_AUTHORITY_HEADER]: 'foreign.example.com:80'
|
||||
};
|
||||
const gated = await fetch(address, { redirect: 'manual', headers: transportHeaders });
|
||||
expect(gated.status).toBe(303);
|
||||
@@ -231,18 +234,26 @@ it.each([
|
||||
applySetCookies(headers, response.headers.getSetCookie());
|
||||
const authenticated = await fetch(address, { redirect: 'manual', headers });
|
||||
expect(authenticated.status).toBe(200);
|
||||
expect(await authenticated.text()).not.toContain(sentinel);
|
||||
const html = await authenticated.text();
|
||||
expect(html).toContain(`property="og:url" content="${origin}/"`);
|
||||
expect(html).not.toContain(sentinel);
|
||||
expect(server.output()).not.toContain(sentinel);
|
||||
expect(server.output()).not.toContain(server.path);
|
||||
expect(await server.stop()).toEqual({ code: 0, signal: null });
|
||||
});
|
||||
|
||||
it('keeps application environment references and fallbacks stable across bootstrap and reloads', async () => {
|
||||
const fallbackTitle = '${DIRECTORY_TITLE} ${MISSING_TITLE:-Fallback}';
|
||||
const title = 'Configured Fallback';
|
||||
const fallbackTitle =
|
||||
'${DIRECTORY_TITLE} ${MISSING_TITLE:-Fallback} ${ORIGIN} ${HOST_HEADER} ${PROTOCOL_HEADER} ${PORT_HEADER}';
|
||||
const title =
|
||||
'Configured Fallback http://localhost original-host original-protocol original-port';
|
||||
const server = await launchServer(source(fallbackTitle), {
|
||||
...hostileEnvironment,
|
||||
DIRECTORY_TITLE: 'Configured'
|
||||
DIRECTORY_TITLE: 'Configured',
|
||||
ORIGIN: 'http://localhost',
|
||||
HOST_HEADER: 'original-host',
|
||||
PROTOCOL_HEADER: 'original-protocol',
|
||||
PORT_HEADER: 'original-port'
|
||||
});
|
||||
const address = await server.ready();
|
||||
await expectPage(address, title);
|
||||
|
||||
@@ -12,7 +12,8 @@ const mocks = vi.hoisted(() => ({
|
||||
load: vi.fn<typeof import('@directory/config').loadConfigFile>(),
|
||||
monitor: vi.fn<typeof import('@directory/config').startConfigReloading>(),
|
||||
auth: vi.fn<typeof import('./auth/index.js').createAuthentication>(),
|
||||
stop: vi.fn<() => void>()
|
||||
stop: vi.fn<() => void>(),
|
||||
runtime: vi.fn<typeof import('./runtime-environment.js').getRuntimeEnvironment>()
|
||||
}));
|
||||
vi.mock('@directory/config', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@directory/config')>()),
|
||||
@@ -22,11 +23,11 @@ vi.mock('@directory/config', async (importOriginal) => ({
|
||||
startConfigReloading: mocks.monitor
|
||||
}));
|
||||
vi.mock('./auth/index.js', () => ({ createAuthentication: mocks.auth }));
|
||||
vi.mock('./runtime-environment.js', () => ({ getRuntimeEnvironment: mocks.runtime }));
|
||||
|
||||
const path = '/config/directory.yaml';
|
||||
const sentinel = 'PRIVATE-config-credential';
|
||||
const origin = 'https://directory.example.com';
|
||||
const frameworkEnv = { ORIGIN: origin };
|
||||
const auth = { credentials: { username: 'family', password: sentinel } };
|
||||
const authentication: Authentication = {
|
||||
strategy: 'credentials',
|
||||
@@ -41,7 +42,7 @@ function configuration(title = 'Home', enabled = false): DirectoryConfiguration
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.stubEnv('ORIGIN', origin);
|
||||
mocks.runtime.mockReturnValue({ env: process.env, origin });
|
||||
mocks.path.mockReturnValue(path);
|
||||
mocks.publicDir.mockReturnValue('/mounted/public');
|
||||
mocks.load.mockResolvedValue(configuration());
|
||||
@@ -56,7 +57,7 @@ describe('application boot wiring', () => {
|
||||
const pending = Promise.withResolvers<DirectoryConfiguration>();
|
||||
mocks.load.mockReturnValueOnce(pending.promise);
|
||||
const env = { CONFIG_PATH: path, DIRECTORY_PUBLIC_DIR: '/mounted/public' };
|
||||
const startup = bootApp(env);
|
||||
const startup = bootApp({ env, origin });
|
||||
expect(mocks.path).toHaveBeenCalledExactlyOnceWith({ env });
|
||||
expect(mocks.publicDir).toHaveBeenCalledExactlyOnceWith(path, { env });
|
||||
expect(mocks.load).toHaveBeenCalledExactlyOnceWith(path, { env });
|
||||
@@ -106,11 +107,11 @@ describe('application boot wiring', () => {
|
||||
});
|
||||
|
||||
it('constructs auth before monitoring from the prepared origin, not legacy auth settings', async () => {
|
||||
vi.stubEnv('ORIGIN', origin);
|
||||
vi.stubEnv('ORIGIN', 'https://not-kit.example.com');
|
||||
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);
|
||||
const app = await bootApp({ env, origin });
|
||||
expect(mocks.load).toHaveBeenCalledExactlyOnceWith(path, { env });
|
||||
expect(mocks.auth).toHaveBeenCalledExactlyOnceWith({ ...auth, origin });
|
||||
expect(mocks.auth.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
@@ -122,51 +123,30 @@ describe('application boot wiring', () => {
|
||||
describe.each([false, true])('canonical URL consistency (auth enabled: %s)', (enabled) => {
|
||||
it('boots with the prepared canonical URL regardless of auth state', async () => {
|
||||
mocks.load.mockResolvedValueOnce(configuration('Home', enabled));
|
||||
const app = await bootApp({}, frameworkEnv);
|
||||
const app = await bootApp({ env: {}, origin });
|
||||
expect(app.currentSnapshot().configuration.site.url).toBe(origin);
|
||||
expect(app.authentication).toBe(enabled ? authentication : undefined);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: 'process ORIGIN missing', prepared: undefined, privateEnv: frameworkEnv },
|
||||
{ name: 'process ORIGIN empty', prepared: '', privateEnv: frameworkEnv },
|
||||
{
|
||||
name: 'prepared process origin changed',
|
||||
prepared: 'https://other.example.com',
|
||||
privateEnv: frameworkEnv
|
||||
},
|
||||
{ name: 'Kit private URLs missing', prepared: origin, privateEnv: {} },
|
||||
{
|
||||
name: 'Kit private ORIGIN differs',
|
||||
prepared: origin,
|
||||
privateEnv: { ...frameworkEnv, ORIGIN: sentinel }
|
||||
},
|
||||
{
|
||||
name: 'site.url changed after preparation',
|
||||
prepared: origin,
|
||||
privateEnv: frameworkEnv,
|
||||
url: 'https://other.example.com'
|
||||
}
|
||||
])(
|
||||
'fails closed when $name before auth or monitoring',
|
||||
async ({ prepared, privateEnv, ...change }) => {
|
||||
vi.stubEnv('ORIGIN', prepared);
|
||||
const config = configuration('Home', enabled);
|
||||
if ('url' in change && change.url) config.site.url = change.url;
|
||||
mocks.load.mockResolvedValueOnce(config);
|
||||
await expect(bootApp({}, privateEnv)).rejects.toMatchObject({
|
||||
issue: { code: 'auth_failed' }
|
||||
});
|
||||
expect(mocks.auth).not.toHaveBeenCalled();
|
||||
expect(mocks.monitor).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
it('rejects a changed site.url before auth or monitoring', async () => {
|
||||
const config = configuration('Home', enabled);
|
||||
config.site.url = 'https://other.example.com';
|
||||
mocks.load.mockResolvedValueOnce(config);
|
||||
await expect(bootApp({ env: {}, origin })).rejects.toMatchObject({
|
||||
issue: { code: 'auth_failed' }
|
||||
});
|
||||
expect(mocks.auth).not.toHaveBeenCalled();
|
||||
expect(mocks.monitor).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the process environment when no environments are injected', async () => {
|
||||
it('uses the captured environment rather than the modified process environment', async () => {
|
||||
const env = { CONFIG_PATH: path, HOST_HEADER: 'original-host-header' };
|
||||
mocks.runtime.mockReturnValue({ env, origin });
|
||||
vi.stubEnv('HOST_HEADER', 'changed-by-adapter-preparation');
|
||||
await bootApp();
|
||||
expect(mocks.path).toHaveBeenCalledExactlyOnceWith({ env: process.env });
|
||||
expect(mocks.load).toHaveBeenCalledExactlyOnceWith(path, { env: process.env });
|
||||
expect(mocks.path).toHaveBeenCalledExactlyOnceWith({ env });
|
||||
expect(mocks.load).toHaveBeenCalledExactlyOnceWith(path, { env });
|
||||
});
|
||||
|
||||
it('retains safe loader diagnostics without starting monitoring', async () => {
|
||||
@@ -196,12 +176,11 @@ describe('application boot wiring', () => {
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
await expect(bootApp({}, frameworkEnv)).rejects.toMatchObject({
|
||||
await expect(bootApp({ env: {}, origin })).rejects.toMatchObject({
|
||||
issue: { code: 'auth_failed' }
|
||||
});
|
||||
expect(mocks.monitor).not.toHaveBeenCalled();
|
||||
|
||||
@@ -5,16 +5,16 @@ import {
|
||||
resolvePublicDir,
|
||||
startConfigReloading
|
||||
} from '@directory/config';
|
||||
import type { ConfigEnvironment, DirectoryConfiguration } from '@directory/config/types';
|
||||
import type { DirectoryConfiguration } from '@directory/config/types';
|
||||
import { createApplication, type Application } from './application.js';
|
||||
import { DirectoryError } from './directory-error.js';
|
||||
import { reportConfigError } from './logger.js';
|
||||
import { getRuntimeEnvironment, type RuntimeEnvironment } from './runtime-environment.js';
|
||||
|
||||
export async function bootApp(
|
||||
env: ConfigEnvironment = process.env,
|
||||
frameworkEnv: ConfigEnvironment = process.env
|
||||
runtime: RuntimeEnvironment = getRuntimeEnvironment()
|
||||
): Promise<Application> {
|
||||
const options = { env };
|
||||
const options = { env: runtime.env };
|
||||
let path: string;
|
||||
let publicDir: string;
|
||||
let config: DirectoryConfiguration;
|
||||
@@ -30,7 +30,7 @@ export async function bootApp(
|
||||
}
|
||||
|
||||
// Reject changes between framework URL preparation and the initial application load.
|
||||
if (config.site.url !== process.env.ORIGIN || config.site.url !== frameworkEnv.ORIGIN) {
|
||||
if (config.site.url !== runtime.origin) {
|
||||
throw new DirectoryError({ code: 'auth_failed' });
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('development lifecycle', () => {
|
||||
mocks.boot.mockResolvedValueOnce(ready);
|
||||
const off = vi.spyOn(process, 'off');
|
||||
await startDevelopment(server);
|
||||
expect(mocks.boot).toHaveBeenCalledExactlyOnceWith(undefined, undefined);
|
||||
expect(mocks.boot).toHaveBeenCalledExactlyOnceWith();
|
||||
expect(server.application).toBe(ready);
|
||||
expect(listeners.mock.calls.filter(([name]) => name === 'sveltekit:shutdown')).toEqual([]);
|
||||
const shutdown = devStopListener();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ConfigEnvironment } from '@directory/config/types';
|
||||
import { bootApp } from '../boot.js';
|
||||
import type { Application, ServerState } from '../application.js';
|
||||
|
||||
@@ -8,17 +7,14 @@ interface DevelopmentAttempt {
|
||||
stopped: boolean;
|
||||
}
|
||||
|
||||
export async function startDevelopment(
|
||||
server: ServerState,
|
||||
env?: ConfigEnvironment
|
||||
): Promise<void> {
|
||||
export async function startDevelopment(server: ServerState): Promise<void> {
|
||||
process.emit('directory:dev-stop');
|
||||
const attempt: DevelopmentAttempt = { server, application: undefined, stopped: false };
|
||||
const shutdown = () => stopDevelopment(attempt, shutdown);
|
||||
process.once('directory:dev-stop', shutdown);
|
||||
let application: Application;
|
||||
try {
|
||||
application = await bootApp(undefined, env);
|
||||
application = await bootApp();
|
||||
} catch (error) {
|
||||
stopDevelopment(attempt, shutdown);
|
||||
throw error;
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('production lifecycle', () => {
|
||||
const ready = application('Production');
|
||||
mocks.boot.mockResolvedValueOnce(ready);
|
||||
await startProduction(server);
|
||||
expect(mocks.boot).toHaveBeenCalledExactlyOnceWith(undefined, undefined);
|
||||
expect(mocks.boot).toHaveBeenCalledExactlyOnceWith();
|
||||
expect(server.application).toBe(ready);
|
||||
expect(listeners.mock.calls.filter(([name]) => name === 'directory:dev-stop')).toEqual([]);
|
||||
const shutdown = listeners.mock.calls.find(([name]) => name === 'sveltekit:shutdown')![1];
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { ConfigEnvironment } from '@directory/config/types';
|
||||
import { bootApp } from '../boot.js';
|
||||
import type { Application, ServerState } from '../application.js';
|
||||
|
||||
export async function startProduction(server: ServerState, env?: ConfigEnvironment): Promise<void> {
|
||||
const application = await bootApp(undefined, env);
|
||||
export async function startProduction(server: ServerState): Promise<void> {
|
||||
const application = await bootApp();
|
||||
// adapter-node emits shutdown after listening, which waits for Kit init.
|
||||
process.once('sveltekit:shutdown', () => stopProduction(server, application));
|
||||
server.application = application;
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { cp, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { cp, mkdir, mkdtemp, readdir, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import {
|
||||
createServer as createHttpServer,
|
||||
type IncomingMessage,
|
||||
type ServerResponse
|
||||
} from 'node:http';
|
||||
import { createServer } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DirectoryError } from './directory-error.js';
|
||||
import { prepareRuntimeEnvironment } from './runtime-environment.js';
|
||||
import {
|
||||
installRequestOrigin,
|
||||
prepareAdapterEnvironment,
|
||||
prepareRuntimeEnvironment,
|
||||
PUBLIC_AUTHORITY_HEADER,
|
||||
PUBLIC_PROTOCOL_HEADER
|
||||
} from './runtime-environment.js';
|
||||
|
||||
const origin = 'https://directory.example.com';
|
||||
const sentinel = 'PRIVATE-runtime-setting';
|
||||
let directory: string;
|
||||
let path: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'directory-runtime-'));
|
||||
path = join(directory, 'directory.yaml');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function credentials(extra = ''): string {
|
||||
return `links: {}
|
||||
site:
|
||||
@@ -33,8 +35,36 @@ auth:
|
||||
${extra}`;
|
||||
}
|
||||
|
||||
describe('framework environment preparation', () => {
|
||||
it('captures the final Vite CLI port before Kit snapshots the stale framework origin', async () => {
|
||||
describe('runtime environment preparation', () => {
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'directory-runtime-'));
|
||||
path = join(directory, 'directory.yaml');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('loads tooling configuration without requiring operator YAML', () => {
|
||||
const web = fileURLToPath(new URL('../../../', import.meta.url));
|
||||
const script = `
|
||||
import { loadConfig } from ${JSON.stringify(import.meta.resolve('@sveltejs/load-config'))};
|
||||
const result = await loadConfig(${JSON.stringify(web)}, { traverse: false });
|
||||
if (!result) throw new Error('Svelte configuration not found');
|
||||
if ('error' in result) throw result.error;
|
||||
`;
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, CONFIG_PATH: path };
|
||||
delete env.VITEST;
|
||||
const result = spawnSync(process.execPath, ['--input-type=module', '--eval', script], {
|
||||
cwd: web,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
timeout: 20_000
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).toBe(0);
|
||||
}, 30_000);
|
||||
|
||||
it('uses the final Vite CLI port for request URLs despite stale environment settings', async () => {
|
||||
const web = fileURLToPath(new URL('../../../', import.meta.url));
|
||||
const vite = join(web, 'node_modules/vite/bin/vite.js');
|
||||
const host = '127.0.0.1';
|
||||
@@ -50,12 +80,16 @@ describe('framework environment preparation', () => {
|
||||
if (address === null || typeof address === 'string') throw new Error('Expected a TCP port');
|
||||
const port = String(address.port);
|
||||
|
||||
// Run the real Vite/Kit lifecycle without writing generated files into the working app.
|
||||
await mkdir(join(directory, 'src/routes'), { recursive: true });
|
||||
for (const file of ['src/app.html', 'svelte.config.js', 'tsconfig.json']) {
|
||||
for (const file of ['src/app.html', 'tsconfig.json']) {
|
||||
await cp(join(web, file), join(directory, file));
|
||||
}
|
||||
await symlink(join(web, 'node_modules'), join(directory, 'node_modules'), 'dir');
|
||||
// Kit 3 must generate this fixture's own node_modules/$app, not overwrite the app's.
|
||||
await mkdir(join(directory, 'node_modules'));
|
||||
for (const name of await readdir(join(web, 'node_modules'))) {
|
||||
if (name.startsWith('.') || name === '$app') continue;
|
||||
await symlink(join(web, 'node_modules', name), join(directory, 'node_modules', name), 'dir');
|
||||
}
|
||||
await writeFile(join(directory, 'package.json'), '{"type":"module"}');
|
||||
await writeFile(
|
||||
join(directory, 'vite.config.mjs'),
|
||||
@@ -63,18 +97,21 @@ describe('framework environment preparation', () => {
|
||||
export default (env) => {
|
||||
const result = config(env);
|
||||
return { ...result, cacheDir: ${JSON.stringify(join(directory, '.vite'))},
|
||||
server: { ...result.server, fs: { allow: ${JSON.stringify([directory, web])} } } };
|
||||
server: { ...result.server, fs: { allow: ${JSON.stringify([directory, join(web, '../..')])} } } };
|
||||
};`
|
||||
);
|
||||
await writeFile(path, 'links: {}');
|
||||
await writeFile(
|
||||
join(directory, 'src/hooks.server.ts'),
|
||||
`export { init, handle } from ${JSON.stringify(join(web, 'src/hooks.server.ts'))};`
|
||||
);
|
||||
await writeFile(
|
||||
join(directory, '.env'),
|
||||
'PORT=2\nORIGIN=https://stale.example.com\nAUTH_URL=https://stale.example.com\n'
|
||||
);
|
||||
await writeFile(
|
||||
join(directory, 'src/routes/+page.server.js'),
|
||||
`import { env } from '$env/dynamic/private';
|
||||
export const load = () => ({ settings: env.ORIGIN + '|' + process.env.PORT });`
|
||||
`export const load = ({ url }) => ({ settings: url.origin + '|' + process.env.PORT });`
|
||||
);
|
||||
await writeFile(
|
||||
join(directory, 'src/routes/+page.svelte'),
|
||||
@@ -88,7 +125,13 @@ export const load = () => ({ settings: env.ORIGIN + '|' + process.env.PORT });`
|
||||
[vite, '--host', host, '--configLoader', 'runner', '--port', port],
|
||||
{
|
||||
cwd: directory,
|
||||
env: { ...env, NODE_ENV: 'development', PORT: '1', CONFIG_PATH: path },
|
||||
env: {
|
||||
...env,
|
||||
NODE_ENV: 'development',
|
||||
PORT: '1',
|
||||
CONFIG_PATH: path,
|
||||
DIRECTORY_PUBLIC_DIR: join(directory, 'public')
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}
|
||||
);
|
||||
@@ -99,13 +142,16 @@ export const load = () => ({ settings: env.ORIGIN + '|' + process.env.PORT });`
|
||||
child.once('error', (error) => (failure = error));
|
||||
const closed = new Promise<void>((resolve) => child.once('close', () => resolve()));
|
||||
try {
|
||||
// Probe HTTP, not Vite's console URL (which changes with CI and ANSI colors).
|
||||
const html = await vi.waitFor(
|
||||
async () => {
|
||||
expect(failure, output).toBeUndefined();
|
||||
expect(child.exitCode, output).toBeNull();
|
||||
const response = await fetch(`http://${host}:${port}/`, {
|
||||
headers: { connection: 'close' },
|
||||
headers: {
|
||||
connection: 'close',
|
||||
[PUBLIC_PROTOCOL_HEADER]: 'https',
|
||||
[PUBLIC_AUTHORITY_HEADER]: 'foreign.example.com'
|
||||
},
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
const html = await response.text();
|
||||
@@ -126,7 +172,7 @@ export const load = () => ({ settings: env.ORIGIN + '|' + process.env.PORT });`
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it('uses site.url instead of legacy framework settings', async () => {
|
||||
it('uses YAML site.url without treating legacy framework settings as configuration', async () => {
|
||||
await writeFile(path, credentials());
|
||||
const env = {
|
||||
CONFIG_PATH: path,
|
||||
@@ -137,8 +183,10 @@ export const load = () => ({ settings: env.ORIGIN + '|' + process.env.PORT });`
|
||||
AUTH_SECRET: sentinel
|
||||
};
|
||||
const before = { ...env };
|
||||
await prepareRuntimeEnvironment(env);
|
||||
expect(env).toEqual({ ...before, ORIGIN: origin });
|
||||
const runtime = await prepareRuntimeEnvironment(env);
|
||||
expect(runtime.origin).toBe(origin);
|
||||
expect(runtime.env).toEqual(before);
|
||||
expect(env).toEqual(before);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -146,63 +194,55 @@ export const load = () => ({ settings: env.ORIGIN + '|' + process.env.PORT });`
|
||||
{ settings: { NODE_ENV: 'development' }, expected: 'http://localhost:5173' },
|
||||
{ settings: { NODE_ENV: 'production', PORT: '8080' }, expected: 'http://localhost:8080' }
|
||||
])(
|
||||
'sets canonical framework URLs for an open directory: $expected',
|
||||
'prepares the default origin for an open directory: $expected',
|
||||
async ({ settings, expected }) => {
|
||||
await writeFile(path, 'links: {}');
|
||||
const env = {
|
||||
CONFIG_PATH: path,
|
||||
ORIGIN: sentinel,
|
||||
AUTH_URL: sentinel,
|
||||
NEXTAUTH_URL: sentinel,
|
||||
AUTH_REDIRECT_PROXY_URL: sentinel,
|
||||
...settings
|
||||
};
|
||||
await prepareRuntimeEnvironment(env);
|
||||
expect(env.ORIGIN).toBe(expected);
|
||||
expect(env.AUTH_URL).toBe(sentinel);
|
||||
const runtime = await prepareRuntimeEnvironment({ CONFIG_PATH: path, ...settings });
|
||||
expect(runtime.origin).toBe(expected);
|
||||
}
|
||||
);
|
||||
|
||||
it('uses an explicit site.url even with auth disabled and an ephemeral listen port', async () => {
|
||||
await writeFile(path, `site:\n url: '${origin}'\nlinks: {}`);
|
||||
const env = { CONFIG_PATH: path, PORT: '0', ORIGIN: sentinel, AUTH_URL: sentinel };
|
||||
await prepareRuntimeEnvironment(env);
|
||||
expect(env.ORIGIN).toBe(origin);
|
||||
expect(env.AUTH_URL).toBe(sentinel);
|
||||
});
|
||||
|
||||
it('preserves explicit legacy-named environment interpolation in YAML', async () => {
|
||||
it('keeps interpolation values captured before transport settings are changed', async () => {
|
||||
await writeFile(path, credentials().replace(origin, '${ORIGIN}'));
|
||||
const env = { CONFIG_PATH: path, ORIGIN: origin, AUTH_URL: sentinel };
|
||||
await prepareRuntimeEnvironment(env);
|
||||
expect(env.ORIGIN).toBe(origin);
|
||||
expect(env.AUTH_URL).toBe(sentinel);
|
||||
const env = {
|
||||
CONFIG_PATH: path,
|
||||
PORT: '0',
|
||||
ORIGIN: origin,
|
||||
HOST_HEADER: 'original-host',
|
||||
PROTOCOL_HEADER: 'original-protocol',
|
||||
PORT_HEADER: 'original-port',
|
||||
AUTH_SECRET: sentinel
|
||||
};
|
||||
const before = { ...env };
|
||||
const runtime = await prepareRuntimeEnvironment(env);
|
||||
prepareAdapterEnvironment(env);
|
||||
expect(runtime.origin).toBe(origin);
|
||||
expect(runtime.env).toEqual(before);
|
||||
expect(env.ORIGIN).toBeUndefined();
|
||||
expect(env).toMatchObject({
|
||||
HOST_HEADER: PUBLIC_AUTHORITY_HEADER,
|
||||
PROTOCOL_HEADER: PUBLIC_PROTOCOL_HEADER,
|
||||
PORT_HEADER: '',
|
||||
AUTH_SECRET: sentinel
|
||||
});
|
||||
});
|
||||
|
||||
it.each([{ PORT: '0' }, { SOCKET_PATH: '/tmp/directory.sock' }, { LISTEN_FDS: '1' }])(
|
||||
'fails safely without site.url for a nonstandard listener: %j',
|
||||
async (settings) => {
|
||||
await writeFile(path, 'links: {}');
|
||||
const env = {
|
||||
CONFIG_PATH: path,
|
||||
ORIGIN: sentinel,
|
||||
AUTH_URL: sentinel,
|
||||
NEXTAUTH_URL: sentinel,
|
||||
AUTH_REDIRECT_PROXY_URL: sentinel,
|
||||
...settings
|
||||
};
|
||||
const before = { ...env };
|
||||
await expect(prepareRuntimeEnvironment(env)).rejects.toMatchObject({
|
||||
await expect(
|
||||
prepareRuntimeEnvironment({ CONFIG_PATH: path, ...settings })
|
||||
).rejects.toMatchObject({
|
||||
issue: {
|
||||
code: 'load_failed',
|
||||
issues: [expect.objectContaining({ code: 'invalid_value', path: '/site/url' })]
|
||||
}
|
||||
});
|
||||
expect(env).toEqual(before);
|
||||
}
|
||||
);
|
||||
|
||||
it('fails safely before changing the environment for invalid configuration', async () => {
|
||||
it('redacts invalid configuration without changing the caller environment', async () => {
|
||||
await writeFile(path, credentials(` secret: '${sentinel}'\n`));
|
||||
const env = { CONFIG_PATH: path, ORIGIN: sentinel, AUTH_URL: sentinel };
|
||||
const before = { ...env };
|
||||
@@ -221,3 +261,38 @@ export const load = () => ({ settings: env.ORIGIN + '|' + process.env.PORT });`
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
origin: 'https://directory.example.com:8443',
|
||||
protocol: 'https',
|
||||
authority: 'directory.example.com:8443'
|
||||
},
|
||||
{ origin: 'http://[::1]:8080', protocol: 'http', authority: '[::1]:8080' }
|
||||
])(
|
||||
'overwrites private headers before the adapter sees $origin, without rewriting browser Origin',
|
||||
({ origin, protocol, authority }) => {
|
||||
let received: IncomingMessage['headers'] | undefined;
|
||||
const server = createHttpServer((request) => {
|
||||
received = { ...request.headers };
|
||||
});
|
||||
installRequestOrigin(server, origin);
|
||||
const request = {
|
||||
headers: {
|
||||
host: 'foreign.example.com',
|
||||
origin: 'https://foreign.example.com',
|
||||
'x-forwarded-host': 'foreign.example.com',
|
||||
[PUBLIC_PROTOCOL_HEADER]: 'https',
|
||||
[PUBLIC_AUTHORITY_HEADER]: 'foreign.example.com'
|
||||
}
|
||||
} as unknown as IncomingMessage;
|
||||
server.emit('request', request, {} as ServerResponse);
|
||||
expect(received).toEqual({
|
||||
host: 'foreign.example.com',
|
||||
origin: 'https://foreign.example.com',
|
||||
'x-forwarded-host': 'foreign.example.com',
|
||||
[PUBLIC_PROTOCOL_HEADER]: protocol,
|
||||
[PUBLIC_AUTHORITY_HEADER]: authority
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,12 +1,29 @@
|
||||
import { ConfigError, loadConfigFile } from '@directory/config';
|
||||
import type { ConfigEnvironment } from '@directory/config/types';
|
||||
import type { Server } from 'node:http';
|
||||
import { DirectoryError } from './directory-error.js';
|
||||
|
||||
export const PUBLIC_PROTOCOL_HEADER = 'x-directory-public-protocol';
|
||||
export const PUBLIC_AUTHORITY_HEADER = 'x-directory-public-authority';
|
||||
|
||||
export interface RuntimeEnvironment {
|
||||
readonly env: ConfigEnvironment;
|
||||
readonly origin: string;
|
||||
}
|
||||
|
||||
// The standalone bootstrap and Kit's bundled hooks need the same captured values.
|
||||
const runtimeKey = Symbol.for('directory.runtime-environment');
|
||||
const runtimeState = globalThis as typeof globalThis & {
|
||||
[runtimeKey]?: RuntimeEnvironment;
|
||||
};
|
||||
|
||||
export async function prepareRuntimeEnvironment(
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): Promise<void> {
|
||||
env: ConfigEnvironment = process.env
|
||||
): Promise<RuntimeEnvironment> {
|
||||
const captured = Object.freeze({ ...env });
|
||||
let origin: string;
|
||||
try {
|
||||
const config = await loadConfigFile(undefined, { env });
|
||||
const config = await loadConfigFile(undefined, { env: captured });
|
||||
origin = config.site.url;
|
||||
} catch (error) {
|
||||
throw new DirectoryError({
|
||||
@@ -15,6 +32,29 @@ export async function prepareRuntimeEnvironment(
|
||||
});
|
||||
}
|
||||
|
||||
// adapter-node 5 still captures ORIGIN before Kit starts; authentication uses site.url directly.
|
||||
env.ORIGIN = origin;
|
||||
const runtime = Object.freeze({ env: captured, origin });
|
||||
runtimeState[runtimeKey] = runtime;
|
||||
return runtime;
|
||||
}
|
||||
|
||||
export function getRuntimeEnvironment(): RuntimeEnvironment {
|
||||
const runtime = runtimeState[runtimeKey];
|
||||
if (!runtime) throw new DirectoryError({ code: 'prepare_failed' });
|
||||
return runtime;
|
||||
}
|
||||
|
||||
export function prepareAdapterEnvironment(env: NodeJS.ProcessEnv = process.env): void {
|
||||
delete env.ORIGIN;
|
||||
env.PROTOCOL_HEADER = PUBLIC_PROTOCOL_HEADER;
|
||||
env.HOST_HEADER = PUBLIC_AUTHORITY_HEADER;
|
||||
env.PORT_HEADER = '';
|
||||
}
|
||||
|
||||
export function installRequestOrigin(server: Server, origin: string): void {
|
||||
const url = new URL(origin);
|
||||
const protocol = url.protocol.slice(0, -1);
|
||||
server.prependListener('request', (request) => {
|
||||
request.headers[PUBLIC_PROTOCOL_HEADER] = protocol;
|
||||
request.headers[PUBLIC_AUTHORITY_HEADER] = url.host;
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
|
||||
export const GET = (({ locals }) =>
|
||||
json(
|
||||
Response.json(
|
||||
{ snapshotId: locals.snapshot.display.snapshotId },
|
||||
{ headers: { 'cache-control': 'private, no-store' } }
|
||||
)) satisfies RequestHandler;
|
||||
@@ -2,24 +2,27 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import IconSignOut from 'virtual:icons/ph/sign-out';
|
||||
import { beforeNavigate, pushState, replaceState } from '$app/navigation';
|
||||
import { beforeNavigate, goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import BookmarkLogo from '$lib/components/bookmarks/BookmarkLogo.svelte';
|
||||
import BookmarkDetails from '$lib/components/bookmarks/BookmarkDetails.svelte';
|
||||
import BookmarkDetailsDialog from '$lib/components/bookmarks/BookmarkDetailsDialog.svelte';
|
||||
import Logo from '$lib/components/Logo.svelte';
|
||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
import BookmarkSearch from '$lib/components/search/BookmarkSearch.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import { nextResultIndex } from '$lib/search/result-navigation.js';
|
||||
import type { DisplayLink } from '$lib/display/types.js';
|
||||
import type { SearchResult } from '$lib/search/engine.js';
|
||||
import BookmarkLogo from '#lib/components/bookmarks/BookmarkLogo.svelte';
|
||||
import BookmarkDetails from '#lib/components/bookmarks/BookmarkDetails.svelte';
|
||||
import BookmarkDetailsDialog from '#lib/components/bookmarks/BookmarkDetailsDialog.svelte';
|
||||
import Logo from '#lib/components/Logo.svelte';
|
||||
import ThemeToggle from '#lib/components/ThemeToggle.svelte';
|
||||
import BookmarkSearch from '#lib/components/search/BookmarkSearch.svelte';
|
||||
import Button from '#lib/components/ui/Button.svelte';
|
||||
import { nextResultIndex } from '#lib/search/result-navigation.js';
|
||||
import type { DisplayLink } from '#lib/display/types.js';
|
||||
import type { SearchResult } from '#lib/search/engine.js';
|
||||
import type { PageData } from './$types.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// Kit shallow routing updates page.state, not page.url.
|
||||
const searchQuery = $derived(page.state.directoryQuery ?? page.url.searchParams.get('q') ?? '');
|
||||
const currentURL = $derived(page.shallow?.url ?? page.url);
|
||||
let pendingSearchQuery = $state<string>();
|
||||
let searchNavigation = 0;
|
||||
const searchQuery = $derived(pendingSearchQuery ?? currentURL.searchParams.get('q') ?? '');
|
||||
let searchResults = $state<SearchResult[] | null>(null);
|
||||
let searchStatus = $state<'idle' | 'loading' | 'ready' | 'failed'>('idle');
|
||||
let searchInput = $state<HTMLInputElement>();
|
||||
@@ -39,7 +42,7 @@
|
||||
const directoryURL = $derived.by(() => {
|
||||
const parameters = new SvelteURLSearchParams();
|
||||
if (searchQuery) parameters.set('q', searchQuery);
|
||||
return parameters.size ? `/?${parameters}` : '/';
|
||||
return parameters.size ? resolve(`?${parameters}`) : resolve('');
|
||||
});
|
||||
const selectedGroup = $derived(
|
||||
data.directory.groups.find((group) => group.links.some((link) => link.id === selectedId))
|
||||
@@ -61,18 +64,29 @@
|
||||
|
||||
function setSearchQuery(query: string) {
|
||||
if (query === searchQuery) return;
|
||||
// Function-bound inputs must update before asynchronous route resolution finishes.
|
||||
pendingSearchQuery = query;
|
||||
const navigation = ++searchNavigation;
|
||||
const parameters = new SvelteURLSearchParams();
|
||||
if (selectedId !== null) parameters.set('link', selectedId);
|
||||
if (query) parameters.set('q', query);
|
||||
const url = parameters.size ? `/?${parameters}` : '/';
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve -- Query URLs stay absolute in this root-only directory.
|
||||
replaceState(url, { ...page.state, directoryQuery: query });
|
||||
const url = parameters.size ? resolve(`?${parameters}`) : resolve('');
|
||||
void goto(url, { shallow: true, replace: true, state: page.state }).then(
|
||||
() => {
|
||||
if (navigation === searchNavigation) pendingSearchQuery = undefined;
|
||||
},
|
||||
() => {
|
||||
if (navigation !== searchNavigation) return;
|
||||
pendingSearchQuery = undefined;
|
||||
window.location.replace(url);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function detailURL(id: string) {
|
||||
const parameters = new SvelteURLSearchParams({ link: id });
|
||||
if (searchQuery) parameters.set('q', searchQuery);
|
||||
return `/?${parameters}`;
|
||||
return resolve(`?${parameters}`);
|
||||
}
|
||||
|
||||
function findBookmarkTile(id: string) {
|
||||
@@ -138,31 +152,51 @@
|
||||
returnTile = tile;
|
||||
pendingFilterQuery = undefined;
|
||||
isClosingDetails = false;
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve -- This absolute href comes from the real tile anchor.
|
||||
pushState(href, { ...page.state, directoryDetail: { id, fromGrid: true } });
|
||||
void goto(href, {
|
||||
shallow: true,
|
||||
state: { ...page.state, directoryDetail: { id, fromGrid: true } }
|
||||
}).catch(() => {
|
||||
window.location.href = href;
|
||||
});
|
||||
}
|
||||
|
||||
function navigateDetails(step: -1 | 1) {
|
||||
async function navigateDetails(step: -1 | 1): Promise<void> {
|
||||
if (isClosingDetails || detailIndex < 0) return;
|
||||
const id = detailOrder[detailIndex + step];
|
||||
if (!id) return;
|
||||
returnTile = findBookmarkTile(id);
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve -- Root-only URLs must stay absolute.
|
||||
replaceState(detailURL(id), {
|
||||
...page.state,
|
||||
directoryDetail: { id, fromGrid: page.state.directoryDetail?.fromGrid ?? false }
|
||||
return goto(detailURL(id), {
|
||||
shallow: true,
|
||||
replace: true,
|
||||
state: {
|
||||
...page.state,
|
||||
directoryDetail: { id, fromGrid: page.state.directoryDetail?.fromGrid ?? false }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function closeDetails() {
|
||||
async function closeDetails() {
|
||||
if (isClosingDetails) return;
|
||||
isClosingDetails = true;
|
||||
if (page.state.directoryDetail?.fromGrid) {
|
||||
history.back();
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve -- Root-only URLs must stay absolute.
|
||||
replaceState(directoryURL, { ...page.state, directoryDetail: null });
|
||||
try {
|
||||
await goto(directoryURL, {
|
||||
shallow: true,
|
||||
replace: true,
|
||||
state: { ...page.state, directoryDetail: null }
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
} finally {
|
||||
// A rejected or cancelled navigation leaves the current dialog available to retry.
|
||||
if (selectedLink) {
|
||||
isClosingDetails = false;
|
||||
pendingFilterQuery = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectDetailFilter(query: string) {
|
||||
@@ -196,7 +230,22 @@
|
||||
directoryHeading?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
beforeNavigate(() => {
|
||||
beforeNavigate((navigation) => {
|
||||
if (navigation.shallow && navigation.type === 'goto') return;
|
||||
|
||||
searchNavigation++;
|
||||
pendingSearchQuery = undefined;
|
||||
// Kit may reconstruct this Back traversal after a query followed by a reload.
|
||||
const returningFromGrid =
|
||||
navigation.shallow &&
|
||||
navigation.type === 'popstate' &&
|
||||
navigation.delta === -1 &&
|
||||
isClosingDetails &&
|
||||
page.state.directoryDetail?.fromGrid &&
|
||||
navigation.to?.route?.id === '/(protected)' &&
|
||||
navigation.to.url.href === new URL(directoryURL, currentURL.href).href;
|
||||
if (returningFromGrid) return;
|
||||
|
||||
isClosingDetails = false;
|
||||
pendingFilterQuery = undefined;
|
||||
returnTile = undefined;
|
||||
@@ -266,8 +315,8 @@
|
||||
{:else if selectedLink}
|
||||
<noscript>
|
||||
<section class="details" aria-labelledby="link-details-title">
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- Root-only URLs must stay absolute in SSR. -->
|
||||
<a class="back" href={directoryURL} data-sveltekit-preload-data="off">Back to directory</a>
|
||||
<a class="back" href={directoryURL} data-sveltekit-preload-data="false">Back to directory</a
|
||||
>
|
||||
<BookmarkDetails
|
||||
link={selectedLink}
|
||||
groupLabel={selectedGroup?.label}
|
||||
@@ -278,11 +327,10 @@
|
||||
{/if}
|
||||
|
||||
{#snippet tile(link: DisplayLink, groupLabel: string | undefined, filtered = false)}
|
||||
<!-- eslint-disable svelte/no-navigation-without-resolve -- Root-only URLs must stay absolute in SSR. -->
|
||||
<a
|
||||
class="tile"
|
||||
href={detailURL(link.id)}
|
||||
data-sveltekit-preload-data="off"
|
||||
data-sveltekit-preload-data="false"
|
||||
onkeydown={filtered ? navigateResults : undefined}
|
||||
onclick={(event) => openDetails(event, link.id)}
|
||||
>
|
||||
@@ -292,7 +340,6 @@
|
||||
<small class="group-caption">{groupLabel}</small>
|
||||
{/if}
|
||||
</a>
|
||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
||||
{/snippet}
|
||||
|
||||
<div class="search-feedback">
|
||||
|
||||
@@ -50,7 +50,10 @@ function eventFor(query = '', withAuth = false) {
|
||||
>[0];
|
||||
}
|
||||
|
||||
const notFound = expect.objectContaining({ status: 404, body: { message: 'Not found' } });
|
||||
const notFound = expect.objectContaining({
|
||||
status: 404,
|
||||
body: expect.objectContaining({ message: 'Not found' })
|
||||
});
|
||||
|
||||
describe('directory page data', () => {
|
||||
it('returns exactly the prepared display, selection and signed-in boolean in no-auth mode', () => {
|
||||
|
||||
@@ -80,11 +80,14 @@ describe('public favicon alias', () => {
|
||||
'/favicon?source=%252e%252e%252fsecret.svg',
|
||||
'/favicon/other.svg?favicon_path=/public/other.svg'
|
||||
]) {
|
||||
const request = event('GET');
|
||||
request.url = new URL(path, 'http://localhost');
|
||||
request.request = new Request(request.url, {
|
||||
headers: { 'x-public-dir': '/outside', range: 'bytes=0-1', 'if-none-match': '*' }
|
||||
});
|
||||
const url = new URL(path, 'http://localhost');
|
||||
const request = {
|
||||
...event('GET'),
|
||||
url,
|
||||
request: new Request(url, {
|
||||
headers: { 'x-public-dir': '/outside', range: 'bytes=0-1', 'if-none-match': '*' }
|
||||
})
|
||||
};
|
||||
expect((await GET(request)).status).toBe(200);
|
||||
}
|
||||
expect(readMountedImage).toHaveBeenCalledTimes(3);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
|
||||
export const GET = (() =>
|
||||
json({ status: 'ok' }, { headers: { 'cache-control': 'no-store' } })) satisfies RequestHandler;
|
||||
Response.json(
|
||||
{ status: 'ok' },
|
||||
{ headers: { 'cache-control': 'no-store' } }
|
||||
)) satisfies RequestHandler;
|
||||
@@ -166,21 +166,24 @@ describe('public generated Open Graph image', () => {
|
||||
'/images/og.png?favicon_path=/public/other.svg&og_image_path=/public/other.jpg&primary_light_color=%23fff&primary_dark_color=%23000',
|
||||
'/images/other.png?source=%252e%252e%252fsecret.svg&title=OTHER'
|
||||
]) {
|
||||
const request = event(method);
|
||||
request.url = new URL(path, 'http://attacker.example');
|
||||
request.request = new Request(request.url, {
|
||||
method,
|
||||
headers: {
|
||||
'x-public-dir': '/outside',
|
||||
'x-favicon-path': '/public/other.svg',
|
||||
'x-title': 'HEADER_TITLE',
|
||||
'x-primary-light-color': '#fff',
|
||||
'x-primary-dark-color': '#000',
|
||||
accept: 'image/jpeg',
|
||||
range: 'bytes=0-1',
|
||||
'if-none-match': '*'
|
||||
}
|
||||
});
|
||||
const url = new URL(path, 'http://attacker.example');
|
||||
const request = {
|
||||
...event(method),
|
||||
url,
|
||||
request: new Request(url, {
|
||||
method,
|
||||
headers: {
|
||||
'x-public-dir': '/outside',
|
||||
'x-favicon-path': '/public/other.svg',
|
||||
'x-title': 'HEADER_TITLE',
|
||||
'x-primary-light-color': '#fff',
|
||||
'x-primary-dark-color': '#000',
|
||||
accept: 'image/jpeg',
|
||||
range: 'bytes=0-1',
|
||||
'if-none-match': '*'
|
||||
}
|
||||
})
|
||||
};
|
||||
await expectPNG(await (method === 'HEAD' ? HEAD : GET)(request), method);
|
||||
}
|
||||
expect(vi.mocked(readMountedImage).mock.calls).toEqual(
|
||||
|
||||
@@ -80,11 +80,14 @@ describe('public selected-logo alias', () => {
|
||||
'/logo?source=%252e%252e%252fsecret.svg',
|
||||
'/logo/other.svg?logo_path=/public/other.svg'
|
||||
]) {
|
||||
const request = event('GET');
|
||||
request.url = new URL(path, 'http://localhost');
|
||||
request.request = new Request(request.url, {
|
||||
headers: { 'x-public-dir': '/outside', range: 'bytes=0-1', 'if-none-match': '*' }
|
||||
});
|
||||
const url = new URL(path, 'http://localhost');
|
||||
const request = {
|
||||
...event('GET'),
|
||||
url,
|
||||
request: new Request(url, {
|
||||
headers: { 'x-public-dir': '/outside', range: 'bytes=0-1', 'if-none-match': '*' }
|
||||
})
|
||||
};
|
||||
expect((await GET(request)).status).toBe(200);
|
||||
}
|
||||
expect(readMountedImage).toHaveBeenCalledTimes(3);
|
||||
|
||||
@@ -57,14 +57,17 @@ describe('public mounted image route', () => {
|
||||
);
|
||||
|
||||
it('ignores conditional and range headers', async () => {
|
||||
const request = event('icon.svg');
|
||||
request.request = new Request(request.request, {
|
||||
headers: {
|
||||
'if-none-match': '*',
|
||||
'if-modified-since': new Date().toUTCString(),
|
||||
range: 'bytes=0-2'
|
||||
}
|
||||
});
|
||||
const captured = event('icon.svg');
|
||||
const request = {
|
||||
...captured,
|
||||
request: new Request(captured.request, {
|
||||
headers: {
|
||||
'if-none-match': '*',
|
||||
'if-modified-since': new Date().toUTCString(),
|
||||
range: 'bytes=0-2'
|
||||
}
|
||||
})
|
||||
};
|
||||
const response = await GET(request);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe('<svg/>');
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import editorSchema from '@directory/config/schema.json' with { type: 'json' };
|
||||
|
||||
export function GET(): Response {
|
||||
return json(editorSchema, {
|
||||
return Response.json(editorSchema, {
|
||||
headers: {
|
||||
'content-type': 'application/schema+json; charset=utf-8',
|
||||
'cache-control': 'no-store'
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import { onMount } from 'svelte';
|
||||
import IconWarningCircle from 'virtual:icons/ph/warning-circle';
|
||||
import IconSignIn from 'virtual:icons/ph/sign-in';
|
||||
import Logo from '$lib/components/Logo.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import Logo from '#lib/components/Logo.svelte';
|
||||
import Button from '#lib/components/ui/Button.svelte';
|
||||
import Input from '#lib/components/ui/Input.svelte';
|
||||
import type { PageData } from './$types.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import ButtonLink from '$lib/components/ui/ButtonLink.svelte';
|
||||
import ButtonLink from '#lib/components/ui/ButtonLink.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import ConfigUpdateNotice from '$lib/components/ConfigUpdateNotice.svelte';
|
||||
import DocumentHead from '$lib/components/DocumentHead.svelte';
|
||||
import ConfigUpdateNotice from '#lib/components/ConfigUpdateNotice.svelte';
|
||||
import DocumentHead from '#lib/components/DocumentHead.svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
import '../lib/styles/reset.css';
|
||||
import '../lib/styles/theme.css';
|
||||
|
||||
+10
-6
@@ -1,13 +1,17 @@
|
||||
import { prepareRuntimeEnvironment } from './lib/server/runtime-environment.js';
|
||||
import {
|
||||
installRequestOrigin,
|
||||
prepareAdapterEnvironment,
|
||||
prepareRuntimeEnvironment
|
||||
} from './lib/server/runtime-environment.js';
|
||||
import { reportStartupFailure } from './lib/server/logger.js';
|
||||
|
||||
try {
|
||||
await prepareRuntimeEnvironment();
|
||||
} catch (error) {
|
||||
const runtime = await prepareRuntimeEnvironment().catch((error: unknown) => {
|
||||
reportStartupFailure(error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
prepareAdapterEnvironment();
|
||||
|
||||
// adapter-node captures ORIGIN during import, before its Kit init hook runs.
|
||||
const entry = '../index.js';
|
||||
export const { server } = await import(entry);
|
||||
// Adapter initialization finishes before listening; install before the next request turn.
|
||||
installRequestOrigin(server, runtime.origin);
|
||||
@@ -1,26 +0,0 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
const connectSources = ['self'];
|
||||
if (process.env.NODE_ENV === 'development') connectSources.push('ws:', 'wss:');
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
csp: {
|
||||
// OIDC form redirects cross origins; state-changing requests are checked server-side.
|
||||
directives: {
|
||||
'default-src': ['self'],
|
||||
'script-src': ['self'],
|
||||
'style-src': ['self', 'unsafe-inline'],
|
||||
'img-src': ['self'],
|
||||
'font-src': ['self'],
|
||||
'connect-src': connectSources,
|
||||
'object-src': ['none'],
|
||||
'base-uri': ['self'],
|
||||
'frame-ancestors': ['none']
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
+3
-29
@@ -1,37 +1,11 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"extends": "$app/tsconfig",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["node", "unplugin-icons/types/svelte"]
|
||||
"types": ["$app/types", "node", "unplugin-icons/types/svelte"]
|
||||
},
|
||||
// Keep the generated Kit includes in sync with .svelte-kit/tsconfig.json.
|
||||
"include": [
|
||||
".svelte-kit/ambient.d.ts",
|
||||
".svelte-kit/env.d.ts",
|
||||
".svelte-kit/non-ambient.d.ts",
|
||||
".svelte-kit/types/**/$types.d.ts",
|
||||
"vite.config.js",
|
||||
"vite.config.ts",
|
||||
"src/**/*.js",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.svelte",
|
||||
"test/**/*.js",
|
||||
"test/**/*.ts",
|
||||
"test/**/*.svelte",
|
||||
"tests/**/*.js",
|
||||
"tests/**/*.ts",
|
||||
"tests/**/*.svelte",
|
||||
"playwright.config.ts",
|
||||
"e2e/**/*.ts"
|
||||
]
|
||||
"include": ["src", "vite.config.ts", "vitest.config.ts", "playwright.config.ts", "e2e/**/*.ts"]
|
||||
}
|
||||
+52
-14
@@ -1,14 +1,36 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import type { Adapter } from '@sveltejs/kit';
|
||||
import { getRequest } from '@sveltejs/kit/node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import { prepareRuntimeEnvironment } from './src/lib/server/runtime-environment.js';
|
||||
import Icons from 'unplugin-icons/vite';
|
||||
|
||||
const connectSources: ('self' | 'ws:' | 'wss:')[] = ['self'];
|
||||
|
||||
if (process.env.NODE_ENV === 'development') connectSources.push('ws:', 'wss:');
|
||||
|
||||
function createAdapter(getOrigin: () => string | undefined): Adapter {
|
||||
const native = adapter();
|
||||
const nativeVite = native.vite;
|
||||
return {
|
||||
...native,
|
||||
vite: (context) => {
|
||||
const settings = typeof nativeVite === 'function' ? nativeVite(context) : nativeVite;
|
||||
return {
|
||||
...settings,
|
||||
getRequest: (options) => getRequest({ ...options, base: getOrigin() ?? options.base })
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig(({ command, mode, isPreview }) => {
|
||||
const development = command === 'serve' && !isPreview;
|
||||
if (development) {
|
||||
Object.assign(process.env, loadEnv(mode, process.cwd(), ''));
|
||||
}
|
||||
const port = Number(process.env.PORT ?? 5173);
|
||||
const environment = development ? loadEnv(mode, process.cwd(), '') : undefined;
|
||||
const port = Number(environment?.PORT ?? process.env.PORT ?? 5173);
|
||||
let origin: string | undefined;
|
||||
return {
|
||||
server: { port, strictPort: true },
|
||||
optimizeDeps: { include: ['fuse.js'] },
|
||||
@@ -16,18 +38,34 @@ export default defineConfig(({ command, mode, isPreview }) => {
|
||||
{
|
||||
name: 'directory-dev-environment',
|
||||
enforce: 'pre',
|
||||
config: {
|
||||
order: 'pre',
|
||||
async handler(config) {
|
||||
if (!development) return;
|
||||
// CLI overrides are merged before config hooks. Kit snapshots env in its own
|
||||
// config hook, so configResolved would leave $env/dynamic/private stale.
|
||||
process.env.PORT = String(config.server?.port ?? 5173);
|
||||
await prepareRuntimeEnvironment();
|
||||
}
|
||||
async configureServer(server) {
|
||||
if (!development) return;
|
||||
Object.assign(process.env, environment);
|
||||
// Config discovery also uses command=serve. Wait for a real dev server,
|
||||
// after CLI overrides, before reading operator YAML.
|
||||
process.env.PORT = String(server.config.server.port ?? 5173);
|
||||
origin = (await prepareRuntimeEnvironment()).origin;
|
||||
}
|
||||
},
|
||||
sveltekit(),
|
||||
|
||||
sveltekit({
|
||||
preprocess: vitePreprocess(),
|
||||
adapter: createAdapter(() => origin),
|
||||
csp: {
|
||||
// OIDC form redirects cross origins; state-changing requests are checked server-side.
|
||||
directives: {
|
||||
'default-src': ['self'],
|
||||
'script-src': ['self'],
|
||||
'style-src': ['self', 'unsafe-inline'],
|
||||
'img-src': ['self'],
|
||||
'font-src': ['self'],
|
||||
'connect-src': connectSources,
|
||||
'object-src': ['none'],
|
||||
'base-uri': ['self'],
|
||||
'frame-ancestors': ['none']
|
||||
}
|
||||
}
|
||||
}),
|
||||
Icons({ compiler: 'svelte' }),
|
||||
// Kit's SSR loader does not run import.meta.hot.dispose.
|
||||
{
|
||||
|
||||
+9
-1
@@ -4,7 +4,15 @@ import svelte from 'eslint-plugin-svelte';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import globals from 'globals';
|
||||
import ts from 'typescript-eslint';
|
||||
import svelteConfig from './apps/web/svelte.config.js';
|
||||
import { loadConfig } from '@sveltejs/load-config';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const loadedConfig = await loadConfig(fileURLToPath(new URL('./apps/web', import.meta.url)), {
|
||||
traverse: false
|
||||
});
|
||||
if (!loadedConfig) throw new Error('Svelte configuration not found');
|
||||
if ('error' in loadedConfig) throw loadedConfig.error;
|
||||
const svelteConfig = loadedConfig.config;
|
||||
|
||||
export default defineConfig(
|
||||
{
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "catalog:",
|
||||
"@eslint/js": "catalog:",
|
||||
"@sveltejs/load-config": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"eslint-config-prettier": "catalog:",
|
||||
"eslint-plugin-svelte": "catalog:",
|
||||
|
||||
Generated
+99
-507
File diff suppressed because it is too large.
Load diff
+5
-4
@@ -10,8 +10,9 @@ catalog:
|
||||
'@eslint/js': 10.0.1
|
||||
'@iconify-json/ph': 1.2.2
|
||||
'@playwright/test': 1.61.1
|
||||
'@sveltejs/adapter-node': 5.5.4
|
||||
'@sveltejs/kit': 2.70.1
|
||||
'@sveltejs/adapter-node': 6.0.1
|
||||
'@sveltejs/kit': 3.1.1
|
||||
'@sveltejs/load-config': 0.2.2
|
||||
'@sveltejs/vite-plugin-svelte': 7.1.2
|
||||
'@types/node': 24.12.4
|
||||
'@vitest/coverage-v8': 4.1.7
|
||||
@@ -24,8 +25,8 @@ catalog:
|
||||
globals: 17.6.0
|
||||
prettier: 3.8.3
|
||||
prettier-plugin-svelte: 3.5.2
|
||||
svelte: 5.56.0
|
||||
svelte-check: 4.4.8
|
||||
svelte: 5.57.3
|
||||
svelte-check: 4.7.7
|
||||
typescript: 6.0.3
|
||||
typescript-eslint: 8.60.0
|
||||
unplugin-icons: 23.0.1
|
||||
|
||||
Reference in new issue
Block a user