(feat) Discord build share
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
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();
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user