181 lines
5.7 KiB
TypeScript
181 lines
5.7 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import { error } from '@sveltejs/kit';
|
|
import {
|
|
renderBuildSocialCardPng,
|
|
type BuildSocialCardFact,
|
|
type BuildSocialCardInput,
|
|
} from '$lib/server/build-social-card';
|
|
import { publicOrigin } from '$lib/server/public-origin';
|
|
import { loadSharedBuildProjection } from '$lib/server/shared-build';
|
|
import type { RequestHandler } from './$types';
|
|
|
|
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
|
|
const MAX_CACHE_BYTES = 16 * 1024 * 1024;
|
|
const MAX_ACTIVE_REQUESTS = 4;
|
|
const MAX_QUEUED_REQUESTS = 32;
|
|
type CachedImage = { body: Buffer; etag: string };
|
|
const imageCache = new Map<string, CachedImage>();
|
|
const pendingImages = new Map<string, Promise<CachedImage>>();
|
|
const requestQueue: Array<(release: () => void) => void> = [];
|
|
let cacheBytes = 0;
|
|
let activeRequests = 0;
|
|
|
|
function unitValue(value: number, unit: string | null | undefined) {
|
|
const rendered = number.format(value);
|
|
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit ?? ''}`;
|
|
}
|
|
|
|
export function _socialCardInput(
|
|
projection: NonNullable<
|
|
Awaited<ReturnType<typeof loadSharedBuildProjection>>
|
|
>,
|
|
directUrl: string,
|
|
): BuildSocialCardInput {
|
|
const attribute = (id: string) =>
|
|
projection.attributes.find((item) => String(item.id) === id);
|
|
const statByName = (name: string) =>
|
|
projection.summary.stats.find(
|
|
(stat) => String(attribute(stat.id)?.name) === name,
|
|
);
|
|
const statValue = (name: string) => {
|
|
const stat = statByName(name);
|
|
return stat ? unitValue(Number(stat.value), attribute(stat.id)?.unit) : '0';
|
|
};
|
|
const weaponName = (index: number) => {
|
|
const selected = projection.loadout.weapons[index];
|
|
return (
|
|
projection.weapons.find((item) => String(item.id) === selected?.itemId)
|
|
?.name ?? 'Unassigned'
|
|
);
|
|
};
|
|
const coreNames = new Set(['Firepower', 'Toughness', 'Engineering']);
|
|
const supportingAttributes: BuildSocialCardFact[] = projection.summary.stats
|
|
.filter((stat) => !coreNames.has(String(attribute(stat.id)?.name)))
|
|
.slice(0, 3)
|
|
.map((stat) => ({
|
|
label: String(attribute(stat.id)?.name ?? 'Attribute'),
|
|
value: unitValue(Number(stat.value), attribute(stat.id)?.unit),
|
|
}));
|
|
|
|
return {
|
|
title: projection.build.title,
|
|
author: projection.build.author,
|
|
primaryWeapon: String(weaponName(0)),
|
|
secondaryWeapon: String(weaponName(1)),
|
|
firepower: statValue('Firepower'),
|
|
toughness: statValue('Toughness'),
|
|
engineering: statValue('Engineering'),
|
|
supportingAttributes,
|
|
visibility: projection.build.visibility as 'public' | 'unlisted',
|
|
directUrl,
|
|
};
|
|
}
|
|
|
|
function matchesEtag(request: Request, etag: string) {
|
|
return request.headers
|
|
.get('if-none-match')
|
|
?.split(',')
|
|
.some((value) => value.trim() === etag);
|
|
}
|
|
|
|
function releaseRequest() {
|
|
const next = requestQueue.shift();
|
|
if (next) {
|
|
next(releaseRequest);
|
|
return;
|
|
}
|
|
activeRequests -= 1;
|
|
}
|
|
|
|
function acquireRequest(): Promise<(() => void) | null> {
|
|
if (activeRequests < MAX_ACTIVE_REQUESTS) {
|
|
activeRequests += 1;
|
|
return Promise.resolve(releaseRequest);
|
|
}
|
|
if (requestQueue.length >= MAX_QUEUED_REQUESTS) return Promise.resolve(null);
|
|
return new Promise((resolve) => requestQueue.push(resolve));
|
|
}
|
|
|
|
function storeImage(key: string, image: CachedImage) {
|
|
imageCache.set(key, image);
|
|
cacheBytes += image.body.byteLength;
|
|
while (cacheBytes > MAX_CACHE_BYTES && imageCache.size > 1) {
|
|
const oldestKey = imageCache.keys().next().value;
|
|
if (!oldestKey) break;
|
|
const oldest = imageCache.get(oldestKey);
|
|
imageCache.delete(oldestKey);
|
|
cacheBytes -= oldest?.body.byteLength ?? 0;
|
|
}
|
|
}
|
|
|
|
async function loadImage(input: BuildSocialCardInput, inputKey: string) {
|
|
const cached = imageCache.get(inputKey);
|
|
if (cached) {
|
|
imageCache.delete(inputKey);
|
|
imageCache.set(inputKey, cached);
|
|
return cached;
|
|
}
|
|
const pending = pendingImages.get(inputKey);
|
|
if (pending) return pending;
|
|
|
|
const request = renderBuildSocialCardPng(input).then((body) => {
|
|
const image = {
|
|
body,
|
|
etag: `"${createHash('sha256').update(body).digest('base64url')}"`,
|
|
};
|
|
storeImage(inputKey, image);
|
|
return image;
|
|
});
|
|
pendingImages.set(inputKey, request);
|
|
try {
|
|
return await request;
|
|
} finally {
|
|
pendingImages.delete(inputKey);
|
|
}
|
|
}
|
|
|
|
export const GET: RequestHandler = async ({ params, request, url }) => {
|
|
const release = await acquireRequest();
|
|
if (!release) error(503, 'Build preview capacity exceeded');
|
|
try {
|
|
const projection = await loadSharedBuildProjection(params.slug, {
|
|
kind: 'public-preview',
|
|
});
|
|
if (!projection) error(404, 'Build preview not found');
|
|
|
|
const origin = publicOrigin(url.origin);
|
|
const directUrl = new URL(
|
|
`/builds/${encodeURIComponent(projection.build.slug)}`,
|
|
origin,
|
|
).toString();
|
|
const input = _socialCardInput(projection, directUrl);
|
|
const inputKey = createHash('sha256')
|
|
.update(JSON.stringify({ version: 1, input }))
|
|
.digest('base64url');
|
|
const image = await loadImage(input, inputKey);
|
|
const cacheControl = 'public, max-age=0, must-revalidate';
|
|
if (matchesEtag(request, image.etag)) {
|
|
return new Response(null, {
|
|
status: 304,
|
|
headers: { 'cache-control': cacheControl, etag: image.etag },
|
|
});
|
|
}
|
|
|
|
return new Response(new Uint8Array(image.body), {
|
|
headers: {
|
|
'cache-control': cacheControl,
|
|
'content-length': String(image.body.byteLength),
|
|
'content-type': 'image/png',
|
|
'cross-origin-resource-policy': 'same-origin',
|
|
etag: image.etag,
|
|
'x-content-type-options': 'nosniff',
|
|
...(projection.build.visibility === 'unlisted'
|
|
? { 'x-robots-tag': 'noindex' }
|
|
: {}),
|
|
},
|
|
});
|
|
} finally {
|
|
release();
|
|
}
|
|
};
|