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:
@@ -67,9 +67,4 @@ app.get("/docs", swaggerUI({ url: "/openapi.json" }));
|
||||
|
||||
export default app;
|
||||
|
||||
export class AniwatchApiContainer /* extends Container */ {
|
||||
// Port the container listens on (default: 8080)
|
||||
defaultPort = 4444;
|
||||
// Time before container sleeps due to inactivity (default: 30s)
|
||||
sleepAfter = "2m";
|
||||
}
|
||||
export { AnilistDurableObject as AnilistDo } from "~/libs/anilist/anilist-do.ts";
|
||||
|
||||
104
src/libs/anilist/anilist-do.ts
Normal file
104
src/libs/anilist/anilist-do.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { graphql } from "gql.tada";
|
||||
import { GraphQLClient } from "graphql-request";
|
||||
|
||||
import type { Title } from "~/types/title";
|
||||
import { MediaFragment } from "~/types/title/mediaFragment";
|
||||
|
||||
import { sleep } from "../sleep";
|
||||
|
||||
const GetTitleQuery = graphql(
|
||||
export const GetTitleQuery = graphql(
|
||||
`
|
||||
query GetTitle($id: Int!) {
|
||||
Media(id: $id) {
|
||||
@@ -21,29 +19,30 @@ export async function 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}`);
|
||||
const durableObjectId = env.ANILIST_DO.idFromName(
|
||||
id.toString() + (token == null ? "" : "_" + token),
|
||||
);
|
||||
const stub = env.ANILIST_DO.get(durableObjectId);
|
||||
|
||||
const response = await stub.fetch("http://anilist-do/graphql", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
operationName: "GetTitle",
|
||||
variables: { id, token },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok || response.status === 204) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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(() => fetchTitleFromAnilist(id, token));
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
32
src/types/anilist-graphql.d.ts
vendored
32
src/types/anilist-graphql.d.ts
vendored
@@ -3,20 +3,7 @@ import * as gqlTada from "gql.tada";
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
|
||||
/** An IntrospectionQuery representation of your schema.
|
||||
*
|
||||
* @remarks
|
||||
* This is an introspection of your schema saved as a file by GraphQLSP.
|
||||
* It will automatically be used by `gql.tada` to infer the types of your GraphQL documents.
|
||||
* If you need to reuse this data or update your `scalars`, update `tadaOutputLocation` to
|
||||
* instead save to a .ts instead of a .d.ts file.
|
||||
*/
|
||||
export type introspection = {
|
||||
name: never;
|
||||
query: 'Query';
|
||||
mutation: 'Mutation';
|
||||
subscription: never;
|
||||
types: {
|
||||
export type introspection_types = {
|
||||
'ActivityLikeNotification': { kind: 'OBJECT'; name: 'ActivityLikeNotification'; fields: { 'activity': { name: 'activity'; type: { kind: 'UNION'; name: 'ActivityUnion'; ofType: null; } }; 'activityId': { name: 'activityId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'context': { name: 'context'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'createdAt': { name: 'createdAt'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'type': { name: 'type'; type: { kind: 'ENUM'; name: 'NotificationType'; ofType: null; } }; 'user': { name: 'user'; type: { kind: 'OBJECT'; name: 'User'; ofType: null; } }; 'userId': { name: 'userId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; }; };
|
||||
'ActivityMentionNotification': { kind: 'OBJECT'; name: 'ActivityMentionNotification'; fields: { 'activity': { name: 'activity'; type: { kind: 'UNION'; name: 'ActivityUnion'; ofType: null; } }; 'activityId': { name: 'activityId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'context': { name: 'context'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'createdAt': { name: 'createdAt'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'type': { name: 'type'; type: { kind: 'ENUM'; name: 'NotificationType'; ofType: null; } }; 'user': { name: 'user'; type: { kind: 'OBJECT'; name: 'User'; ofType: null; } }; 'userId': { name: 'userId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; }; };
|
||||
'ActivityMessageNotification': { kind: 'OBJECT'; name: 'ActivityMessageNotification'; fields: { 'activityId': { name: 'activityId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'context': { name: 'context'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'createdAt': { name: 'createdAt'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'message': { name: 'message'; type: { kind: 'OBJECT'; name: 'MessageActivity'; ofType: null; } }; 'type': { name: 'type'; type: { kind: 'ENUM'; name: 'NotificationType'; ofType: null; } }; 'user': { name: 'user'; type: { kind: 'OBJECT'; name: 'User'; ofType: null; } }; 'userId': { name: 'userId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; }; };
|
||||
@@ -202,7 +189,22 @@ export type introspection = {
|
||||
'UserTitleLanguage': { name: 'UserTitleLanguage'; enumValues: 'ROMAJI' | 'ENGLISH' | 'NATIVE' | 'ROMAJI_STYLISED' | 'ENGLISH_STYLISED' | 'NATIVE_STYLISED'; };
|
||||
'UserVoiceActorStatistic': { kind: 'OBJECT'; name: 'UserVoiceActorStatistic'; fields: { 'chaptersRead': { name: 'chaptersRead'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'characterIds': { name: 'characterIds'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; }; } }; 'count': { name: 'count'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'meanScore': { name: 'meanScore'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Float'; ofType: null; }; } }; 'mediaIds': { name: 'mediaIds'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; }; } }; 'minutesWatched': { name: 'minutesWatched'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'voiceActor': { name: 'voiceActor'; type: { kind: 'OBJECT'; name: 'Staff'; ofType: null; } }; }; };
|
||||
'YearStats': { kind: 'OBJECT'; name: 'YearStats'; fields: { 'amount': { name: 'amount'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; 'meanScore': { name: 'meanScore'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; 'year': { name: 'year'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; }; };
|
||||
};
|
||||
};
|
||||
|
||||
/** An IntrospectionQuery representation of your schema.
|
||||
*
|
||||
* @remarks
|
||||
* This is an introspection of your schema saved as a file by GraphQLSP.
|
||||
* It will automatically be used by `gql.tada` to infer the types of your GraphQL documents.
|
||||
* If you need to reuse this data or update your `scalars`, update `tadaOutputLocation` to
|
||||
* instead save to a .ts instead of a .d.ts file.
|
||||
*/
|
||||
export type introspection = {
|
||||
name: never;
|
||||
query: "Query";
|
||||
mutation: "Mutation";
|
||||
subscription: never;
|
||||
types: introspection_types;
|
||||
};
|
||||
|
||||
declare module "gql.tada" {
|
||||
|
||||
1189
worker-configuration.d.ts
vendored
1189
worker-configuration.d.ts
vendored
File diff suppressed because it is too large
Load Diff
@@ -10,3 +10,11 @@ ENABLE_ANIFY = false
|
||||
|
||||
[observability]
|
||||
enabled = true
|
||||
|
||||
[[durable_objects.bindings]]
|
||||
name = "ANILIST_DO"
|
||||
class_name = "AnilistDo"
|
||||
|
||||
[[migrations]]
|
||||
tag = "v1"
|
||||
new_classes = ["AnilistDo"]
|
||||
|
||||
Reference in New Issue
Block a user