mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-18 19:42:04 +00:00
perf: fix slow page loads and gate performance in CI
- Ship one hashed Tailwind stylesheet instead of two (one render-blocking) - Compress responses in-app (brotli/gzip, streaming-safe) and precompress the node build so local and CI measurements match production - Validate the Supabase session once per request - Render the homepage immediately and stream public stats - Stream a Pokedex's entries with the page instead of fetching after hydration, running the rows and count queries in parallel - Shrink the avatar and offline placeholder images, fix layout shift, contrast, link names and missing meta descriptions - Add Lighthouse CI (mobile + desktop) with score and metric budgets, bundle-size budgets in the build tests, and signed-in speed scenarios
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { Readable } from 'node:stream';
|
||||
import type { ReadableStream as NodeReadableStream } from 'node:stream/web';
|
||||
import { constants, createBrotliCompress, createGzip } from 'node:zlib';
|
||||
|
||||
export type Encoding = 'br' | 'gzip';
|
||||
|
||||
// Text responses the app renders or returns from API routes. Images, fonts and other binary
|
||||
// content is already compressed, so re-compressing it only costs CPU.
|
||||
const COMPRESSIBLE = /^(text\/|application\/(json|javascript|xml|manifest\+json)|image\/svg\+xml)/i;
|
||||
|
||||
/** Picks the best encoding the client accepts, preferring brotli. Honours `q=0` refusals. */
|
||||
export function pickEncoding(acceptEncoding: string | null): Encoding | null {
|
||||
if (!acceptEncoding) return null;
|
||||
const accepted = new Map<string, number>();
|
||||
for (const part of acceptEncoding.split(',')) {
|
||||
const [name, ...params] = part.trim().toLowerCase().split(';');
|
||||
const q = params.map((p) => p.trim()).find((p) => p.startsWith('q='));
|
||||
accepted.set(name, q ? Number(q.slice(2)) : 1);
|
||||
}
|
||||
const allows = (name: Encoding) => (accepted.get(name) ?? accepted.get('*') ?? 0) > 0;
|
||||
if (allows('br')) return 'br';
|
||||
if (allows('gzip')) return 'gzip';
|
||||
return null;
|
||||
}
|
||||
|
||||
// adapter-netlify's Lambda handler serialises text responses with `response.text()`, which would
|
||||
// corrupt a compressed body. Netlify compresses function responses itself, so skip it there.
|
||||
// Everywhere else (the Node build, local preview, CI) the app compresses its own responses.
|
||||
const serialisesBodiesAsText = () => Boolean(process.env.AWS_LAMBDA_FUNCTION_NAME);
|
||||
|
||||
function shouldCompress(request: Request, response: Response): boolean {
|
||||
if (serialisesBodiesAsText()) return false;
|
||||
if (!response.body || request.method === 'HEAD') return false;
|
||||
if (response.status < 200 || response.status === 204 || response.status === 304) return false;
|
||||
if (response.headers.has('content-encoding')) return false;
|
||||
return COMPRESSIBLE.test(response.headers.get('content-type') ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses a rendered page or API response so its transfer size doesn't depend on the host.
|
||||
* Every chunk is flushed as soon as it is written, so SvelteKit's streamed load data still reaches
|
||||
* the browser progressively instead of waiting for the whole body.
|
||||
*/
|
||||
export function compressResponse(request: Request, response: Response): Response {
|
||||
if (!shouldCompress(request, response)) return response;
|
||||
const encoding = pickEncoding(request.headers.get('accept-encoding'));
|
||||
|
||||
const headers = new Headers(response.headers);
|
||||
// Caches must key on the request encoding even when this response isn't compressed.
|
||||
headers.append('vary', 'Accept-Encoding');
|
||||
if (!encoding) return new Response(response.body, { status: response.status, headers });
|
||||
|
||||
const compressor =
|
||||
encoding === 'br'
|
||||
? createBrotliCompress({
|
||||
flush: constants.BROTLI_OPERATION_FLUSH,
|
||||
// Quality 11 is for build-time precompression; 5 is fast enough per request.
|
||||
params: { [constants.BROTLI_PARAM_QUALITY]: 5 }
|
||||
})
|
||||
: createGzip({ flush: constants.Z_SYNC_FLUSH, level: 6 });
|
||||
|
||||
const source = Readable.fromWeb(response.body as unknown as NodeReadableStream);
|
||||
source.on('error', (error) => compressor.destroy(error));
|
||||
source.pipe(compressor);
|
||||
|
||||
headers.set('content-encoding', encoding);
|
||||
headers.delete('content-length');
|
||||
return new Response(Readable.toWeb(compressor) as unknown as ReadableStream, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
|
||||
import { resolveDexScopes } from '$lib/services/PokedexDexScopeService';
|
||||
import type { Pokedex } from '$lib/models/Pokedex';
|
||||
|
||||
export type CombinedDataQuery = {
|
||||
page: number;
|
||||
limit: number;
|
||||
enableForms: boolean;
|
||||
region?: string;
|
||||
game?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads one page of a Pokédex's entries joined with the owner's catch records. Shared by the
|
||||
* combined-data API and the Pokédex page's server load so both return exactly the same data.
|
||||
* The caller must already have checked that `userId` owns `pokedex`.
|
||||
*/
|
||||
export async function loadCombinedDataPage(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
pokedex: Pokedex,
|
||||
{ page, limit, enableForms, region = '', game = '' }: CombinedDataQuery
|
||||
) {
|
||||
// Use the pokédex's gameScope as the default filter if no manual game filter is set.
|
||||
const effectiveGame = game || pokedex.gameScope || '';
|
||||
const dexScopes = await resolveDexScopes(supabase, pokedex);
|
||||
const repo = new CombinedDataRepository(supabase, userId, pokedex._id);
|
||||
|
||||
// The rows and the count are independent queries, so run them together.
|
||||
const [combinedData, totalCount] = await Promise.all([
|
||||
repo.findCombinedData(userId, page, limit, enableForms, region, effectiveGame, dexScopes),
|
||||
repo.countCombinedData(enableForms, region, effectiveGame, dexScopes)
|
||||
]);
|
||||
|
||||
return {
|
||||
combinedData,
|
||||
totalPages: Math.ceil(totalCount / limit),
|
||||
currentPage: page,
|
||||
totalCount
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user