feat: visualize error page as card, improve docs

This commit is contained in:
2026-03-21 16:22:33 +07:00 Unverified
parent 43b193440b
commit 83047fb0ad
6 changed files with 115 additions and 24 deletions
+2 -2
View File
@@ -10,7 +10,7 @@
"@takumi-rs/image-response": "^0.71.7", "@takumi-rs/image-response": "^0.71.7",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"elysia": "latest", "elysia": "latest",
"enka.ts": "^1.1.8", "enka.ts": "^1.1.9",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
}, },
@@ -114,7 +114,7 @@
"elysia": ["[email protected]", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-2UlmNEjPJVA/WZVPYKy+KdsrfFwwNlqSBW1lHz6i2AHc75k7gV4Rhm01kFeotH7PDiHIX2G8X3KnRPc33SGVIg=="], "elysia": ["[email protected]", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-2UlmNEjPJVA/WZVPYKy+KdsrfFwwNlqSBW1lHz6i2AHc75k7gV4Rhm01kFeotH7PDiHIX2G8X3KnRPc33SGVIg=="],
"enka.ts": ["[email protected].8", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-ToK2bb+h4yX04pRmJbOKPOi6qsKUGbRqkwUdNfaPRExhxrBLg+1zrqJgOCTErNf31fwYYRD/nD00EXp+7/hTaw=="], "enka.ts": ["[email protected].9", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-6dBNMIQ/LQ2y6RtcxJCGBjTPOyGcF93OdGbxJ5NW/p9KZ+0Jaqj4dZfrVvYpynbBMGj1NNuvIW3f+g6m5SGrlg=="],
"exact-mirror": ["[email protected]", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="], "exact-mirror": ["[email protected]", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="],
+1 -1
View File
@@ -12,7 +12,7 @@
"@takumi-rs/image-response": "^0.71.7", "@takumi-rs/image-response": "^0.71.7",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"elysia": "latest", "elysia": "latest",
"enka.ts": "^1.1.8", "enka.ts": "^1.1.9",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4"
}, },
+6
View File
@@ -0,0 +1,6 @@
import { file } from "bun";
import iconFontFile from "./shicon.woff" with { type: "file" };
import fontFile from "./shinshin.woff2" with { type: "file" };
export const font = file(fontFile);
export const iconFont = file(iconFontFile);
+70 -21
View File
@@ -1,7 +1,7 @@
import { feature } from "bun:bundle"; import { feature } from "bun:bundle";
import { ImageResponse } from "@takumi-rs/image-response"; import { ImageResponse } from "@takumi-rs/image-response";
import { file, hash } from "bun"; import { file, hash, inspect } from "bun";
import { NotFoundError, status, t } from "elysia"; import { NotFoundError, redirect, status, t } from "elysia";
import { import {
EquipType, EquipType,
fetchEnka, fetchEnka,
@@ -14,11 +14,10 @@ import {
import { generateBackground } from "@/assets/background"; import { generateBackground } from "@/assets/background";
import { ConstsSvg } from "@/assets/consts"; import { ConstsSvg } from "@/assets/consts";
import { featherImage } from "@/assets/feather"; import { featherImage } from "@/assets/feather";
import { font, iconFont } from "@/assets/fonts";
import { EnkaIcon } from "@/assets/icons"; import { EnkaIcon } from "@/assets/icons";
import { library } from "@/assets/icons-library"; import { library } from "@/assets/icons-library";
import shadeSvg from "@/assets/shade.svg" with { type: "file" }; import shadeSvg from "@/assets/shade.svg" with { type: "file" };
import iconFontFile from "@/assets/shicon.woff" with { type: "file" };
import fontFile from "@/assets/shinshin.woff2" with { type: "file" };
import { star } from "@/assets/stars"; import { star } from "@/assets/stars";
import { TalentSvg } from "@/assets/talent"; import { TalentSvg } from "@/assets/talent";
import { WeaponGlazeSvg } from "@/assets/weapon-glaze"; import { WeaponGlazeSvg } from "@/assets/weapon-glaze";
@@ -30,13 +29,12 @@ import { mockData } from "@/lib/mock";
import { buildDisplayRows } from "@/lib/rows"; import { buildDisplayRows } from "@/lib/rows";
import { SubstatOrder } from "@/type/enka"; import { SubstatOrder } from "@/type/enka";
import { PercentageFightProp } from "@/type/fightPropMap"; import { PercentageFightProp } from "@/type/fightPropMap";
import { ErrorPage } from "./error";
const assets = new AssetManager(); const assets = new AssetManager();
const cache = new TTLCache(); const cache = new TTLCache();
const deduper = new Deduplicator(); const deduper = new Deduplicator();
const font = file(fontFile);
const iconFont = file(iconFontFile);
const shade = file(shadeSvg); const shade = file(shadeSvg);
var purging = 0; var purging = 0;
@@ -64,8 +62,12 @@ export function card(app: App) {
.derive(() => ({ .derive(() => ({
logger: createLogger(), logger: createLogger(),
})) }))
// FE still uses this endpoint for some reason(aka lazy to change)
.head("/genshin/:uid/:char/preload", async ({ params: { uid } }) =>
redirect(`/genshin/${uid}/preload`),
)
.head( .head(
"/genshin/:uid/:char/preload", "/genshin/:uid/preload",
async ({ params: { uid }, logger }) => { async ({ params: { uid }, logger }) => {
logger`Preload ${uid}`; logger`Preload ${uid}`;
try { try {
@@ -100,9 +102,13 @@ export function card(app: App) {
async ({ async ({
params: { uid, char: charId }, params: { uid, char: charId },
logger, logger,
query: { debug, h: height = 1024 }, query: { debug, h: height = 1024, error: errorType },
}) => { }) => {
logger`Rendering ${uid}/${charId}`; logger`Rendering ${uid}/${charId}`;
const error =
errorType === "card"
? (_: number, message: string) => ErrorPage(message)
: status;
try { try {
// #region Data fetching // #region Data fetching
const [[avatarStore, weaponStore, relicStore, locs], data] = const [[avatarStore, weaponStore, relicStore, locs], data] =
@@ -119,21 +125,21 @@ export function card(app: App) {
: mockData, : mockData,
]); ]);
if (typeof data.avatarInfoList === "undefined") if (typeof data.avatarInfoList === "undefined")
return status(400, "The showcase for this UID is private"); return error(400, "The showcase for this UID is private");
const char = data.avatarInfoList.find( const char = data.avatarInfoList.find(
(c) => c.avatar.avatarId === Number(charId), (c) => c.avatar.avatarId === Number(charId),
); );
if (!char) return status(400, "Character not found in showcase"); if (!char) return error(400, "Character not found in showcase");
const avatar = avatarStore[char.avatar.avatarId]; const avatar = avatarStore[char.avatar.avatarId];
if (!avatar) if (!avatar)
return status( return error(
400, 400,
"Unsupported character, avatar store doesn't have this character", "Unsupported character, avatar store doesn't have this character",
); );
if (!char.equipList) if (!char.equipList)
return status(501, "INTERNAL: No equipList provided"); return error(501, "INTERNAL: No equipList provided");
const weaponRaw = char.equipList.find((e) => e.weapon)!; const weaponRaw = char.equipList.find((e) => e.weapon)!;
const weapon = { const weapon = {
data: weaponRaw.weapon!, data: weaponRaw.weapon!,
@@ -694,31 +700,63 @@ export function card(app: App) {
return res.clone(); return res.clone();
} catch (err) { } catch (err) {
if (err instanceof InvalidFormatError) if (err instanceof InvalidFormatError)
return status(400, "Invalid UID format"); return error(400, "Invalid UID format");
else if (err instanceof NotFoundError) else if (err instanceof NotFoundError)
return status(404, "UID not found on enka.network"); return error(404, "UID not found on enka.network");
else if (err instanceof MaintenanceError) else if (err instanceof MaintenanceError)
return status(424, "enka.network is under maintenance"); return error(424, "enka.network is under maintenance");
else if (err instanceof RatelimitError) else if (err instanceof RatelimitError)
return status( return error(
429, 429,
"Too many requests to enka.network, please try again later", "Too many requests to enka.network, please try again later",
); );
else if (err instanceof InternalError) else if (err instanceof InternalError)
return status(502, "Internal server error"); return status(502, "Internal server error");
console.error(err); console.error(err);
throw err; try {
return error(500, inspect(err) as "");
} catch {
throw err;
}
} }
}, },
{ {
params: t.Object({ params: t.Object({
uid: t.String(), uid: t.String({
char: t.Number(), examples: ["775956514"],
description: "Genshin Impact UID",
}),
char: t.Number({
examples: ["10000120"],
description: "Character ID",
}),
}), }),
query: t.Object( query: t.Object(
{ {
debug: t.Optional(t.String()), debug: t.Optional(
h: t.Optional(t.Number({ default: 1024 })), t.Boolean({
description: "Return debug information instead of card.",
}),
),
h: t.Optional(
t.Number({ default: 1024, description: "Height of card." }),
),
w: t.Optional(
t.Number({
description:
"Target width of card, used to calculate height. Has higher priority than `h`.",
}),
),
error: t.Optional(
t.Enum(
{ http: "http", card: "card" },
{
default: "http",
description:
"Type of error to return when card fails to render.",
},
),
),
}, },
{ additionalProperties: true }, { additionalProperties: true },
), ),
@@ -727,6 +765,17 @@ export function card(app: App) {
description: description:
"Generate a Genshin Impact enka.network card image from a UID.", "Generate a Genshin Impact enka.network card image from a UID.",
}, },
error({ code, error, query: { error: errorType } }) {
if (code === "VALIDATION") {
const { summary } = JSON.parse(error.message);
try {
if (errorType === "http") throw "";
return ErrorPage(`Validation Error:\n${summary}`);
} catch {
return status(500, summary);
}
}
},
afterResponse: ({ logger }) => logger`Rendering completed`, afterResponse: ({ logger }) => logger`Rendering completed`,
}, },
), ),
+7
View File
@@ -0,0 +1,7 @@
import { CardResponse } from "./response";
export async function ErrorPage(message: string) {
return CardResponse(
<pre tw="p-[1em] m-0 text-[1.1em] whitespace-pre-wrap">{message}</pre>,
);
}
+29
View File
@@ -0,0 +1,29 @@
import ImageResponse from "@takumi-rs/image-response";
import { ReactNode } from "react";
import { font, iconFont } from "@/assets/fonts";
export async function CardResponse(children: ReactNode) {
return new ImageResponse(
<div tw="text-white bg-[#2f3136] w-full h-full flex flex-col">
{children}
<div tw="grow" />
<div tw="ml-[.5em] flex items-center">
<div tw="text-[2em] font-[shicon] pb-[.1em] -mr-[.3em] text-black/50">
</div>
<div tw="flex flex-col">
<div tw="leading-none text-white/50">Powered By</div>
<div tw="text-[1.5em] leading-[.8]">Enka.Network</div>
</div>
</div>
</div>,
{
fonts: [await font.arrayBuffer(), await iconFont.arrayBuffer()],
format: "webp",
// same ratio as card
width: 1228,
height: 512,
// drawDebugBorder: true,
},
);
}