feat: visualize error page as card, improve docs
This commit is contained in:
@@ -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
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
+69
-20
@@ -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);
|
||||||
|
try {
|
||||||
|
return error(500, inspect(err) as "");
|
||||||
|
} catch {
|
||||||
throw err;
|
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`,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user