Merge pull request #127 from ghoshRitesh12/improvements

Improvements
This commit is contained in:
Ritesh Ghosh
2025-05-26 00:13:10 +05:30
committed by GitHub
15 changed files with 288 additions and 133 deletions
+6 -2
View File
@@ -18,8 +18,12 @@ ANIWATCH_API_MAX_REQS=70
# ANIWATCH_API_HOSTNAME=<https://your-production-domain.com> # ANIWATCH_API_HOSTNAME=<https://your-production-domain.com>
# NOTE: this env is "required" for vercel deployments # NOTE:
# ANIWATCH_API_VERCEL_DEPLOYMENT=<true or any non zero value> # The deployment environment of the Aniwatch API.
# Many configurations depend on this env, rate limiting being one of them.
# It must be set incase of serverless deployments; otherwise, you may run into weird issues.
# Possible values: 'nodejs' | 'docker' | 'vercel' | 'render' | 'cloudflare-workers'
ANIWATCH_API_DEPLOYMENT_ENV="nodejs"
# env to use optional redis caching functionality # env to use optional redis caching functionality
ANIWATCH_API_REDIS_CONN_URL=<rediss://default:your-secure-password@your-redis-instance-name.provider.com:6379> ANIWATCH_API_REDIS_CONN_URL=<rediss://default:your-secure-password@your-redis-instance-name.provider.com:6379>
@@ -1,4 +1,4 @@
name: "Lint and format check" name: "Type and format check"
on: on:
push: push:
@@ -22,8 +22,9 @@ jobs:
- name: Install deps - name: Install deps
run: npm i run: npm i
- name: Check lint - name: Check types
run: npm run lint # This step checks for TypeScript type errors
run: npm run typecheck
- name: Check prettier - name: Check prettier
run: npm run format:check run: npm run format:check
+1 -1
View File
@@ -17,7 +17,7 @@ RUN npm run build
FROM node:22-alpine as prod FROM node:22-alpine as prod
LABEL org.opencontainers.image.source=https://github.com/ghoshRitesh12/aniwatch-api LABEL org.opencontainers.image.source=https://github.com/ghoshRitesh12/aniwatch-api
LABEL org.opencontainers.image.description="Node.js API for obtaining anime information from hianime.to" LABEL org.opencontainers.image.description="NodeJS API for obtaining anime data from hianime.to"
LABEL org.opencontainers.image.licenses=MIT LABEL org.opencontainers.image.licenses=MIT
# install curl for healthcheck # install curl for healthcheck
+20 -15
View File
@@ -55,7 +55,7 @@
> [!IMPORTANT] > [!IMPORTANT]
> >
> 1. [https://api-aniwatch.onrender.com](https://api-aniwatch.onrender.com/) is only meant to demo the API and has rate-limiting enabled to minimize bandwidth consumption. It is recommended to deploy your own instance for personal use by customizing the API as you need it to be. > 1. There was previously a hosted version of this API for showcasing purposes only, and it was misused; since then, there have been no other hosted versions. It is recommended to deploy your own instance for personal use by customizing the API as you need it to be.
> 2. This API is just an unofficial API for [hianimez.to](https://hianimez.to) and is in no other way officially related to the same. > 2. This API is just an unofficial API for [hianimez.to](https://hianimez.to) and is in no other way officially related to the same.
> 3. The content that this API provides is not mine, nor is it hosted by me. These belong to their respective owners. This API just demonstrates how to build an API that scrapes websites and uses their content. > 3. The content that this API provides is not mine, nor is it hosted by me. These belong to their respective owners. This API just demonstrates how to build an API that scrapes websites and uses their content.
@@ -137,21 +137,21 @@ The `-d` flag runs the container in detached mode, and the `--name` flag is used
Currently this API supports parsing of only one custom header, and more may be implemented in the future to accommodate varying needs. Currently this API supports parsing of only one custom header, and more may be implemented in the future to accommodate varying needs.
- `X-ANIWATCH-CACHE-EXPIRY`: this custom header is used to specify the cache expiration duration in **seconds** (defaults to 60 if the header is missing). The `ANIWATCH_API_REDIS_CONN_URL` env is required for this custom header to function as intended; otherwise, there's no point in setting this custom header. - `Aniwatch-Cache-Expiry`: This custom request header is used to specify the cache expiration duration in **seconds** (defaults to 300 or 5 mins if the header is missing). The `ANIWATCH_API_REDIS_CONN_URL` env is required for this custom header to function as intended; otherwise, there's no point in setting this custom request header. A **response header** of the same name is also returned with the value set to the cache expiration duration in seconds if `ANIWATCH_API_REDIS_CONN_URL` env is set.
### Environment Variables ### Environment Variables
More info can be found in the [`.env.example`](https://github.com/ghoshRitesh12/aniwatch-api/blob/main/.env.example) file, where envs' having a value that is contained within `<` `>` angled brackets, commented out or not, are just examples and should be replaced with relevant ones. More info can be found in the [`.env.example`](https://github.com/ghoshRitesh12/aniwatch-api/blob/main/.env.example) file, where envs' having a value that is contained within `<` `>` angled brackets, commented out or not, are just examples and should be replaced with relevant ones.
- `ANIWATCH_API_PORT`: port number of the aniwatch API. - `ANIWATCH_API_PORT`: Port number of the aniwatch API.
- `ANIWATCH_API_WINDOW_MS`: duration to track requests for rate limiting (in milliseconds). - `ANIWATCH_API_WINDOW_MS`: Duration to track requests for rate limiting (in milliseconds).
- `ANIWATCH_API_MAX_REQS`: maximum number of requests in the `ANIWATCH_API_WINDOW_MS` time period. - `ANIWATCH_API_MAX_REQS`: Maximum number of requests in the `ANIWATCH_API_WINDOW_MS` time period.
- `ANIWATCH_API_CORS_ALLOWED_ORIGINS`: allowed origins, separated by commas and no spaces in between. - `ANIWATCH_API_CORS_ALLOWED_ORIGINS`: Allowed origins, separated by commas and no spaces in between (CSV).
- `ANIWATCH_API_VERCEL_DEPLOYMENT`: required for distinguishing Vercel deployment from other ones; set it to true or any other non-zero value. - `ANIWATCH_API_DEPLOYMENT_ENV`: The deployment environment of the Aniwatch API. Many configurations depend on this env, rate limiting being one of them. It must be set incase of serverless deployments; otherwise, you may run into weird issues. Possible values: `'nodejs' | 'docker' | 'vercel' | 'render' | 'cloudflare-workers'`.
- `ANIWATCH_API_HOSTNAME`: set this to your api instance's hostname to enable rate limiting, don't have this value if you don't wish to rate limit. - `ANIWATCH_API_HOSTNAME`: Set this to your api instance's hostname to enable rate limiting, don't have this value if you don't wish to rate limit.
- `ANIWATCH_API_REDIS_CONN_URL`: this env is optional by default and can be set to utilize Redis caching functionality. It has to be a valid connection URL; otherwise, the Redis client can throw unexpected errors. - `ANIWATCH_API_REDIS_CONN_URL`: This env is optional by default and can be set to utilize Redis caching functionality. It has to be a valid connection URL; otherwise, the Redis client can throw unexpected errors.
- `ANIWATCH_API_S_MAXAGE`: specifies the maximum amount of time (in seconds) a resource is considered fresh when served by a CDN cache. - `ANIWATCH_API_S_MAXAGE`: Specifies the maximum amount of time (in seconds) a resource is considered fresh when served by a CDN cache.
- `ANIWATCH_API_STALE_WHILE_REVALIDATE`: specifies the amount of time (in seconds) a resource is served stale while a new one is fetched. - `ANIWATCH_API_STALE_WHILE_REVALIDATE`: Specifies the amount of time (in seconds) a resource is served stale while a new one is fetched.
## <span id="host-your-instance">⛅ Host your instance</span> ## <span id="host-your-instance">⛅ Host your instance</span>
@@ -161,22 +161,27 @@ More info can be found in the [`.env.example`](https://github.com/ghoshRitesh12/
> >
> - If you want to have rate limiting in your application, then set the `ANIWATCH_API_HOSTNAME` env to your deployed instance's hostname; otherwise, don't set or have this env at all. If you set this env to an incorrect value, you may face other issues. > - If you want to have rate limiting in your application, then set the `ANIWATCH_API_HOSTNAME` env to your deployed instance's hostname; otherwise, don't set or have this env at all. If you set this env to an incorrect value, you may face other issues.
> - It's optional by default, but if you want to have endpoint response caching functionality, then set the `ANIWATCH_API_REDIS_CONN_URL` env to a valid Redis connection URL. If the connection URL is invalid, the Redis client can throw unexpected errors. > - It's optional by default, but if you want to have endpoint response caching functionality, then set the `ANIWATCH_API_REDIS_CONN_URL` env to a valid Redis connection URL. If the connection URL is invalid, the Redis client can throw unexpected errors.
> - Remove the if block from the [`server.ts`](https://github.com/ghoshRitesh12/aniwatch-api/blob/main/src/server.ts) file, spanning from lines [61](https://github.com/ghoshRitesh12/aniwatch-api/blob/main/src/server.ts#L61) to [85](https://github.com/ghoshRitesh12/aniwatch-api/blob/main/src/server.ts#L85). > - You **may or may not** wanna remove the last `if` block within the [IIFE](https://developer.mozilla.org/en-US/docs/Glossary/IIFE) in the [`server.ts`](https://github.com/ghoshRitesh12/aniwatch-api/blob/main/src/server.ts) file. It is for **render free deployments** only, as their free tier has an approx 10 or 15 minute sleep time. That `if` block keeps the server awake and prevents it from sleeping. You can enable the automatic health check by setting the environment variables `ANIWATCH_API_HOSTNAME` to your deployment's hostname, and `ANIWATCH_API_DEPLOYMENT_ENV` to `render` in your environment variables. If you are not using render, you can remove that `if` block.
> - If you are using a serverless deployment, then set the `ANIWATCH_API_DEPLOYMENT_ENV` env to `vercel` or `render` or `cloudflare-workers` depending on your deployment platform. This is because the API uses this env to configure different functionalities, such as rate limiting, graceful shutdown or hosting static files.
### Vercel ### Vercel
Deploy your own instance of Aniwatch API on Vercel. Deploy your own instance of Aniwatch API on Vercel.
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/ghoshRitesh12/aniwatch-api)
> [!NOTE] > [!NOTE]
> >
> When deploying to vercel, set an env named `ANIWATCH_API_VERCEL_DEPLOYMENT` to `true` or any non-zero value, but this env must be present. > When deploying to vercel, you must set the env named `ANIWATCH_API_DEPLOYMENT_ENV` to `vercel` for proper functioning of the API.
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/ghoshRitesh12/aniwatch-api)
### Render ### Render
Deploy your own instance of Aniwatch API on Render. Deploy your own instance of Aniwatch API on Render.
> [!NOTE]
>
> When deploying to render, you must set the env named `ANIWATCH_API_DEPLOYMENT_ENV` to `render` for proper functioning of the API.
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/ghoshRitesh12/aniwatch-api) [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/ghoshRitesh12/aniwatch-api)
## <span id="documentation">📚 Documentation</span> ## <span id="documentation">📚 Documentation</span>
+13 -12
View File
@@ -1,18 +1,19 @@
{ {
"name": "aniwatch-api", "name": "aniwatch-api",
"version": "2.14.5", "version": "2.14.5",
"description": "Node.js API for obtaining anime information from hianimez.to", "description": "NodeJS API for obtaining anime data from hianimez.to",
"main": "src/server.ts", "main": "dist/src/server.js",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "tsx src/server.ts", "start": "node dist/src/server.js",
"dev": "tsx watch src/server.ts", "dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json", "build": "tsc",
"vercel-build": "echo \"Hello\"", "buildnstart": "tsc && node dist/src/server.js",
"vercel-build": "echo hello",
"prepare": "node .husky/install.mjs", "prepare": "node .husky/install.mjs",
"test": "vitest run --config vitest.config.ts", "test": "vitest run --config vitest.config.ts",
"healthcheck": "curl -f http://localhost:4000/health", "healthcheck": "curl -f http://localhost:4000/health",
"lint": "tsc", "typecheck": "tsc --noEmit",
"format": "prettier --cache --write .", "format": "prettier --cache --write .",
"format:check": "prettier --cache --check ." "format:check": "prettier --cache --check ."
}, },
@@ -36,22 +37,22 @@
"author": "https://github.com/ghoshRitesh12", "author": "https://github.com/ghoshRitesh12",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@hono/node-server": "^1.14.1", "@hono/node-server": "^1.14.2",
"aniwatch": "^2.23.0", "aniwatch": "^2.23.0",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"envalid": "^8.0.0", "envalid": "^8.0.0",
"hono": "^4.7.9", "hono": "^4.7.10",
"hono-rate-limiter": "^0.4.2", "hono-rate-limiter": "^0.4.2",
"ioredis": "^5.6.1", "ioredis": "^5.6.1",
"pino": "^9.6.0", "pino": "^9.7.0"
"tsx": "^4.19.4"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.15.17", "@types/node": "^22.15.21",
"husky": "^9.1.7", "husky": "^9.1.7",
"pino-pretty": "^13.0.0", "pino-pretty": "^13.0.0",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"tsx": "^4.19.4",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vitest": "^3.1.3" "vitest": "^3.1.4"
} }
} }
+10 -11
View File
@@ -23,13 +23,13 @@
<meta name="twitter:card" content="summary_image"> <meta name="twitter:card" content="summary_image">
<meta name="twitter:site" content="@aniwatch-api"> <meta name="twitter:site" content="@aniwatch-api">
<meta name="twitter:title" content="Aniwatch API"> <meta name="twitter:title" content="Aniwatch API">
<meta name="twitter:description" content="Node.js API for obtaining anime information from hianimez.to"> <meta name="twitter:description" content="NodeJS API for obtaining anime data from hianimez.to">
<meta name="twitter:image:src" <meta name="twitter:image:src"
content="https://raw.githubusercontent.com/ghoshRitesh12/aniwatch-api/refs/heads/main/public/img/hianime_v2.png"> content="https://raw.githubusercontent.com/ghoshRitesh12/aniwatch-api/refs/heads/main/public/img/hianime_v2.png">
<meta name="keywords" content="hianime api scraper anime aniwatch node express typescript"> <meta name="keywords" content="hianime api scraper anime aniwatch node express typescript">
<meta property="og:title" content="Aniwatch API"> <meta property="og:title" content="Aniwatch API">
<meta name="description" content="Node.js API for obtaining anime information from hianimez.to"> <meta name="description" content="NodeJS API for obtaining anime data from hianimez.to">
<meta property="og:description" content="Node.js API for obtaining anime information from hianimez.to"> <meta property="og:description" content="NodeJS API for obtaining anime data from hianimez.to">
<link rel="shortcut icon" <link rel="shortcut icon"
href="https://raw.githubusercontent.com/ghoshRitesh12/aniwatch-api/refs/heads/main/public/img/hianime_v2.png"> href="https://raw.githubusercontent.com/ghoshRitesh12/aniwatch-api/refs/heads/main/public/img/hianime_v2.png">
@@ -202,11 +202,12 @@
<!-- <!--
IMPORTANT: IMPORTANT:
1. The hosted version of this API is only meant to demo the API and would have rate-limiting enabled to minimise bandwidth 1. This API is just an unofficial API for hianimez.to and
consumption. It is recommended to deploy your own instance for personal use by customizing the API as you need it to be. is in no other way officially related to the same.
2. This API is just an unofficial API for hianimez.to and is in no other way officially related to the same. 2. The content that this API provides is not mine, nor is
3. The content that this API provides is not mine, nor is it hosted by me. These belong to their respective owners. This it hosted by me. These belong to their respective
API just demonstrates how to build an API that scrapes websites and uses their content. owners. This API just demonstrates how to build an API
that scrapes websites and uses their content.
--> -->
<body> <body>
@@ -216,9 +217,7 @@ API just demonstrates how to build an API that scrapes websites and uses their c
<section> <section>
<h2> <h2>
Welcome to the unofficial Welcome to the Aniwatch API
<a href="https://hianimez.to/home" style="text-underline-offset: 3px;">hianimez.to</a>
API
<span style="-webkit-text-fill-color: var(--accent)">⚔️</span> <span style="-webkit-text-fill-color: var(--accent)">⚔️</span>
</h2> </h2>
+29 -20
View File
@@ -5,15 +5,17 @@ export class AniwatchAPICache {
private static instance: AniwatchAPICache | null = null; private static instance: AniwatchAPICache | null = null;
private client: Redis | null; private client: Redis | null;
public isOptional: boolean = true; public enabled: boolean = false;
static DEFAULT_CACHE_EXPIRY_SECONDS = 60 as const; static enabled = false;
static CACHE_EXPIRY_HEADER_NAME = "X-ANIWATCH-CACHE-EXPIRY" as const; // 5 mins, 5 * 60
static DEFAULT_CACHE_EXPIRY_SECONDS = 300 as const;
static CACHE_EXPIRY_HEADER_NAME = "Aniwatch-Cache-Expiry" as const;
constructor() { constructor() {
const redisConnURL = env.ANIWATCH_API_REDIS_CONN_URL; const redisConnURL = env.ANIWATCH_API_REDIS_CONN_URL;
this.isOptional = !Boolean(redisConnURL); this.enabled = AniwatchAPICache.enabled = Boolean(redisConnURL);
this.client = this.isOptional ? null : new Redis(String(redisConnURL)); this.client = this.enabled ? new Redis(String(redisConnURL)) : null;
} }
static getInstance() { static getInstance() {
@@ -23,27 +25,17 @@ export class AniwatchAPICache {
return AniwatchAPICache.instance; return AniwatchAPICache.instance;
} }
// set(key: string | Buffer, value: string | Buffer | number) {
// if (this.isOptional) return;
// return this.client?.set(key, value);
// }
// get(key: string | Buffer) {
// if (this.isOptional) return;
// return this.client?.get(key);
// }
/** /**
* @param expirySeconds set to 60 by default * @param expirySeconds set to 300 (5 mins) by default
*/ */
async getOrSet<T>( async getOrSet<T>(
dataGetter: () => Promise<T>, dataGetter: () => Promise<T>,
key: string | Buffer, key: string,
expirySeconds: number = AniwatchAPICache.DEFAULT_CACHE_EXPIRY_SECONDS expirySeconds: number = AniwatchAPICache.DEFAULT_CACHE_EXPIRY_SECONDS
) { ) {
const cachedData = this.isOptional const cachedData = this.enabled
? null ? (await this.client?.get?.(key)) || null
: (await this.client?.get?.(key)) || null; : null;
let data = JSON.parse(String(cachedData)) as T; let data = JSON.parse(String(cachedData)) as T;
if (!data) { if (!data) {
@@ -57,6 +49,23 @@ export class AniwatchAPICache {
} }
return data; return data;
} }
closeConnection() {
this.client
?.quit()
?.then(() => {
this.client = null;
AniwatchAPICache.instance = null;
console.info(
"aniwatch-api redis connection closed and cache instance reset"
);
})
.catch((err) => {
console.error(
`aniwatch-api error while closing redis connection: ${err}`
);
});
}
} }
export const cache = AniwatchAPICache.getInstance(); export const cache = AniwatchAPICache.getInstance();
+12
View File
@@ -0,0 +1,12 @@
import type { Prettify } from "../utils.js";
type ServerContextVariables = Prettify<{
CACHE_CONFIG: {
key: string;
duration: number;
};
}>;
export type ServerContext = Prettify<{
Variables: ServerContextVariables;
}>;
+23 -9
View File
@@ -1,8 +1,23 @@
import { config } from "dotenv"; import { config } from "dotenv";
import { cleanEnv, num, str, bool, url, port } from "envalid"; import { cleanEnv, num, str, url, port } from "envalid";
config(); config();
export enum DeploymentEnv {
NODEJS = "nodejs",
DOCKER = "docker",
VERCEL = "vercel",
CLOUDFLARE_WORKERS = "cloudflare-workers",
RENDER = "render",
}
export const API_DEPLOYMENT_ENVIRONMENTS = Object.values(DeploymentEnv);
export const SERVERLESS_ENVIRONMENTS = [
DeploymentEnv.VERCEL,
DeploymentEnv.CLOUDFLARE_WORKERS,
DeploymentEnv.RENDER,
];
//
export const env = cleanEnv(process.env, { export const env = cleanEnv(process.env, {
ANIWATCH_API_PORT: port({ ANIWATCH_API_PORT: port({
default: 4000, default: 4000,
@@ -28,9 +43,12 @@ export const env = cleanEnv(process.env, {
desc: "Allowed origins, separated by commas and no spaces in between (CSV).", desc: "Allowed origins, separated by commas and no spaces in between (CSV).",
}), }),
ANIWATCH_API_VERCEL_DEPLOYMENT: bool({ ANIWATCH_API_DEPLOYMENT_ENV: str({
default: false, choices: API_DEPLOYMENT_ENVIRONMENTS,
desc: "Required for distinguishing Vercel deployment from other ones; set it to true", default: DeploymentEnv.NODEJS,
example: DeploymentEnv.VERCEL,
desc: "The deployment environment of the Aniwatch API. It must be set incase of serverless deployments, otherwise you may run into weird issues.",
docs: "https://github.com/ghoshRitesh12/aniwatch-api/blob/main/.env.example#L21",
}), }),
ANIWATCH_API_HOSTNAME: str({ ANIWATCH_API_HOSTNAME: str({
@@ -44,7 +62,7 @@ export const env = cleanEnv(process.env, {
default: undefined, default: undefined,
example: example:
"rediss://default:your-secure-password@your-redis-instance-name.provider.com:6379", "rediss://default:your-secure-password@your-redis-instance-name.provider.com:6379",
desc: "This env is optional by default and can be set to utilize Redis caching functionality. It has to be a valid connection URL.", desc: "This env is optional by default and can be set to utilize Redis caching functionality. It has to be a valid connection URL; otherwise, the Redis client can throw unexpected errors",
}), }),
ANIWATCH_API_S_MAXAGE: num({ ANIWATCH_API_S_MAXAGE: num({
@@ -72,7 +90,3 @@ function isDevEnv(): boolean {
process.env.NODE_ENV === "test" process.env.NODE_ENV === "test"
); );
} }
export function isEnvUndefined(envVar?: string): boolean {
return typeof envVar === "undefined" || envVar === "undefined";
}
+24 -7
View File
@@ -1,11 +1,27 @@
import { rateLimiter } from "hono-rate-limiter"; import { rateLimiter } from "hono-rate-limiter";
import { getConnInfo } from "@hono/node-server/conninfo"; import { getConnInfo as vercelGetConnInfo } from "hono/vercel";
import { env } from "./env.js"; import { getConnInfo as cfWorkersGetConnInfo } from "hono/cloudflare-workers";
import { getConnInfo as nodejsGetConnInfo } from "@hono/node-server/conninfo";
import type { GetConnInfo } from "hono/conninfo";
import { DeploymentEnv, env } from "./env.js";
let getConnInfo: GetConnInfo;
switch (env.ANIWATCH_API_DEPLOYMENT_ENV) {
case DeploymentEnv.VERCEL:
getConnInfo = vercelGetConnInfo;
break;
case DeploymentEnv.CLOUDFLARE_WORKERS:
getConnInfo = cfWorkersGetConnInfo;
break;
default:
getConnInfo = nodejsGetConnInfo;
}
export const ratelimit = rateLimiter({ export const ratelimit = rateLimiter({
windowMs: env.ANIWATCH_API_WINDOW_MS,
limit: env.ANIWATCH_API_MAX_REQS,
standardHeaders: "draft-7", standardHeaders: "draft-7",
limit: env.ANIWATCH_API_MAX_REQS,
windowMs: env.ANIWATCH_API_WINDOW_MS,
keyGenerator(c) { keyGenerator(c) {
const { remote } = getConnInfo(c); const { remote } = getConnInfo(c);
const key = const key =
@@ -14,9 +30,10 @@ export const ratelimit = rateLimiter({
return key; return key;
}, },
handler: (c) => handler(c) {
c.json( return c.json(
{ status: 429, message: "Too Many Requests 😵" }, { status: 429, message: "Too Many Requests 😵" },
{ status: 429 } { status: 429 }
), );
},
}); });
-8
View File
@@ -1,8 +0,0 @@
type CacheVariables = {
CACHE_CONFIG: {
key: string;
duration: number;
};
};
export type AniwatchAPIVariables = CacheVariables & {};
+39 -6
View File
@@ -1,6 +1,8 @@
import type { MiddlewareHandler } from "hono";
import { env } from "../config/env.js"; import { env } from "../config/env.js";
import { AniwatchAPICache } from "../config/cache.js"; import { AniwatchAPICache, cache } from "../config/cache.js";
import type { BlankInput } from "hono/types";
import type { Context, MiddlewareHandler } from "hono";
import type { ServerContext } from "../config/context.js";
// Define middleware to add Cache-Control header // Define middleware to add Cache-Control header
export const cacheControl: MiddlewareHandler = async (c, next) => { export const cacheControl: MiddlewareHandler = async (c, next) => {
@@ -19,14 +21,45 @@ export function cacheConfigSetter(keySliceIndex: number): MiddlewareHandler {
return async (c, next) => { return async (c, next) => {
const { pathname, search } = new URL(c.req.url); const { pathname, search } = new URL(c.req.url);
c.set("CACHE_CONFIG", { const duration = Number(
key: `${pathname.slice(keySliceIndex) + search}`,
duration: Number(
c.req.header(AniwatchAPICache.CACHE_EXPIRY_HEADER_NAME) || c.req.header(AniwatchAPICache.CACHE_EXPIRY_HEADER_NAME) ||
AniwatchAPICache.DEFAULT_CACHE_EXPIRY_SECONDS AniwatchAPICache.DEFAULT_CACHE_EXPIRY_SECONDS
), );
c.set("CACHE_CONFIG", {
key: `${pathname.slice(keySliceIndex) + search}`,
duration,
}); });
if (AniwatchAPICache.enabled) {
c.res.headers.set(
AniwatchAPICache.CACHE_EXPIRY_HEADER_NAME,
duration.toString()
);
}
await next(); await next();
}; };
} }
export function withCache<T, P extends string = string>(
getData: (c: Context<ServerContext, P, BlankInput>) => Promise<T>
) {
return async (c: Context<ServerContext, P, BlankInput>) => {
const cacheConfig = c.get("CACHE_CONFIG");
const data = await cache.getOrSet<T>(
() => getData(c),
cacheConfig.key,
cacheConfig.duration
);
return c.json({ status: 200, data }, { status: 200 });
};
}
// export function _withCache<T>(
// context: Context<ServerContext>,
// promise: Promise<T>
// ): MiddlewareHandler {
// return async (c) => {};
// }
+16 -16
View File
@@ -1,10 +1,10 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { HiAnime } from "aniwatch"; import { HiAnime } from "aniwatch";
import { cache } from "../config/cache.js"; import { cache } from "../config/cache.js";
import type { AniwatchAPIVariables } from "../config/variables.js"; import type { ServerContext } from "../config/context.js";
const hianime = new HiAnime.Scraper(); const hianime = new HiAnime.Scraper();
const hianimeRouter = new Hono<{ Variables: AniwatchAPIVariables }>(); const hianimeRouter = new Hono<ServerContext>();
// /api/v2/hianime // /api/v2/hianime
hianimeRouter.get("/", (c) => c.redirect("/", 301)); hianimeRouter.get("/", (c) => c.redirect("/", 301));
@@ -19,7 +19,7 @@ hianimeRouter.get("/home", async (c) => {
cacheConfig.duration cacheConfig.duration
); );
return c.json({ success: true, data }, { status: 200 }); return c.json({ status: 200, data }, { status: 200 });
}); });
// /api/v2/hianime/azlist/{sortOption}?page={page} // /api/v2/hianime/azlist/{sortOption}?page={page}
@@ -38,7 +38,7 @@ hianimeRouter.get("/azlist/:sortOption", async (c) => {
cacheConfig.duration cacheConfig.duration
); );
return c.json({ success: true, data }, { status: 200 }); return c.json({ status: 200, data }, { status: 200 });
}); });
// /api/v2/hianime/qtip/{animeId} // /api/v2/hianime/qtip/{animeId}
@@ -52,7 +52,7 @@ hianimeRouter.get("/qtip/:animeId", async (c) => {
cacheConfig.duration cacheConfig.duration
); );
return c.json({ success: true, data }, { status: 200 }); return c.json({ status: 200, data }, { status: 200 });
}); });
// /api/v2/hianime/category/{name}?page={page} // /api/v2/hianime/category/{name}?page={page}
@@ -70,7 +70,7 @@ hianimeRouter.get("/category/:name", async (c) => {
cacheConfig.duration cacheConfig.duration
); );
return c.json({ success: true, data }, { status: 200 }); return c.json({ status: 200, data }, { status: 200 });
}); });
// /api/v2/hianime/genre/{name}?page={page} // /api/v2/hianime/genre/{name}?page={page}
@@ -86,7 +86,7 @@ hianimeRouter.get("/genre/:name", async (c) => {
cacheConfig.duration cacheConfig.duration
); );
return c.json({ success: true, data }, { status: 200 }); return c.json({ status: 200, data }, { status: 200 });
}); });
// /api/v2/hianime/producer/{name}?page={page} // /api/v2/hianime/producer/{name}?page={page}
@@ -102,7 +102,7 @@ hianimeRouter.get("/producer/:name", async (c) => {
cacheConfig.duration cacheConfig.duration
); );
return c.json({ success: true, data }, { status: 200 }); return c.json({ status: 200, data }, { status: 200 });
}); });
// /api/v2/hianime/schedule?date={date}&tzOffset={tzOffset} // /api/v2/hianime/schedule?date={date}&tzOffset={tzOffset}
@@ -121,7 +121,7 @@ hianimeRouter.get("/schedule", async (c) => {
cacheConfig.duration cacheConfig.duration
); );
return c.json({ success: true, data }, { status: 200 }); return c.json({ status: 200, data }, { status: 200 });
}); });
// /api/v2/hianime/search?q={query}&page={page}&filters={...filters} // /api/v2/hianime/search?q={query}&page={page}&filters={...filters}
@@ -138,7 +138,7 @@ hianimeRouter.get("/search", async (c) => {
cacheConfig.duration cacheConfig.duration
); );
return c.json({ success: true, data }, { status: 200 }); return c.json({ status: 200, data }, { status: 200 });
}); });
// /api/v2/hianime/search/suggestion?q={query} // /api/v2/hianime/search/suggestion?q={query}
@@ -152,7 +152,7 @@ hianimeRouter.get("/search/suggestion", async (c) => {
cacheConfig.duration cacheConfig.duration
); );
return c.json({ success: true, data }, { status: 200 }); return c.json({ status: 200, data }, { status: 200 });
}); });
// /api/v2/hianime/anime/{animeId} // /api/v2/hianime/anime/{animeId}
@@ -166,7 +166,7 @@ hianimeRouter.get("/anime/:animeId", async (c) => {
cacheConfig.duration cacheConfig.duration
); );
return c.json({ success: true, data }, { status: 200 }); return c.json({ status: 200, data }, { status: 200 });
}); });
// /api/v2/hianime/episode/servers?animeEpisodeId={id} // /api/v2/hianime/episode/servers?animeEpisodeId={id}
@@ -182,7 +182,7 @@ hianimeRouter.get("/episode/servers", async (c) => {
cacheConfig.duration cacheConfig.duration
); );
return c.json({ success: true, data }, { status: 200 }); return c.json({ status: 200, data }, { status: 200 });
}); });
// episodeId=steinsgate-3?ep=230 // episodeId=steinsgate-3?ep=230
@@ -206,7 +206,7 @@ hianimeRouter.get("/episode/sources", async (c) => {
cacheConfig.duration cacheConfig.duration
); );
return c.json({ success: true, data }, { status: 200 }); return c.json({ status: 200, data }, { status: 200 });
}); });
// /api/v2/hianime/anime/{anime-id}/episodes // /api/v2/hianime/anime/{anime-id}/episodes
@@ -220,7 +220,7 @@ hianimeRouter.get("/anime/:animeId/episodes", async (c) => {
cacheConfig.duration cacheConfig.duration
); );
return c.json({ success: true, data }, { status: 200 }); return c.json({ status: 200, data }, { status: 200 });
}); });
// /api/v2/hianime/anime/{anime-id}/next-episode-schedule // /api/v2/hianime/anime/{anime-id}/next-episode-schedule
@@ -234,7 +234,7 @@ hianimeRouter.get("/anime/:animeId/next-episode-schedule", async (c) => {
cacheConfig.duration cacheConfig.duration
); );
return c.json({ success: true, data }, { status: 200 }); return c.json({ status: 200, data }, { status: 200 });
}); });
export { hianimeRouter }; export { hianimeRouter };
+71 -21
View File
@@ -4,12 +4,13 @@ import { Hono } from "hono";
import { serve } from "@hono/node-server"; import { serve } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static"; import { serveStatic } from "@hono/node-server/serve-static";
import { env } from "./config/env.js";
import { log } from "./config/logger.js"; import { log } from "./config/logger.js";
import { corsConfig } from "./config/cors.js"; import { corsConfig } from "./config/cors.js";
import { ratelimit } from "./config/ratelimit.js"; import { ratelimit } from "./config/ratelimit.js";
import { execGracefulShutdown } from "./utils.js";
import { DeploymentEnv, env, SERVERLESS_ENVIRONMENTS } from "./config/env.js";
import { errorHandler, notFoundHandler } from "./config/errorHandler.js"; import { errorHandler, notFoundHandler } from "./config/errorHandler.js";
import type { AniwatchAPIVariables } from "./config/variables.js"; import type { ServerContext } from "./config/context.js";
import { hianimeRouter } from "./routes/hianime.js"; import { hianimeRouter } from "./routes/hianime.js";
import { logging } from "./middleware/logging.js"; import { logging } from "./middleware/logging.js";
@@ -20,22 +21,30 @@ import pkgJson from "../package.json" with { type: "json" };
// //
const BASE_PATH = "/api/v2" as const; const BASE_PATH = "/api/v2" as const;
const app = new Hono<{ Variables: AniwatchAPIVariables }>(); const app = new Hono<ServerContext>();
app.use(logging); app.use(logging);
app.use(corsConfig); app.use(corsConfig);
app.use(cacheControl); app.use(cacheControl);
// /*
// CAUTION: For personal deployments, "refrain" from having an env CAUTION:
// named "ANIWATCH_API_HOSTNAME". You may face rate limitting Having the "ANIWATCH_API_HOSTNAME" env will
// or other issues if you do. enable rate limitting for the deployment.
const ISNT_PERSONAL_DEPLOYMENT = Boolean(env.ANIWATCH_API_HOSTNAME); WARNING:
if (ISNT_PERSONAL_DEPLOYMENT) { If you are using any serverless environment, you must set the
"ANIWATCH_API_DEPLOYMENT_ENV" to that environment's name,
otherwise you may face issues.
*/
const isPersonalDeployment = Boolean(env.ANIWATCH_API_HOSTNAME);
if (isPersonalDeployment) {
app.use(ratelimit); app.use(ratelimit);
} }
// if (env.ANIWATCH_API_DEPLOYMENT_ENV === DeploymentEnv.NODEJS) {
app.use("/", serveStatic({ root: "public" })); app.use("/", serveStatic({ root: "public" }));
// }
app.get("/health", (c) => c.text("daijoubu", { status: 200 })); app.get("/health", (c) => c.text("daijoubu", { status: 200 }));
app.get("/v", async (c) => app.get("/v", async (c) =>
c.text( c.text(
@@ -55,31 +64,72 @@ app.notFound(notFoundHandler);
app.onError(errorHandler); app.onError(errorHandler);
// //
// NOTE: this env is "required" for vercel deployments (function () {
if (!env.ANIWATCH_API_VERCEL_DEPLOYMENT) { /*
serve({ NOTE:
"ANIWATCH_API_DEPLOYMENT_MODE" env must be set to
its supported name for serverless deployments
Eg: "vercel" for vercel deployments
*/
if (SERVERLESS_ENVIRONMENTS.includes(env.ANIWATCH_API_DEPLOYMENT_ENV)) {
return;
}
const server = serve({
port: env.ANIWATCH_API_PORT, port: env.ANIWATCH_API_PORT,
fetch: app.fetch, fetch: app.fetch,
}).addListener("listening", () => { }).addListener("listening", () =>
log.info( log.info(
`aniwatch-api RUNNING at http://localhost:${env.ANIWATCH_API_PORT}` `aniwatch-api RUNNING at http://localhost:${env.ANIWATCH_API_PORT}`
)
); );
process.on("SIGINT", () => execGracefulShutdown(server));
process.on("SIGTERM", () => execGracefulShutdown(server));
process.on("uncaughtException", (err) => {
log.error(`Uncaught Exception: ${err.message}`);
execGracefulShutdown(server);
});
process.on("unhandledRejection", (reason, promise) => {
log.error(
`Unhandled Rejection at: ${promise}, reason: ${reason instanceof Error ? reason.message : reason}`
);
execGracefulShutdown(server);
}); });
// NOTE: remove the `if` block below for personal deployments /*
if (ISNT_PERSONAL_DEPLOYMENT) { CAUTION:
const interval = 9 * 60 * 1000; // 9mins The `if` below block is for `render free deployments` only,
as their free tier has an approx 10 or 15 minute sleep time.
This is to keep the server awake and prevent it from sleeping.
You can enable the automatic health check by setting the
environment variables "ANIWATCH_API_HOSTNAME" to your deployment's hostname,
and "ANIWATCH_API_DEPLOYMENT_ENV" to "render" in your environment variables.
If you are not using render, you can remove the below `if` block.
*/
if (
isPersonalDeployment &&
env.ANIWATCH_API_DEPLOYMENT_ENV === DeploymentEnv.RENDER
) {
const INTERVAL_DELAY = 8 * 60 * 1000; // 8mins
const url = new URL(`https://${env.ANIWATCH_API_HOSTNAME}/health`);
// don't sleep // don't sleep
setInterval(() => { setInterval(() => {
https
.get(url.href)
.on("response", () => {
log.info( log.info(
`aniwatch-api HEALTH_CHECK at ${new Date().toISOString()}` `aniwatch-api HEALTH_CHECK at ${new Date().toISOString()}`
); );
https })
.get(`https://${env.ANIWATCH_API_HOSTNAME}/health`) .on("error", (err) =>
.on("error", (err) => log.error(err.message.trim())); log.warn(
}, interval); `aniwatch-api HEALTH_CHECK failed; ${err.message.trim()}`
} )
);
}, INTERVAL_DELAY);
} }
})();
export default app; export default app;
+18
View File
@@ -0,0 +1,18 @@
import { cache } from "./config/cache.js";
import type { ServerType } from "@hono/node-server";
export type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
export function execGracefulShutdown(server: ServerType) {
process.stdout.write("\naniwatch-api SHUTTING DOWN gracefully...\n");
cache.closeConnection();
server.close((err) => {
process.stdout.write("\naniwatch-api SHUTDOWN complete.\n");
err ? console.error(err) : null;
process.exit(err ? 1 : 0);
});
process.exit(0);
}