/* * ======= • ======= • ======= • ======= • =======• ======= * MiruroAPI — worker.js (Cloudflare Worker Edition) * Repository: https://github.com/Shineii86/MiruroAPI * * @description * Cloudflare Worker version of MiruroAPI. Runs entirely on * Cloudflare's edge network — pipe requests bypass bot detection * automatically since they route through Cloudflare's internal network. * * Key advantages over Vercel deployment: * - Pipe requests NOT blocked by Cloudflare (edge → edge) * - Zero cold starts (V8 isolates) * - Free tier: 100K requests/day * - No npm dependencies required (native fetch + DecompressionStream) * * @endpoints * System: /api/health * Search: /api/search, /api/suggestions, /api/filter * Collections: /api/trending, /api/popular, /api/top, /api/random * Details: /api/info/:id, /api/anime/:id/characters, /api/anime/:id/relations * Streaming: /api/episodes/:id, /api/watch/:provider/:anilistId/:category/:slug * Metadata: /api/genres * * @author Shinei Nouzen * @license MIT * ======= • ======= • ======= • ======= • =======• ======= */ // ══════════════════════════════════════════════════════════════ // CONFIGURATION // ══════════════════════════════════════════════════════════════ /** * AniList GraphQL endpoint. * All metadata (search, trending, info, characters) comes from here. * * @type {string} */ const ANILIST_URL = "https://graphql.anilist.co"; /** * Miruro pipe origins, ordered by reliability. * The worker tries each mirror in sequence until one succeeds. * Running on Cloudflare edge means these requests go through * Cloudflare's internal network — no bot detection triggered. * * @type {string[]} */ const MIRURO_ORIGINS = [ "https://www.miruro.to", "https://www.miruro.ru", "https://www.miruro.bz", "https://www.miruro.tv", ]; /** * Canonical Miruro origin for referer/origin headers. * * @type {string} */ const CANONICAL_ORIGIN = "https://www.miruro.to"; /** * Pipe API path — appended to each origin. * * @type {string} */ const PIPE_PATH = "/api/secure/pipe"; /** * XOR obfuscation key for pipe response decoding (scheme 2). * 16-byte hex key used when x-obfuscated header = "2". * * @type {Uint8Array} */ const PIPE_OBF_KEY = new Uint8Array([ 0x71, 0x95, 0x10, 0x34, 0xf8, 0xfb, 0xcf, 0x53, 0xd8, 0x9d, 0xb5, 0x2c, 0xeb, 0x3d, 0xc2, 0x2c, ]); /** * Browser-accurate request headers for Cloudflare fingerprinting. * These headers must match what a real Chrome browser sends — * missing or incorrect headers trigger Cloudflare's bot detection. * * @type {Object} */ const HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.5", "sec-ch-ua": '"Chromium";v="131", "Not_A Brand";v="24"', "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": '"Windows"', "sec-fetch-dest": "document", "sec-fetch-mode": "navigate", "sec-fetch-site": "none", "sec-fetch-user": "?1", Referer: `${CANONICAL_ORIGIN}/`, Origin: CANONICAL_ORIGIN, }; // ══════════════════════════════════════════════════════════════ // CORS & RESPONSE UTILITIES // ══════════════════════════════════════════════════════════════ /** * Standard CORS headers applied to every response. * Allows any origin to call the API (open CORS policy). * * @type {Object} */ const CORS = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", }; /** * Returns a JSON response with CORS headers. * * @param {*} data - Data to serialize as JSON * @param {number} [status=200] - HTTP status code * @returns {Response} Cloudflare Worker Response object */ function json(data, status = 200) { return new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json", ...CORS }, }); } /** * Returns a standardized error response. * All errors follow the same shape: { success, message, timestamp }. * * @param {string} msg - Error description * @param {number} [status=500] - HTTP status code * @returns {Response} Error response with CORS headers */ function error(msg, status = 500) { return json( { success: false, message: msg, timestamp: new Date().toISOString() }, status ); } // ══════════════════════════════════════════════════════════════ // ANILIST GRAPHQL CLIENT // ══════════════════════════════════════════════════════════════ /** * Executes a GraphQL query against the AniList API. * * @param {string} query - GraphQL query string * @ @param {Object} [variables={}] - Query variables * @returns {Promise} Parsed data from the GraphQL response * @throws {Error} If the GraphQL response contains errors * * @example * const data = await anilistQuery(`{ GenreCollection }`); * console.log(data.GenreCollection); // ["Action", "Comedy", ...] */ async function anilistQuery(query, variables = {}) { const res = await fetch(ANILIST_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }), }); const json = await res.json(); if (json.errors) { throw new Error(json.errors[0]?.message || "AniList query failed"); } return json.data; } // ══════════════════════════════════════════════════════════════ // PIPE ENCODE / DECODE // ══════════════════════════════════════════════════════════════ /** * Encodes a pipe request payload to base64url. * The pipe endpoint expects the request as base64url-encoded JSON. * * @param {Object} payload - Request payload { path, method, query, body } * @returns {string} Base64url-encoded string (no padding) * * @example * encodePipeRequest({ path: "/api/episodes", method: "GET", query: { id: 20 } }) * // "eyJwYXRoIjoiL2FwaS9lcGlzb2RlcyIs..." */ function encodePipeRequest(payload) { return btoa(JSON.stringify(payload)) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, ""); } /** * Decodes a pipe response based on the x-obfuscated header. * * Miruro's pipe now signals the decode scheme via a response header: * - Header absent → plain JSON (no encoding) * - Header = "1" → base64url + gzip (no XOR) * - Header = "2" → base64url + XOR(PIPE_OBF_KEY) + gzip * * This function handles all three schemes automatically. * * @param {string} encodedStr - Raw response body from pipe endpoint * @param {string|null} obfHeader - Value of x-obfuscated response header * @returns {Promise} Decoded JSON object * @throws {Error} If decoding fails * * @trick * The DecompressionStream API is available in Workers but not in Node.js. * This is why we can't share this exact code with the Express version. */ async function decodePipeResponse(encodedStr, obfHeader) { // Scheme 0: plain JSON (no header) if (!obfHeader) { return JSON.parse(encodedStr); } // Step 1: base64url → raw bytes let bytes = Uint8Array.from( atob(encodedStr.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0) ); // Scheme 2: XOR decrypt before gzip // The XOR key is applied cyclically to each byte if (obfHeader === "2") { for (let i = 0; i < bytes.length; i++) { bytes[i] ^= PIPE_OBF_KEY[i % PIPE_OBF_KEY.length]; } } // Step 2: gzip → decompressed bytes const decompressed = new DecompressionStream("gzip"); const writer = decompressed.writable.getWriter(); writer.write(bytes); writer.close(); // Step 3: bytes → JSON return new Response(decompressed.readable).json(); } // ══════════════════════════════════════════════════════════════ // PIPE REQUEST (MIRROR ROTATION) // ══════════════════════════════════════════════════════════════ /** * Sends a request to the Miruro pipe endpoint with automatic mirror rotation. * * Tries each origin in MIRURO_ORIGINS order. If one returns a non-200 * status or throws, the next mirror is tried. This provides resilience * against individual mirror outages or rate limits. * * @param {string} path - Pipe API path (e.g. "/api/episodes") * @param {Object} query - Query parameters to encode * @returns {Promise} Decoded pipe response * @throws {Error} If all mirrors fail * * @trick * On Cloudflare Workers, fetch() to *.miruro.* routes through * Cloudflare's internal network — no bot detection triggered. * This is why Workers succeed where Vercel/serverless fails. */ async function pipeRequest(path, query) { const payload = { path, method: "GET", query, body: null }; const encodedReq = encodePipeRequest(payload); // Try each mirror in order for (const origin of MIRURO_ORIGINS) { try { const res = await fetch(`${origin}${PIPE_PATH}?e=${encodedReq}`, { headers: HEADERS, }); // Cloudflare returns 403 for blocked requests if (!res.ok) continue; const obf = res.headers.get("x-obfuscated"); const text = await res.text(); return await decodePipeResponse(text, obf); } catch (e) { // Mirror failed — try next one continue; } } throw new Error("All pipe mirrors failed"); } // ══════════════════════════════════════════════════════════════ // ROUTE HANDLERS // ══════════════════════════════════════════════════════════════ // ---- FEATURE: Health check endpoint ---- /** * Returns API health status, version, and runtime info. * Used by monitoring tools and uptime checkers. * * @param {URLSearchParams} params - Unused * @returns {Promise} Health status JSON */ async function healthHandler(params) { return json({ success: true, results: { status: "healthy", version: "2.3.0-worker", runtime: "cloudflare-workers", endpoints: 15, timestamp: new Date().toISOString(), }, }); } // ---- FEATURE: Full-text anime search ---- /** * Searches AniList for anime matching the given query. * Returns paginated results with full metadata per result. * * @param {URLSearchParams} params - query (required), page, per_page * @returns {Promise} Paginated search results * @throws {400} If query parameter is missing * * @example * GET /api/search?query=naruto&page=1&per_page=10 */ async function searchHandler(params) { const query = params.get("query"); if (!query) return error("query parameter is required", 400); const page = parseInt(params.get("page") || "1"); const perPage = parseInt(params.get("per_page") || "20"); const gql = `query ($search: String, $page: Int, $perPage: Int) { Page(page: $page, perPage: $perPage) { pageInfo { total currentPage hasNextPage perPage } media(search: $search, type: ANIME, sort: SEARCH_MATCH) { id title { romaji english native } coverImage { large } format season seasonYear episodes status averageScore genres popularity } } }`; const data = await anilistQuery(gql, { search: query, page, perPage }); return json({ success: true, results: data.Page }); } // ---- FEATURE: Autocomplete suggestions ---- /** * Returns fast autocomplete suggestions for search input. * Limited to 8 results for low-latency response. * * @param {URLSearchParams} params - query (required) * @returns {Promise} Array of suggestion objects */ async function suggestionsHandler(params) { const query = params.get("query"); if (!query) return error("query parameter is required", 400); const gql = `query ($search: String) { Page(page: 1, perPage: 8) { media(search: $search, type: ANIME, sort: SEARCH_MATCH) { id title { romaji english } coverImage { large } format status seasonYear episodes } } }`; const data = await anilistQuery(gql, { search: query }); return json({ success: true, results: data.Page.media }); } // ---- FEATURE: Advanced filter with multiple params ---- /** * Filters anime by genre, tag, year, season, format, and status. * All parameters are optional — combine them for narrow results. * * @param {URLSearchParams} params - genre, tag, year, season, format, status, sort, page, per_page * @returns {Promise} Filtered and paginated results * * @trick * GraphQL variables are built dynamically based on which params * are present. This avoids sending null variables to AniList. */ async function filterHandler(params) { const genre = params.get("genre"); const tag = params.get("tag"); const year = params.get("year") ? parseInt(params.get("year")) : undefined; const season = params.get("season"); const format = params.get("format"); const status = params.get("status"); const sort = params.get("sort") || "POPULARITY_DESC"; const page = parseInt(params.get("page") || "1"); const perPage = parseInt(params.get("per_page") || "20"); // Build dynamic variables and filter strings const variables = { page, perPage, sort }; const filters = []; if (genre) { filters.push("genre: $genre"); variables.genre = genre; } if (tag) { filters.push("tag: $tag"); variables.tag = tag; } if (year) { filters.push("seasonYear: $seasonYear"); variables.seasonYear = year; } if (season) { filters.push("season: $season"); variables.season = season; } if (format) { filters.push("format: $format"); variables.format = format; } if (status) { filters.push("status: $status"); variables.status = status; } // Build type declarations for present variables const typeDecls = [ "$page: Int", "$perPage: Int", "$sort: [MediaSort]", genre && "$genre: Genre", tag && "$tag: String", year && "$seasonYear: Int", season && "$season: Season", format && "$format: MediaFormat", status && "$status: MediaStatus", ].filter(Boolean).join(", "); const gql = `query (${typeDecls}) { Page(page: $page, perPage: $perPage) { pageInfo { total currentPage hasNextPage perPage } media(${filters.join(", ")}, type: ANIME, sort: $sort) { id title { romaji english } coverImage { large } format status averageScore episodes } } }`; const data = await anilistQuery(gql, variables); return json({ success: true, results: data.Page }); } // ---- FEATURE: Trending anime ---- /** * Returns currently trending anime (releasing only). * Sorted by trending score on AniList. * * @param {URLSearchParams} params - per_page (optional, default 20) * @returns {Promise} Trending anime list */ async function trendingHandler(params) { const perPage = parseInt(params.get("per_page") || "20"); const gql = `query ($perPage: Int) { Page(page: 1, perPage: $perPage) { media(type: ANIME, sort: TRENDING, status: RELEASING) { id title { romaji english } coverImage { large } format status averageScore episodes genres } } }`; const data = await anilistQuery(gql, { perPage }); return json({ success: true, results: { results: data.Page.media } }); } // ---- FEATURE: Popular anime ---- /** * Returns most popular anime of all time. * Sorted by AniList popularity metric. * * @param {URLSearchParams} params - per_page (optional, default 20) * @returns {Promise} Popular anime list */ async function popularHandler(params) { const perPage = parseInt(params.get("per_page") || "20"); const gql = `query ($perPage: Int) { Page(page: 1, perPage: $perPage) { media(type: ANIME, sort: POPULARITY_DESC) { id title { romaji english } coverImage { large } format status averageScore episodes genres } } }`; const data = await anilistQuery(gql, { perPage }); return json({ success: true, results: { results: data.Page.media } }); } // ---- FEATURE: Top scored anime ---- /** * Returns highest scored anime on AniList. * Sorted by average score descending. * * @param {URLSearchParams} params - per_page (optional, default 20) * @returns {Promise} Top scored anime list */ async function topHandler(params) { const perPage = parseInt(params.get("per_page") || "20"); const gql = `query ($perPage: Int) { Page(page: 1, perPage: $perPage) { media(type: ANIME, sort: SCORE_DESC) { id title { romaji english } coverImage { large } format status averageScore episodes genres } } }`; const data = await anilistQuery(gql, { perPage }); return json({ success: true, results: { results: data.Page.media } }); } // ---- FEATURE: Complete anime info by AniList ID ---- /** * Returns full anime details including description, studios, * tags, next airing episode, and more. * * @param {string} id - AniList anime ID (path param) * @returns {Promise} Complete anime info * * @example * GET /api/info/20 → Naruto * GET /api/info/1535 → Death Note */ async function infoHandler(params, id) { const gql = `query ($id: Int) { Media(id: $id, type: ANIME) { id idMal title { romaji english native } description coverImage { large extraLarge } bannerImage format season seasonYear episodes duration status averageScore meanScore popularity favourites genres tags { name } studios { nodes { name isAnimationStudio } } startDate { year month day } endDate { year month day } nextAiringEpisode { episode airingAt timeUntilAiring } } }`; const data = await anilistQuery(gql, { id: parseInt(id) }); return json({ success: true, results: data.Media }); } // ---- FEATURE: Anime characters with voice actors ---- /** * Returns characters for an anime, including Japanese voice actors. * Characters are sorted by role importance (MAIN first). * * @param {string} id - AniList anime ID (path param) * @returns {Promise} Character list with voice actors */ async function charactersHandler(params, id) { const gql = `query ($id: Int) { Media(id: $id, type: ANIME) { characters(sort: ROLE, perPage: 25) { edges { role node { id name { full native } image { large } } voiceActors(language: JAPANESE) { id name { full native } languageV2 } } } } }`; const data = await anilistQuery(gql, { id: parseInt(id) }); return json({ success: true, results: data.Media.characters }); } // ---- FEATURE: Anime relations (sequels, prequels, etc.) ---- /** * Returns related anime (sequels, prequels, side stories, etc.). * Useful for building "watch order" guides. * * @param {string} id - AniList anime ID (path param) * @returns {Promise} Relations with connected anime */ async function relationsHandler(params, id) { const gql = `query ($id: Int) { Media(id: $id, type: ANIME) { relations { edges { relationType node { id title { romaji english } coverImage { large } format status } } } } }`; const data = await anilistQuery(gql, { id: parseInt(id) }); return json({ success: true, results: data.Media.relations }); } // ---- FEATURE: All genres list ---- /** * Returns the complete list of AniList genres. * Static data — rarely changes. * * @returns {Promise} Array of genre strings */ async function genresHandler() { const gql = `{ GenreCollection }`; const data = await anilistQuery(gql); return json({ success: true, results: data.GenreCollection }); } // ---- FEATURE: Episode list from Miruro pipe ---- /** * Fetches episode list for an anime from the Miruro pipe. * Uses mirror rotation to maximize success rate. * * @param {string} id - AniList anime ID (path param) * @returns {Promise} Episode data from all providers * * @trick * This endpoint calls the pipe, which is where Cloudflare Workers * shine — edge-to-edge requests bypass bot detection entirely. */ async function episodesHandler(params, id) { try { const data = await pipeRequest("/api/episodes", { id }); return json({ success: true, results: data }); } catch (e) { return error(`All pipe methods failed: ${e.message}`); } } // ---- FEATURE: Streaming sources for a specific episode ---- /** * Fetches streaming sources for a specific episode. * Returns M3U8 URLs, subtitles, and quality info. * * @param {string} provider - Provider name (kiwi, pewe, bonk, etc.) * @param {string} anilistId - AniList anime ID * @param {string} category - sub or dub * @param {string} slug - Episode slug from episodes response * @returns {Promise} Streaming sources and subtitles */ async function watchHandler(params, provider, anilistId, category, slug) { try { const data = await pipeRequest("/api/watch", { provider, anilistId, category, slug }); return json({ success: true, results: data }); } catch (e) { return error(`All pipe methods failed: ${e.message}`); } } // ---- FEATURE: Random anime ---- /** * Returns a random anime from AniList. * Uses random pagination to pick from the top 1000 most popular. * * @returns {Promise} Random anime details * * @trick * Two levels of randomness: random page (1-50) + random index * within that page. This gives a uniform distribution across * the top ~1000 most popular anime. */ async function randomHandler() { const page = Math.floor(Math.random() * 50) + 1; const perPage = Math.floor(Math.random() * 20) + 1; const gql = `query ($page: Int, $perPage: Int) { Page(page: $page, perPage: $perPage) { media(type: ANIME, sort: POPULARITY_DESC) { id title { romaji english native } coverImage { large } format season seasonYear episodes status averageScore genres popularity } } }`; const data = await anilistQuery(gql, { page, perPage }); const media = data.Page.media; return json({ success: true, results: media[Math.floor(Math.random() * media.length)] || null, }); } // ══════════════════════════════════════════════════════════════ // ROUTER // ══════════════════════════════════════════════════════════════ /** * Route table mapping "METHOD /pattern" to handler functions. * Patterns support :param placeholders for dynamic path segments. * * @type {Object} * * @trick * Routes are matched longest-first, so /api/anime/:id/characters * matches before /api/anime/:id. This prevents ambiguous matches. */ const ROUTES = { // ---- System ---- "GET /api/health": healthHandler, // ---- Search ---- "GET /api/search": searchHandler, "GET /api/suggestions": suggestionsHandler, "GET /api/filter": filterHandler, // ---- Collections ---- "GET /api/trending": trendingHandler, "GET /api/popular": popularHandler, "GET /api/top": topHandler, "GET /api/random": randomHandler, // ---- Anime Details ---- "GET /api/info/:id": infoHandler, "GET /api/anime/:id/characters": charactersHandler, "GET /api/anime/:id/relations": relationsHandler, // ---- Metadata ---- "GET /api/genres": genresHandler, // ---- Streaming (pipe) ---- "GET /api/episodes/:id": episodesHandler, "GET /api/watch/:provider/:anilistId/:category/:slug": watchHandler, }; /** * Matches a request method + path against the route table. * Supports :param placeholders for dynamic segments. * * @param {string} method - HTTP method (GET, POST, etc.) * @param {string} path - URL path (e.g. /api/info/20) * @returns {{ handler: Function, params: string[] } | null} Matched route info, or null * * @trick * The router tries exact matches first, then pattern matches. * Pattern matches extract :param values into the params array * which are passed as positional arguments to the handler. */ function matchRoute(method, path) { // Try exact match first (fast path) const exact = `${method} ${path}`; if (ROUTES[exact]) return { handler: ROUTES[exact], params: [] }; // Try pattern matches (slower, handles :params) for (const key of Object.keys(ROUTES)) { const [m, pattern] = key.split(" "); if (m !== method) continue; const patternParts = pattern.split("/"); const pathParts = path.split("/"); if (patternParts.length !== pathParts.length) continue; const params = []; let match = true; for (let i = 0; i < patternParts.length; i++) { if (patternParts[i].startsWith(":")) { // Dynamic segment — extract value params.push(pathParts[i]); } else if (patternParts[i] !== pathParts[i]) { // Static segment — must match exactly match = false; break; } } if (match) return { handler: ROUTES[key], params }; } return null; } // ══════════════════════════════════════════════════════════════ // WORKER ENTRY POINT // ══════════════════════════════════════════════════════════════ /** * Main fetch event handler. Cloudflare Workers use this instead * of Express app.listen(). Every incoming request goes through here. * * @param {FetchEvent} event - Cloudflare fetch event * @returns {Response} HTTP response * * @trick * Workers don't have a server process — they're stateless functions * that spin up on demand and die after responding. No persistent * memory between requests (use KV or Durable Objects for state). */ addEventListener("fetch", (event) => { event.respondWith(handleRequest(event)); }); /** * Core request handler. Routes requests, handles CORS preflight, * and serves the landing page. * * @param {FetchEvent} event - Cloudflare fetch event * @returns {Promise} HTTP response */ async function handleRequest(event) { const req = event.request; const url = new URL(req.url); // CORS preflight if (req.method === "OPTIONS") { return new Response(null, { status: 204, headers: CORS }); } // Landing page if (url.pathname === "/") { return new Response( ` MiruroAPI — Cloudflare Worker

📺 MiruroAPI

Cloudflare Worker Edition — 15 endpoints, zero cold starts

Shinei Nouzen • MIT License

`, { headers: { "Content-Type": "text/html", ...CORS } } ); } // Route matching const route = matchRoute(req.method, url.pathname); if (!route) return error("Not found", 404); // Execute handler try { return await route.handler(url.searchParams, ...route.params); } catch (e) { return error(e.message); } } // ══════════════════════════════════════════════════════════════ // MODULE FOOTER // ══════════════════════════════════════════════════════════════ // // End of MiruroAPI Worker Edition // // Deploy with: wrangler deploy // Test locally: wrangler dev // // Routes: // /api/health → System health check // /api/search?query=naruto → Full-text search // /api/suggestions?query=naruto → Autocomplete // /api/filter?genre=Action → Advanced filter // /api/trending → Trending anime // /api/popular → Most popular // /api/top → Top scored // /api/random → Random anime // /api/info/20 → Full anime info // /api/anime/20/characters → Characters + VAs // /api/anime/20/relations → Related anime // /api/genres → All genres // /api/episodes/20 → Episode list (pipe) // /api/watch/kiwi/20/sub/episode-1 → Streaming sources (pipe) // // ══════════════════════════════════════════════════════════════