feat: Add durable object for Anilist requests (#2)

This commit introduces a durable object to handle Anilist GraphQL requests, with the goal of preventing request duplication.

The durable object is designed to cache responses from the Anilist API, with a TTL based on the `nextAiringEpisode` field in the response.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
This commit is contained in:
2025-11-14 13:07:36 +08:00
committed by GitHub
parent f6a3ea2649
commit 959265484c
6 changed files with 1258 additions and 133 deletions

View File

@@ -0,0 +1,104 @@
import { DurableObject, env } from "cloudflare:workers";
import { GraphQLClient } from "graphql-request";
import { z } from "zod";
import { GetTitleQuery, _fetchTitleFromAnilist } from "~/libs/anilist/getTitle";
import { sleep } from "~/libs/sleep.ts";
import type { Title } from "~/types/title";
const nextAiringEpisodeSchema = z.nullable(
z.object({
episode: z.number().int(),
airingAt: z.number().int(),
timeUntilAiring: z.number().int(),
}),
);
export class AnilistDurableObject extends DurableObject {
state: DurableObjectState;
constructor(state: DurableObjectState) {
super(state, env);
this.state = state;
}
async fetch(request: Request) {
const body = await request.json();
const { operationName } = body;
switch (operationName) {
case "GetTitle": {
const { variables } = body;
const cache = await this.state.storage.get(variables.id);
if (cache) {
return new Response(JSON.stringify(cache), {
headers: { "Content-Type": "application/json" },
});
}
const anilistResponse = await this.fetchTitleFromAnilist(
variables.id,
variables.token,
);
const nextAiringEpisode = nextAiringEpisodeSchema.parse(
anilistResponse?.nextAiringEpisode,
);
const airingAt = (nextAiringEpisode?.airingAt ?? 0) * 1000;
await this.state.storage.put(variables.id, anilistResponse);
if (airingAt) {
await this.state.storage.setAlarm(airingAt);
await this.state.storage.put(`alarm:${variables.id}`, airingAt);
}
return new Response(JSON.stringify(anilistResponse), {
headers: { "Content-Type": "application/json" },
});
}
default:
return new Response("Not found", { status: 404 });
}
}
async alarm() {
const now = Date.now();
const alarms = await this.state.storage.list({ prefix: "alarm:" });
console.log("alarm", now, alarms);
for (const [id, ttl] of Object.entries(alarms)) {
if (now >= ttl) {
await this.state.storage.delete(id);
}
}
}
async fetchTitleFromAnilist(
id: number,
token?: string | undefined,
): Promise<Title | undefined> {
const client = new GraphQLClient("https://graphql.anilist.co/");
const headers = new Headers();
if (token) {
headers.append("Authorization", `Bearer ${token}`);
}
return client
.request(GetTitleQuery, { id }, headers)
.then((data) => data?.Media ?? undefined)
.catch((error) => {
if (error.message.includes("Not Found")) {
return undefined;
}
if (error.response?.status === 429) {
console.log(
"429, retrying in",
error.response.headers.get("Retry-After"),
);
return sleep(
Number(error.response.headers.get("Retry-After")!) * 1000,
).then(() => this.fetchTitleFromAnilist(id, token));
}
throw error;
});
}
}