feat: upgrade to SvelteKit 3 and adapter-bun
Some checks failed
Test and deploy staging / staging (push) Failing after 43s

Run the sveltekit-3 migration and finish its manual tasks:

- Move kit config into the sveltekit() Vite plugin; drop svelte.config.js
- Replace $lib with #lib subpath imports
- Declare env vars in src/env.ts and import them from $app/env/{private,public}
- Consolidate param matchers into src/params.ts
- Use error(status, message), Response.json, refreshAll and goto reset/shallow
- Fall back to a full page load when goto rejects a login return path

Switch from adapter-node to adapter-bun: the build script runs Vite under
Bun (the adapter requires it) and the image starts with `bun ./build`.
Runtime env names (PORT, BODY_SIZE_LIMIT, ADDRESS_HEADER, ...) are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 22:17:30 +00:00
parent b52e9188f4
commit 54924b39f0
240 changed files with 894 additions and 944 deletions

View File

@@ -1,29 +1,37 @@
import { env } from '$env/dynamic/private';
import { building, dev } from '$app/environment';
import {
BETTER_AUTH_SECRET,
BETTER_AUTH_URL,
ORIGIN,
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET,
OSU_CLIENT_ID,
OSU_CLIENT_SECRET
} from '$app/env/private';
import { building, dev } from '$app/env';
import { betterAuth } from 'better-auth/minimal';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { genericOAuth } from 'better-auth/plugins';
import { APIError, createAuthMiddleware } from 'better-auth/api';
import { sveltekitCookies } from 'better-auth/svelte-kit';
import { getRequestEvent } from '$app/server';
import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
import { db } from '#lib/server/db/index.js';
import * as schema from '#lib/server/db/schema/index.js';
import { osuProfile } from './osu-profile';
export { isAdmin, requireAdmin, requireUser } from './authorization';
if (!building && (!env.BETTER_AUTH_SECRET || env.BETTER_AUTH_SECRET.length < 32)) {
if (!building && (!BETTER_AUTH_SECRET || BETTER_AUTH_SECRET.length < 32)) {
throw new Error('Set BETTER_AUTH_SECRET to a random secret of at least 32 characters');
}
if (!building && !dev && !env.BETTER_AUTH_URL && !env.ORIGIN) {
if (!building && !dev && !BETTER_AUTH_URL && !ORIGIN) {
throw new Error('Set BETTER_AUTH_URL to the public site origin');
}
export const authProviders = {
google: !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET),
osu: !!(env.OSU_CLIENT_ID && env.OSU_CLIENT_SECRET)
google: !!(GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET),
osu: !!(OSU_CLIENT_ID && OSU_CLIENT_SECRET)
};
export const auth = betterAuth({
baseURL: env.BETTER_AUTH_URL || env.ORIGIN || 'http://localhost:5173',
secret: env.BETTER_AUTH_SECRET || 'build-only-placeholder-not-a-runtime-secret',
baseURL: BETTER_AUTH_URL || ORIGIN || 'http://localhost:5173',
secret: BETTER_AUTH_SECRET || 'build-only-placeholder-not-a-runtime-secret',
database: drizzleAdapter(db, { provider: 'pg', schema }),
emailAndPassword: { enabled: true, minPasswordLength: 12, maxPasswordLength: 128 },
user: {
@@ -42,7 +50,7 @@ export const auth = betterAuth({
rateLimit: { enabled: true },
socialProviders: authProviders.google
? {
google: { clientId: env.GOOGLE_CLIENT_ID!, clientSecret: env.GOOGLE_CLIENT_SECRET! }
google: { clientId: GOOGLE_CLIENT_ID!, clientSecret: GOOGLE_CLIENT_SECRET! }
}
: {},
hooks: {
@@ -63,8 +71,8 @@ export const auth = betterAuth({
? [
{
providerId: 'osu',
clientId: env.OSU_CLIENT_ID!,
clientSecret: env.OSU_CLIENT_SECRET!,
clientId: OSU_CLIENT_ID!,
clientSecret: OSU_CLIENT_SECRET!,
authorizationUrl: 'https://osu.ppy.sh/oauth/authorize',
tokenUrl: 'https://osu.ppy.sh/oauth/token',
scopes: ['identify'],

View File

@@ -1,9 +1,9 @@
import { env } from '$env/dynamic/private';
import { ANTHROPIC_API_KEY } from '$app/env/private';
import { createAnthropic } from '@ai-sdk/anthropic';
import { generateText } from 'ai';
const anthropic = createAnthropic({
apiKey: env.ANTHROPIC_API_KEY
apiKey: ANTHROPIC_API_KEY
});
export interface BreedDetectionResult {

View File

@@ -1,9 +1,9 @@
import { env } from '$env/dynamic/private';
import { DB_URL } from '$app/env/private';
import { relations } from './relations';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
const client = postgres(env.DB_URL, { prepare: false });
const client = postgres(DB_URL, { prepare: false });
export const db = drizzle({
relations,

View File

@@ -1,5 +1,5 @@
// One file per domain. Everything is re-exported here so the rest of the app
// (and drizzle-kit / relations) only ever imports `$lib/server/db/schema`.
// (and drizzle-kit / relations) only ever imports `#lib/server/db/schema/index.js`.
export * from './auth';
export * from './blog';
export * from './progress';

View File

@@ -20,10 +20,12 @@ export function logApiError(error: unknown) {
: { type: 'unexpected' }
);
}
// Only the call signature: Bun's global fetch type also carries `preconnect`.
export type Fetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const cooldowns = new Map<string, number>();
export async function requestJson(
fetchFn: typeof fetch,
fetchFn: Fetch,
provider: string,
url: string,
init: RequestInit = {},

View File

@@ -1,7 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('$env/dynamic/private', () => ({
env: { SPOTIFY_CLIENT_ID: 'test', SPOTIFY_CLIENT_SECRET: 'test' }
}));
vi.mock('$app/env/private', () => ({ SPOTIFY_CLIENT_ID: 'test', SPOTIFY_CLIENT_SECRET: 'test' }));
import { createTokenCache, mapConcurrent, requestJson } from './finder-http';
import { createRateLimiter, RATE_WINDOW_MS } from './finder-limit';
import { getSpotifyMetadata, playlistTracks, MAX_PLAYLIST_TRACKS } from './spotify';
@@ -127,7 +125,7 @@ describe('playlists', () => {
}
});
});
const result = await getSpotifyMetadata(fetchFn as typeof fetch, {
const result = await getSpotifyMetadata(fetchFn, {
kind: 'playlist',
id: id(1)
});
@@ -158,7 +156,7 @@ it('paginates album tracks and attaches shared album metadata', async () => {
tracks: { items: [track(1)], next: 'next', total: 2 }
})
);
const result = await getSpotifyMetadata(fetchFn as typeof fetch, { kind: 'album', id: id(1) });
const result = await getSpotifyMetadata(fetchFn, { kind: 'album', id: id(1) });
expect(result.collection).toMatchObject({ kind: 'album', name: 'Album name', owner: 'Artist' });
expect(result.tracks.map((t) => t.id)).toEqual([id(1), id(2)]);
expect(result.tracks[1].album.name).toBe('Album name');

View File

@@ -1,34 +1,39 @@
// House tracker ingest: the home worker POSTs what it scraped, this stores it
// and sends alerts. Nothing here talks to the listing sites.
import { timingSafeEqual } from 'node:crypto';
import { env } from '$env/dynamic/private';
import {
HOUSE_WORKER_TOKEN,
HOUSE_DISCORD_WEBHOOK_URL,
HOUSE_NTFY_URL,
HOUSE_NTFY_TOKEN
} from '$app/env/private';
import { error } from '@sveltejs/kit';
import { and, desc, eq, inArray, isNull, lt, sql } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { db } from '#lib/server/db/index.js';
import {
houseAlertRules,
houseListings,
housePriceHistory,
houseSearches,
houseWorkerRuns
} from '$lib/server/db/schema';
} from '#lib/server/db/schema/index.js';
import {
alertMessage,
matchesRule,
type AlertMessage,
type AlertRule
} from '$lib/house-tracker/alerts';
import { listingRef } from '$lib/house-tracker/pararius';
} from '#lib/house-tracker/alerts.js';
import { listingRef } from '#lib/house-tracker/pararius.js';
import type {
DetailsRequest,
ListingCard,
ReportRequest,
SyncRequest,
SyncResponse
} from '$lib/house-tracker/types';
} from '#lib/house-tracker/types.js';
export function requireWorker(request: Request) {
const expected = env.HOUSE_WORKER_TOKEN ?? '';
const expected = HOUSE_WORKER_TOKEN ?? '';
if (expected.length < 32) error(503, 'HOUSE_WORKER_TOKEN is not configured');
const given = (request.headers.get('authorization') ?? '').replace(/^Bearer\s+/i, '');
const a = Buffer.from(given);
@@ -307,9 +312,9 @@ export async function recordReport(input: ReportRequest) {
/** Discord webhook and/or ntfy. Returns true when at least one channel accepted it. */
export async function sendAlert(message: AlertMessage): Promise<boolean> {
const jobs: Promise<boolean>[] = [];
if (env.HOUSE_DISCORD_WEBHOOK_URL) {
if (HOUSE_DISCORD_WEBHOOK_URL) {
jobs.push(
fetch(env.HOUSE_DISCORD_WEBHOOK_URL, {
fetch(HOUSE_DISCORD_WEBHOOK_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
@@ -330,15 +335,15 @@ export async function sendAlert(message: AlertMessage): Promise<boolean> {
)
);
}
if (env.HOUSE_NTFY_URL) {
const url = new URL(env.HOUSE_NTFY_URL);
if (HOUSE_NTFY_URL) {
const url = new URL(HOUSE_NTFY_URL);
const topic = url.pathname.replace(/^\/+|\/+$/g, '');
jobs.push(
fetch(url.origin, {
method: 'POST',
headers: {
'content-type': 'application/json',
...(env.HOUSE_NTFY_TOKEN ? { authorization: `Bearer ${env.HOUSE_NTFY_TOKEN}` } : {})
...(HOUSE_NTFY_TOKEN ? { authorization: `Bearer ${HOUSE_NTFY_TOKEN}` } : {})
},
body: JSON.stringify({
topic,

View File

@@ -1,5 +1,5 @@
import { expect, it, vi } from 'vitest';
vi.mock('$env/dynamic/private', () => ({ env: { OSU_CLIENT_ID: '1', OSU_CLIENT_SECRET: 'test' } }));
vi.mock('$app/env/private', () => ({ OSU_CLIENT_ID: '1', OSU_CLIENT_SECRET: 'test' }));
import { getOsuMaps } from './osu';
it('falls back deliberately, merges IDs, reuses OAuth and caches matching results', async () => {
const fetchFn = vi.fn(async (url: string | URL | Request) => {
@@ -22,14 +22,11 @@ it('falls back deliberately, merges IDs, reuses OAuth and caches matching result
artists: [{ name: 'Artist' }],
album: { name: '', images: [] }
};
const results = await Promise.all([
getOsuMaps(fetchFn as typeof fetch, track),
getOsuMaps(fetchFn as typeof fetch, track)
]);
const results = await Promise.all([getOsuMaps(fetchFn, track), getOsuMaps(fetchFn, track)]);
expect(results[0]).toHaveLength(1);
expect(results[0][0].score).toBe(100);
expect(results[1]).toEqual(results[0]);
await getOsuMaps(fetchFn as typeof fetch, track);
await getOsuMaps(fetchFn, track);
expect(fetchFn).toHaveBeenCalledTimes(3);
expect(fetchFn.mock.calls.filter(([url]) => String(url).includes('/oauth/token'))).toHaveLength(
1

View File

@@ -1,7 +1,12 @@
import { env } from '$env/dynamic/private';
import type { OsuBeatmapset, ScoredBeatmapset, SpotifyTrack } from '$lib/map-finder/types';
import { cleanTitle, normalize, rankCandidates, MEDIUM_CONFIDENCE } from '$lib/map-finder/matching';
import { ApiError, createTokenCache, requestJson } from './finder-http';
import { OSU_CLIENT_ID, OSU_CLIENT_SECRET } from '$app/env/private';
import type { OsuBeatmapset, ScoredBeatmapset, SpotifyTrack } from '#lib/map-finder/types.js';
import {
cleanTitle,
normalize,
rankCandidates,
MEDIUM_CONFIDENCE
} from '#lib/map-finder/matching.js';
import { ApiError, createTokenCache, requestJson, type Fetch } from './finder-http';
const token = createTokenCache();
const cache = new Map<string, { expires: number; result: ScoredBeatmapset[] }>();
const pending = new Map<string, Promise<ScoredBeatmapset[]>>();
@@ -20,16 +25,16 @@ async function limited<T>(work: () => Promise<T>): Promise<T> {
else active--;
}
}
async function search(fetchFn: typeof fetch, query: string): Promise<OsuBeatmapset[]> {
async function search(fetchFn: Fetch, query: string): Promise<OsuBeatmapset[]> {
const access = await token(() => {
if (!env.OSU_CLIENT_ID || !env.OSU_CLIENT_SECRET)
if (!OSU_CLIENT_ID || !OSU_CLIENT_SECRET)
throw new ApiError('osu!', 503, 'osu! is not configured on this server.');
return requestJson(fetchFn, 'osu!', 'https://osu.ppy.sh/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
client_id: Number(env.OSU_CLIENT_ID),
client_secret: env.OSU_CLIENT_SECRET,
client_id: Number(OSU_CLIENT_ID),
client_secret: OSU_CLIENT_SECRET,
grant_type: 'client_credentials',
scope: 'public'
})
@@ -63,10 +68,7 @@ async function search(fetchFn: typeof fetch, query: string): Promise<OsuBeatmaps
beatmaps: Array.isArray(item.beatmaps) ? item.beatmaps.map(() => ({ id: 0 })) : undefined
}));
}
export async function getOsuMaps(
fetchFn: typeof fetch,
track: SpotifyTrack
): Promise<ScoredBeatmapset[]> {
export async function getOsuMaps(fetchFn: Fetch, track: SpotifyTrack): Promise<ScoredBeatmapset[]> {
const key = JSON.stringify([normalize(track.name), track.artists.map((a) => normalize(a.name))]);
for (const [key, entry] of cache) if (entry.expires <= Date.now()) cache.delete(key);
const hit = cache.get(key);

View File

@@ -1,7 +1,7 @@
import { error } from '@sveltejs/kit';
import { and, asc, eq, inArray } from 'drizzle-orm';
import { pokemonResearch, pokemonGameResearch, pokemonLearnsets, pokemonMoves } from './db/schema';
import { db } from '$lib/server/db';
import { db } from '#lib/server/db/index.js';
import {
games,
pokemon,
@@ -9,7 +9,7 @@ import {
pokemonEvolutions,
pokemonGameEntries,
pokemonLocations
} from '$lib/server/db/schema';
} from '#lib/server/db/schema/index.js';
export type PokemonDetail = Awaited<ReturnType<typeof pokemonDetail>>;

View File

@@ -12,7 +12,7 @@ import {
pokemonGameEntries,
userEntries
} from './db/schema';
import { boundedInteger, isStage, obtainedMethods, validDate } from '$lib/game-tracker/journal';
import { boundedInteger, isStage, obtainedMethods, validDate } from '#lib/game-tracker/journal.js';
// Serialize mutations for one user's game, including competing party assignments.
async function lockGame(

View File

@@ -1,14 +1,20 @@
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { env } from '$env/dynamic/private';
import {
R2_ACCESS_KEY_ID,
R2_SECRET_ACCESS_KEY,
R2_ACCOUNT_ID,
R2_BUCKET,
R2_PUBLIC_URL
} from '$app/env/private';
import { randomUUID } from 'crypto';
function isConfigured(): boolean {
return !!(
env.R2_ACCESS_KEY_ID &&
env.R2_SECRET_ACCESS_KEY &&
env.R2_ACCOUNT_ID &&
env.R2_BUCKET &&
env.R2_PUBLIC_URL
R2_ACCESS_KEY_ID &&
R2_SECRET_ACCESS_KEY &&
R2_ACCOUNT_ID &&
R2_BUCKET &&
R2_PUBLIC_URL
);
}
@@ -19,10 +25,10 @@ function getClient(): S3Client {
if (!isConfigured()) throw new Error('R2 is not configured — check your .env');
_client = new S3Client({
region: 'auto',
endpoint: `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
endpoint: `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: env.R2_ACCESS_KEY_ID!,
secretAccessKey: env.R2_SECRET_ACCESS_KEY!
accessKeyId: R2_ACCESS_KEY_ID!,
secretAccessKey: R2_SECRET_ACCESS_KEY!
}
});
}
@@ -53,13 +59,13 @@ export async function uploadImage(
const key = `${prefix}/${randomUUID()}.${EXT[mimeType]}`;
await getClient().send(
new PutObjectCommand({
Bucket: env.R2_BUCKET,
Bucket: R2_BUCKET,
Key: key,
ContentType: mimeType,
Body: new Uint8Array(await file.arrayBuffer())
})
);
return { key, publicUrl: `${env.R2_PUBLIC_URL!.replace(/\/$/, '')}/${key}` };
return { key, publicUrl: `${R2_PUBLIC_URL!.replace(/\/$/, '')}/${key}` };
}
export function listImageUrl(key: string): string | null {
@@ -69,19 +75,19 @@ export function listImageUrl(key: string): string | null {
)
)
return null;
if (!env.R2_PUBLIC_URL) return null;
return `${env.R2_PUBLIC_URL.replace(/\/$/, '')}/${key}`;
if (!R2_PUBLIC_URL) return null;
return `${R2_PUBLIC_URL.replace(/\/$/, '')}/${key}`;
}
/** Public URL of an uploaded Pokémon location picture, or null for foreign keys. */
export function locationImageUrl(key: string | null): string | null {
if (!key || !/^locations\/[0-9a-f-]{36}\.(jpg|png|webp)$/.test(key) || !env.R2_PUBLIC_URL)
if (!key || !/^locations\/[0-9a-f-]{36}\.(jpg|png|webp)$/.test(key) || !R2_PUBLIC_URL)
return null;
return `${env.R2_PUBLIC_URL.replace(/\/$/, '')}/${key}`;
return `${R2_PUBLIC_URL.replace(/\/$/, '')}/${key}`;
}
export function listImageKey(url: string): string | null {
const base = env.R2_PUBLIC_URL?.replace(/\/$/, '');
const base = R2_PUBLIC_URL?.replace(/\/$/, '');
if (!base || !url.startsWith(`${base}/`)) return null;
const key = url.slice(base.length + 1);
return listImageUrl(key) === url ? key : null;
@@ -91,5 +97,5 @@ export function listImageKey(url: string): string | null {
* Delete a photo from R2 by its key.
*/
export async function deleteObject(key: string): Promise<void> {
await getClient().send(new DeleteObjectCommand({ Bucket: env.R2_BUCKET, Key: key }));
await getClient().send(new DeleteObjectCommand({ Bucket: R2_BUCKET, Key: key }));
}

View File

@@ -1,5 +1,5 @@
import { db } from '$lib/server/db';
import { blogArticles, lists } from '$lib/server/db/schema';
import { db } from '#lib/server/db/index.js';
import { blogArticles, lists } from '#lib/server/db/schema/index.js';
import { eq } from 'drizzle-orm';
export function slugify(title: string, fallback = 'article'): string {

View File

@@ -1,7 +1,7 @@
import { env } from '$env/dynamic/private';
import type { SpotifyInput } from '$lib/map-finder/input';
import type { SpotifyTrack, SpotifyCollection } from '$lib/map-finder/types';
import { ApiError, createTokenCache, requestJson } from './finder-http';
import { SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET } from '$app/env/private';
import type { SpotifyInput } from '#lib/map-finder/input.js';
import type { SpotifyTrack, SpotifyCollection } from '#lib/map-finder/types.js';
import { ApiError, createTokenCache, requestJson, type Fetch } from './finder-http';
export const MAX_PLAYLIST_TRACKS = 50;
export const MAX_PLAYLIST_PAGES = 10;
const token = createTokenCache();
@@ -55,14 +55,14 @@ export function playlistTracks(items: unknown[], seen = new Set<string>()): Spot
}
return result;
}
async function spotify(fetchFn: typeof fetch, path: string) {
async function spotify(fetchFn: Fetch, path: string) {
const access = await token(() => {
if (!env.SPOTIFY_CLIENT_ID || !env.SPOTIFY_CLIENT_SECRET)
if (!SPOTIFY_CLIENT_ID || !SPOTIFY_CLIENT_SECRET)
throw new ApiError('Spotify', 503, 'Spotify is not configured on this server.');
return requestJson(fetchFn, 'Spotify', 'https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
Authorization: `Basic ${btoa(`${env.SPOTIFY_CLIENT_ID}:${env.SPOTIFY_CLIENT_SECRET}`)}`,
Authorization: `Basic ${btoa(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`)}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({ grant_type: 'client_credentials' })
@@ -75,7 +75,7 @@ async function spotify(fetchFn: typeof fetch, path: string) {
);
}
export async function getSpotifyMetadata(
fetchFn: typeof fetch,
fetchFn: Fetch,
input:
| Extract<SpotifyInput, { kind: 'track' | 'playlist' | 'album' }>
| Extract<SpotifyInput, { kind: 'search' }>

View File

@@ -1,6 +1,6 @@
import { eq, count } from 'drizzle-orm';
import { db } from './db';
import { pokemonProgress } from '$lib/game-tracker/progress';
import { pokemonProgress } from '#lib/game-tracker/progress.js';
import {
games,
pokemon,