diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39bf2b9..45bbcb6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,14 +36,14 @@ jobs: - name: Run TypeScript build run: pnpm build - - name: Validate Cloudflare Worker and container configuration + - name: Validate Cloudflare Worker configuration run: pnpm cloudflare:check - name: Start Worker in the Cloudflare runtime timeout-minutes: 2 run: pnpm cloudflare:startup - - name: Build and smoke test the production container + - name: Build and smoke test the Node.js image timeout-minutes: 5 run: | docker build -t bonyan-api-ci . diff --git a/README.md b/README.md index 79f4c44..87f2ad9 100644 --- a/README.md +++ b/README.md @@ -27,18 +27,24 @@ The default address is `http://localhost:3000`. Configuration names are listed i ### Cloudflare Workers -This is a Fastify API deployed through a Cloudflare Container, not a static Pages site and not a Fastify process inside the Workers runtime. The repository includes `wrangler.toml`, `src/worker.ts` and the existing `Dockerfile`. In Cloudflare Workers Builds use: +The Cloudflare entrypoint uses native `Request` and `Response` handlers. It runs on Workers Free without Containers, Docker or a paid plan. The Node.js entrypoint still uses Fastify; both adapters share the same controllers and route definitions. + +In Cloudflare Workers Builds use: ```text Build command: pnpm run build Deploy command: pnpm run deploy:cloudflare ``` -Set production variables and secrets in the Worker and container settings. Do not upload `.env`. Cloudflare Containers require a Workers Paid plan and build the existing Docker image. For a local deployment, run `pnpm build` before `pnpm deploy:cloudflare`; local container development requires Docker. +Set production variables and secrets in the Worker settings. Do not upload `.env`. Local development uses `pnpm exec wrangler dev`; deployment uses `pnpm deploy:cloudflare`. + +The production custom domain is `api.bonyanoss.org`. Branch builds run `npx wrangler preview` using the empty `previews` configuration. Enable Preview URLs on the existing Worker before testing a branch, then test its URL before merging. `workers_dev = false` keeps production traffic on the custom domain; `preview_urls = true` preserves access to test deployments. + +CI validates every documented response against OpenAPI for both HTTP adapters, starts the Worker in workerd and requests its health, readiness, routing and CORS endpoints. The Docker smoke test verifies the optional Node.js deployment separately. Worker initialization performs no asynchronous I/O and does not import Fastify. -Use `pnpm run deploy:cloudflare` in the production Workers Build. The repository script pins the local Wrangler binary and explicitly selects `wrangler.toml`. To test a branch before production, connect a separate staging Worker or Wrangler environment and run a full deploy there. `wrangler versions upload` uploads Worker code only; it does not build or roll out container images or provide a full container preview. +The `v2` Durable Object migration retires the unused class from the earlier Container deployment. Keep the migration history when updating an existing Worker. Roll back code by redeploying the previous native Worker revision; restoring the Container version would require the paid plan and a new class migration. -CI runs `pnpm cloudflare:startup` to execute the Worker in workerd, then builds the Docker image and requests `/health` from its Node.js server. A successful `tsc` build or Wrangler dry run alone does not validate Worker startup. Keep Fastify initialization in `src/server.ts` inside the container; awaiting it at module scope in `src/worker.ts` can cause `Top-level await in module is unsettled` during deployment. +Workers Free currently allows 100,000 requests per day, 10 ms CPU per invocation and 50 external subrequests. Network waiting does not count toward CPU time. Large cold Quran or hadith responses still need testing under production load. Cache values and rate-limit counters are local to each Worker isolate; they are not a global quota. Inflight I/O is confined to its request, and stale refreshes use `waitUntil`. See [Cloudflare limits](https://developers.cloudflare.com/workers/platform/limits/). ## API @@ -83,7 +89,7 @@ Ordered chains and source policy are in [SOURCES.md](SOURCES.md). Adapters valid - Reciter IDs remain MP3Quran IDs. A bundled snapshot retains 241 identities and recording coverage during catalogue outages. Refresh it deliberately when the source changes. - Audio fallback covers nine verified Hafs murattal recordings. Narrator, narration and style remain the same; the recording file may differ. Unavailable chapters return 404; failed eligible sources return 503. - Tafsir is restricted to `muyassar` and `saadi`; response editions must match requests. -- Both azkar adapters cover 132 Hisn chapters. Missing structured metadata such as `count` is omitted. Item IDs are chapter-local positions, not permanent cross-source identities. +- Both azkar hosts serve the same pinned corpus of 132 Hisn chapters, using one request per host. Missing structured metadata such as `count` is omitted. Item IDs are chapter-local positions, not permanent cross-source identities. - Hadith uses a pinned collection across two hosts. `available` counts records, not the largest number. Inclusive ranges can contain gaps and accept at most 300 requested numbers. Random selection chooses an existing record. - Coordinate-based prayer has an offline calculation. Algorithms/rounding can differ by minutes; polar conditions may remain unavailable. Calculated times are not a local mosque timetable. - Both calendar sources use Umm al-Qura and return `calendar: "islamic-umalqura"`; this does not determine local moon sightings. Qibla has an offline bearing calculation. diff --git a/SOURCES.md b/SOURCES.md index 4e7d41b..f82c46e 100644 --- a/SOURCES.md +++ b/SOURCES.md @@ -10,7 +10,7 @@ Reviewed on 2026-10-04. Adapters must preserve requested content identity, editi | Audio | MP3Quran recording, Quran.com where explicitly mapped | Nine verified Hafs murattal mappings; other recordings have one source | | Muyassar | AlQuran Cloud `ar.muyassar`, QuranEnc `arabic_moyassar`, Quran.com `16`, spa5k | Reject wrong editions or missing references | | Saadi | Quran.com `91`, spa5k | Unsupported cloud/QuranEnc resources removed | -| Azkar | Pinned rn0x Hisn mirror, HisnMuslim index plus chapter files | Same 132 canonical chapters; missing repetition metadata omitted | +| Azkar | Pinned rn0x Hisn corpus via jsDelivr, same revision on raw GitHub | Same 132 canonical chapters; missing repetition metadata omitted | | Hadith | Pinned gadingnst via jsDelivr, same revision on raw GitHub | Nine collections, 38,102 records; two hosts share one data origin | | Prayer coordinates | AlAdhan, local `adhan` | Same method/timezone; standard Asr; numerical differences possible | | Prayer city | AlAdhan | No offline geocoder | @@ -53,7 +53,7 @@ Prayer permits AlAdhan IDs `1,2,3,4,5,9,10,11`, with local equivalents. IDs `0` JSON deadlines include body parsing. Default 8 seconds; Quran 20; tafsir requests 12 (mirror 15); Hisn mirror 12; prayer 10. Full hadith books allow 30 seconds per host. Bukhari is about 13.4 MB uncompressed and previously exceeded a 20-second deadline. A cold request can take about 60 seconds if both hosts are slow. Successful books cache for 12 hours. -Official Hisn has a 20-second overall abort signal, 8-second request deadlines, and six workers fetching 132 chapters after the index. The audit uses three concurrent adapter checks. Quran/tafsir/azkar cache 24 hours, surahs 12 hours, reciters one hour, audio 15 minutes, prayer one hour, calendar seven days, Qibla 30 days. `CACHE_MAX_ENTRIES` defaults to 1,000. Concurrent loads are coalesced; invalidated older loads cannot overwrite newer results. +The former HisnMuslim index-and-chapter fallback required 133 requests, exceeding Workers Free's 50-subrequest limit. Both current azkar adapters request the same pinned corpus once from independent hosts; they share one data origin and omit unavailable repetition metadata. The audit uses three concurrent adapter checks. Quran/tafsir/azkar cache 24 hours, surahs 12 hours, reciters one hour, audio 15 minutes, prayer one hour, calendar seven days, Qibla 30 days. `CACHE_MAX_ENTRIES` defaults to 1,000. Concurrent loads are coalesced within each Worker request (process-wide on Node.js); invalidated older loads cannot overwrite newer results. ## Licenses and permissions diff --git a/openapi.yaml b/openapi.yaml index 37c5f95..1f5888a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1268,7 +1268,7 @@ components: apiName: type: string enum: - - hisnmuslim.com + - raw.githubusercontent.com/rn0x/hisn_almuslim_json - cdn.jsdelivr.net/rn0x/hisn_almuslim_json AzkarSummary: type: object @@ -1287,7 +1287,7 @@ components: apiName: type: string enum: - - hisnmuslim.com + - raw.githubusercontent.com/rn0x/hisn_almuslim_json - cdn.jsdelivr.net/rn0x/hisn_almuslim_json AzkarResult: type: object @@ -1305,7 +1305,7 @@ components: apiName: type: string enum: - - hisnmuslim.com + - raw.githubusercontent.com/rn0x/hisn_almuslim_json - cdn.jsdelivr.net/rn0x/hisn_almuslim_json HadithBook: type: object diff --git a/package.json b/package.json index 4515a2e..d1bff46 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage", "check:upstreams": "tsx scripts/check-upstreams.ts", - "cloudflare:check": "pnpm exec wrangler deploy --dry-run --containers-rollout=none --config wrangler.toml", + "cloudflare:check": "pnpm exec wrangler deploy --dry-run --config wrangler.toml", "cloudflare:startup": "tsx scripts/check-worker-startup.ts", "deploy:cloudflare": "pnpm exec wrangler deploy --config wrangler.toml", "prepare": "husky" diff --git a/scripts/check-worker-startup.ts b/scripts/check-worker-startup.ts index 735072f..7d1ae18 100644 --- a/scripts/check-worker-startup.ts +++ b/scripts/check-worker-startup.ts @@ -4,12 +4,13 @@ * MIT License */ +import assert from 'node:assert/strict'; import { unstable_dev, unstable_readConfig } from 'wrangler'; const config = unstable_readConfig({ config: 'wrangler.toml' }); if (!config.main) throw new Error('wrangler.toml must define a Worker entrypoint'); -// A dry run bundles code but does not execute its top-level initialization. +// Bundling alone cannot detect startup errors or runtime-incompatible APIs. const worker = await unstable_dev(config.main, { config: 'wrangler.toml', local: true, @@ -17,13 +18,62 @@ const worker = await unstable_dev(config.main, { inspectorPort: 0, persist: false, logLevel: 'error', - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, - enableContainers: false, - watch: false, - }, + experimental: { disableExperimentalWarning: true, disableDevRegistry: true, watch: false }, }); -await worker.stop(); -console.log('Worker startup passed in workerd (container execution checked separately).'); +try { + for (const [path, expected] of [ + ['/health', 'ok'], + ['/ready', 'ready'], + ]) { + const response = await worker.fetch(path!); + assert.equal(response.status, 200); + assert.equal(((await response.json()) as { status: string }).status, expected); + } + const catalogue = await worker.fetch('/'); + const body = (await catalogue.json()) as { routes: { method: string; url: string }[] }; + assert.equal(body.routes.filter((r) => r.method === 'GET').length, 27); + const metrics = await worker.fetch('/metrics'); + assert.equal(metrics.status, 200); + assert.match(await metrics.text(), /bonyan_api_cache_entries/); + const invalid = await worker.fetch('/qibla?latitude=91&longitude=46'); + assert.equal(invalid.status, 400); + assert.equal(((await invalid.json()) as { success: boolean }).success, false); + const missing = await worker.fetch('/unknown'); + assert.equal(missing.status, 404); + const head = await worker.fetch('/health', { method: 'HEAD' }); + assert.equal(head.status, 200); + assert.equal(await head.text(), ''); + const preflight = await worker.fetch('/health', { + method: 'OPTIONS', + headers: { Origin: 'https://client.example', 'Access-Control-Request-Method': 'GET' }, + }); + assert.equal(preflight.status, 204); + assert.equal(preflight.headers.get('access-control-allow-origin'), 'https://client.example'); + if (process.argv.includes('--live')) { + for (const path of [ + '/surah/1', + '/reciters/123', + '/reciters/123/surah/1', + '/ayat/1/aya/1', + '/ayat', + '/azkar', + '/tafsir/muyassar/1/2', + '/tafsir/saadi/1/2', + '/hadith/bukhari/1', + '/prayer/times?date=04-10-2026&latitude=21.4225&longitude=39.8262', + '/hijri/from-gregorian?date=04-10-2026', + '/hijri/to-gregorian?date=23-04-1448', + '/qibla?latitude=24.7136&longitude=46.6753', + ]) { + const response = await worker.fetch(path); + const text = await response.text(); + console.log(JSON.stringify({ path, status: response.status, bytes: text.length })); + assert.equal(response.status, 200, text.slice(0, 300)); + assert.equal((JSON.parse(text) as { success: boolean }).success, true); + } + } + console.log('Native Worker startup, health, routes, validation, HEAD and CORS passed in workerd.'); +} finally { + await worker.stop(); +} diff --git a/src/app.ts b/src/app.ts index d7bc9b2..0f0e04d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,15 +8,7 @@ import Fastify, { type FastifyServerOptions } from 'fastify'; import cors from '@fastify/cors'; import rateLimit from '@fastify/rate-limit'; -import recitersRoutes from './modules/reciters/reciters.route.js'; -import surahRoutes from './modules/surah/surah.route.js'; -import ayatRoutes from './modules/ayat/ayat.route.js'; -import azkarRoutes from './modules/azkar/azkar.route.js'; -import tafsirRoutes from './modules/tafsir/tafsir.route.js'; -import hadithRoutes from './modules/hadith/hadith.route.js'; -import prayerRoutes from './modules/prayer/prayer.route.js'; -import hijriRoutes from './modules/hijri/hijri.route.js'; -import qiblaRoutes from './modules/qibla/qibla.route.js'; +import { registerApiRoutes } from './http/routes.js'; import { getCacheStats } from './utils/cache.js'; import { fail } from './utils/http.js'; import { renderMetrics } from './utils/metrics.js'; @@ -79,15 +71,7 @@ export async function buildApp(options: FastifyServerOptions = {}) { return fail(reply, 500, 'Internal server error', 'INTERNAL_SERVER_ERROR'); }); - await app.register(recitersRoutes); - await app.register(surahRoutes); - await app.register(ayatRoutes); - await app.register(azkarRoutes); - await app.register(tafsirRoutes); - await app.register(hadithRoutes); - await app.register(prayerRoutes); - await app.register(hijriRoutes); - await app.register(qiblaRoutes); + registerApiRoutes(app); return app; } diff --git a/src/http/routes.ts b/src/http/routes.ts new file mode 100644 index 0000000..d7a1bb3 --- /dev/null +++ b/src/http/routes.ts @@ -0,0 +1,32 @@ +/* + * Bonyan-API – Quran & Azkar API + * Copyright (c) 2026 BonyanOSS + * MIT License + */ + +import type { RouteRegistrar } from './types.js'; +import recitersRoutes from '../modules/reciters/reciters.route.js'; +import surahRoutes from '../modules/surah/surah.route.js'; +import ayatRoutes from '../modules/ayat/ayat.route.js'; +import azkarRoutes from '../modules/azkar/azkar.route.js'; +import tafsirRoutes from '../modules/tafsir/tafsir.route.js'; +import hadithRoutes from '../modules/hadith/hadith.route.js'; +import prayerRoutes from '../modules/prayer/prayer.route.js'; +import hijriRoutes from '../modules/hijri/hijri.route.js'; +import qiblaRoutes from '../modules/qibla/qibla.route.js'; + +export function registerApiRoutes(router: RouteRegistrar): void { + for (const register of [ + recitersRoutes, + surahRoutes, + ayatRoutes, + azkarRoutes, + tafsirRoutes, + hadithRoutes, + prayerRoutes, + hijriRoutes, + qiblaRoutes, + ]) { + register(router); + } +} diff --git a/src/http/types.ts b/src/http/types.ts new file mode 100644 index 0000000..aab99da --- /dev/null +++ b/src/http/types.ts @@ -0,0 +1,28 @@ +/* + * Bonyan-API – Quran & Azkar API + * Copyright (c) 2026 BonyanOSS + * MIT License + */ + +export interface RequestFields { + Params?: unknown; + Querystring?: unknown; +} + +export interface HttpRequest { + params: Route['Params']; + query: Route['Querystring']; +} + +export interface HttpReply { + request: { id: string }; + log: { warn(context: unknown, message: string): void }; + status(code: number): HttpReply; + send(body: unknown): HttpReply; +} + +export type HttpHandler = (request: HttpRequest, reply: HttpReply) => unknown | Promise; + +export interface RouteRegistrar { + get(path: string, handler: HttpHandler): unknown; +} diff --git a/src/http/worker-app.ts b/src/http/worker-app.ts new file mode 100644 index 0000000..cdfa5ae --- /dev/null +++ b/src/http/worker-app.ts @@ -0,0 +1,167 @@ +/* + * Bonyan-API – Quran & Azkar API + * Copyright (c) 2026 BonyanOSS + * MIT License + */ + +import { registerApiRoutes } from './routes.js'; +import type { HttpHandler, HttpReply, HttpRequest, RequestFields, RouteRegistrar } from './types.js'; +import { fail } from '../utils/http.js'; +import { getCacheStats } from '../utils/cache.js'; +import { renderMetrics } from '../utils/metrics.js'; + +interface Route { + path: string; + segments: string[]; + handler: HttpHandler; +} + +class WebReply implements HttpReply { + readonly request = { id: crypto.randomUUID() }; + readonly log = { warn: (context: unknown, message: string) => console.warn(message, context) }; + private code = 200; + response?: Response; + + status(code: number): this { + this.code = code; + return this; + } + + send(body: unknown): this { + this.response = Response.json(body, { status: this.code, headers: { 'content-type': 'application/json; charset=utf-8' } }); + return this; + } +} + +export function createWorkerApp() { + const routes: Route[] = []; + const router: RouteRegistrar = { + get(path: string, handler: HttpHandler) { + // The route pattern supplies the handler's named string parameters. + routes.push({ path, segments: path.split('/'), handler: handler as HttpHandler }); + }, + }; + registerApiRoutes(router); + routes.sort((a, b) => a.segments.filter((p) => p.startsWith(':')).length - b.segments.filter((p) => p.startsWith(':')).length); + const catalogue = routes.flatMap(({ path }) => [ + { method: 'GET', url: path }, + { method: 'HEAD', url: path }, + ]); + const clients = new Map(); + + return { + async fetch(request: Request, env: Record = {}): Promise { + const reply = new WebReply(); + const headers = new Headers(); + const origin = request.headers.get('origin'); + const allowed = + !env.CORS_ORIGIN || + ['*', 'true'].includes(env.CORS_ORIGIN.trim().toLowerCase()) || + env.CORS_ORIGIN.split(',') + .map((s) => s.trim()) + .includes(origin ?? ''); + headers.set('vary', 'Origin'); + if (origin && allowed) headers.set('access-control-allow-origin', origin); + + const finish = (response: Response): Response => { + headers.forEach((value, key) => response.headers.set(key, value)); + return request.method === 'HEAD' ? new Response(null, { status: response.status, headers: response.headers }) : response; + }; + + if (request.method === 'OPTIONS' && allowed) { + if (!origin || !request.headers.has('access-control-request-method')) { + fail(reply, 400, 'Invalid Preflight Request'); + return finish(reply.response!); + } + headers.set('access-control-allow-methods', 'GET,HEAD,POST'); + const requestedHeaders = request.headers.get('access-control-request-headers'); + if (requestedHeaders) { + headers.set('access-control-allow-headers', requestedHeaders); + headers.append('vary', 'Access-Control-Request-Headers'); + } + return finish(new Response(null, { status: 204 })); + } + + const configuredMax = Number(env.RATE_LIMIT_MAX); + const max = Number.isSafeInteger(configuredMax) && configuredMax > 0 ? configuredMax : 120; + const windowMs = parseWindow(env.RATE_LIMIT_WINDOW); + const now = Date.now(); + const clientKey = request.headers.get('cf-connecting-ip') ?? 'unknown'; + let client = clients.get(clientKey); + if (!client || client.expiresAt <= now) { + client = { count: 0, expiresAt: now + windowMs }; + clients.set(clientKey, client); + } + client.count++; + const reset = Math.ceil((client.expiresAt - now) / 1000); + headers.set('x-ratelimit-limit', String(max)); + headers.set('x-ratelimit-remaining', String(Math.max(0, max - client.count))); + headers.set('x-ratelimit-reset', String(reset)); + if (clients.size > 10_000) clients.delete(clients.keys().next().value!); + if (client.count > max) { + headers.set('retry-after', String(reset)); + fail(reply, 429, 'Rate limit exceeded, retry in ' + reset + ' seconds', 'RATE_LIMITED'); + return finish(reply.response!); + } + + try { + const url = new URL(request.url); + if (request.method === 'GET' || request.method === 'HEAD') { + if (url.pathname === '/') + reply.send({ name: 'Bonyan-API', description: 'Quran & Azkar API with multi-source fallback', routes: catalogue }); + else if (url.pathname === '/health') reply.send({ status: 'ok', code: 200, timestamp: new Date().toISOString() }); + else if (url.pathname === '/ready') + reply.send({ status: 'ready', code: 200, timestamp: new Date().toISOString(), cache: getCacheStats() }); + else if (url.pathname === '/metrics') + return finish(new Response(renderMetrics(getCacheStats()), { headers: { 'content-type': 'text/plain; version=0.0.4' } })); + else { + let segments: string[]; + try { + segments = url.pathname.split('/').map(decodeURIComponent); + } catch { + fail(reply, 400, 'Malformed URL'); + return finish(reply.response!); + } + const route = routes.find( + (r) => + r.segments.length === segments.length && r.segments.every((part, i) => part.startsWith(':') || part === segments[i]), + ); + if (route) { + const params = Object.fromEntries( + route.segments.flatMap((part, i) => (part.startsWith(':') ? [[part.slice(1), segments[i]!]] : [])), + ); + const query: Record = Object.create(null); + for (const [key, value] of url.searchParams) { + const previous = query[key]; + query[key] = previous === undefined ? value : Array.isArray(previous) ? [...previous, value] : [previous, value]; + } + await route.handler({ params, query } satisfies HttpRequest, reply); + } + } + } + if (!reply.response) fail(reply, 404, `Route ${request.method} ${url.pathname}${url.search} not found`, 'NOT_FOUND'); + } catch (error) { + console.error('Request failed', error); + fail(reply, 500, 'Internal server error', 'INTERNAL_SERVER_ERROR'); + } + return finish(reply.response!); + }, + }; +} + +function parseWindow(value = '1 minute'): number { + const match = /^(\d+(?:\.\d+)?)\s*(ms|milliseconds?|s|seconds?|m|minutes?|h|hours?|d|days?)$/i.exec(value.trim()); + if (!match) return 60_000; + const unit = match[2]!.toLowerCase(); + const multiplier = + unit.startsWith('ms') || unit.startsWith('millisecond') + ? 1 + : unit.startsWith('s') + ? 1000 + : unit.startsWith('m') + ? 60_000 + : unit.startsWith('h') + ? 3_600_000 + : 86_400_000; + return Math.max(1, Number(match[1]) * multiplier); +} diff --git a/src/modules/ayat/ayat.controller.ts b/src/modules/ayat/ayat.controller.ts index 80c7c8e..baee724 100644 --- a/src/modules/ayat/ayat.controller.ts +++ b/src/modules/ayat/ayat.controller.ts @@ -4,14 +4,14 @@ * MIT License */ -import { FastifyReply, FastifyRequest } from 'fastify'; +import { type HttpReply, type HttpRequest } from '../../http/types.js'; import { getAyatContent } from './ayat.service.js'; import type { AyaItem } from '@/src/types/Items.js'; import { normalizeArabicForQuranSearch } from '../../utils/arabic.js'; import { fail, ok, unavailable } from '../../utils/http.js'; import { parseInteger } from '../../utils/validation.js'; -export async function getAllAyat(_req: FastifyRequest, reply: FastifyReply) { +export async function getAllAyat(_req: HttpRequest, reply: HttpReply) { try { const data = await getAyatContent(); return ok(reply, data); @@ -20,7 +20,7 @@ export async function getAllAyat(_req: FastifyRequest, reply: FastifyReply) { } } -export async function getAyatById(req: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) { +export async function getAyatById(req: HttpRequest<{ Params: { id: string } }>, reply: HttpReply) { const id = parseInteger(req.params.id); if (Number.isNaN(id)) return fail(reply, 400, 'Invalid Aya ID'); if (id < 1 || id > 6236) return fail(reply, 400, 'ID must be between 1 and 6236'); @@ -37,7 +37,7 @@ export async function getAyatById(req: FastifyRequest<{ Params: { id: string } } } } -export async function getAyatBySurah(req: FastifyRequest<{ Params: { surah: string; id: string } }>, reply: FastifyReply) { +export async function getAyatBySurah(req: HttpRequest<{ Params: { surah: string; id: string } }>, reply: HttpReply) { const surahNum = parseInteger(req.params.surah); const ayaNum = parseInteger(req.params.id); @@ -61,7 +61,7 @@ export async function getAyatBySurah(req: FastifyRequest<{ Params: { surah: stri } } -export async function getAyatByText(req: FastifyRequest<{ Querystring: { text?: string; limit?: string } }>, reply: FastifyReply) { +export async function getAyatByText(req: HttpRequest<{ Querystring: { text?: string; limit?: string } }>, reply: HttpReply) { const text = req.query.text?.trim(); if (!text) return fail(reply, 400, 'Query parameter "text" is required'); diff --git a/src/modules/ayat/ayat.route.ts b/src/modules/ayat/ayat.route.ts index 4e366af..76150e5 100644 --- a/src/modules/ayat/ayat.route.ts +++ b/src/modules/ayat/ayat.route.ts @@ -4,15 +4,15 @@ * MIT License */ -import { FastifyInstance } from 'fastify'; +import { type RouteRegistrar } from '../../http/types.js'; import { getAllAyat, getAyatById, getAyatBySurah, getAyatByText } from './ayat.controller.js'; -export default async function ayatRoutes(fastify: FastifyInstance) { - fastify.get('/ayat/search', getAyatByText); +export default function ayatRoutes(router: RouteRegistrar) { + router.get('/ayat/search', getAyatByText); - fastify.get('/ayat', getAllAyat); + router.get('/ayat', getAllAyat); - fastify.get('/ayat/:surah/aya/:id', getAyatBySurah); + router.get('/ayat/:surah/aya/:id', getAyatBySurah); - fastify.get('/ayat/:id', getAyatById); + router.get('/ayat/:id', getAyatById); } diff --git a/src/modules/azkar/azkar.controller.ts b/src/modules/azkar/azkar.controller.ts index 7e69218..1af1737 100644 --- a/src/modules/azkar/azkar.controller.ts +++ b/src/modules/azkar/azkar.controller.ts @@ -4,7 +4,7 @@ * MIT License */ -import { FastifyReply, FastifyRequest } from 'fastify'; +import { type HttpReply, type HttpRequest } from '../../http/types.js'; import { getAzkarContent } from './azkar.service.js'; import { normalizeArabic } from '../../utils/arabic.js'; import { fail, ok, unavailable } from '../../utils/http.js'; @@ -13,7 +13,7 @@ import type { AzkarItem, AzkarApiSource } from '../../types/Items.js'; type AzkarResult = { category: string; item: AzkarItem; apiName: AzkarApiSource }; -export async function getAzkarCategories(_req: FastifyRequest, reply: FastifyReply) { +export async function getAzkarCategories(_req: HttpRequest, reply: HttpReply) { try { const data = await getAzkarContent(); return ok(reply, { @@ -24,7 +24,7 @@ export async function getAzkarCategories(_req: FastifyRequest, reply: FastifyRep } } -export async function getAzkarByCategory(req: FastifyRequest<{ Params: { category: string } }>, reply: FastifyReply) { +export async function getAzkarByCategory(req: HttpRequest<{ Params: { category: string } }>, reply: HttpReply) { const target = req.params.category.trim(); if (!target) return fail(reply, 400, 'Category is required'); @@ -40,7 +40,7 @@ export async function getAzkarByCategory(req: FastifyRequest<{ Params: { categor } } -export async function searchAzkar(req: FastifyRequest<{ Querystring: { text?: string; limit?: string } }>, reply: FastifyReply) { +export async function searchAzkar(req: HttpRequest<{ Querystring: { text?: string; limit?: string } }>, reply: HttpReply) { const text = req.query.text?.trim(); if (!text) return fail(reply, 400, 'Query parameter "text" is required'); @@ -69,7 +69,7 @@ export async function searchAzkar(req: FastifyRequest<{ Querystring: { text?: st } } -export async function getRandomZekr(_req: FastifyRequest, reply: FastifyReply) { +export async function getRandomZekr(_req: HttpRequest, reply: HttpReply) { try { const data = await getAzkarContent(); const allItems: AzkarResult[] = []; diff --git a/src/modules/azkar/azkar.route.ts b/src/modules/azkar/azkar.route.ts index 37b1bb2..034b860 100644 --- a/src/modules/azkar/azkar.route.ts +++ b/src/modules/azkar/azkar.route.ts @@ -4,12 +4,12 @@ * MIT License */ -import { FastifyInstance } from 'fastify'; +import { type RouteRegistrar } from '../../http/types.js'; import { getAzkarByCategory, getAzkarCategories, getRandomZekr, searchAzkar } from './azkar.controller.js'; -export default async function azkarRoutes(fastify: FastifyInstance) { - fastify.get('/azkar', getAzkarCategories); - fastify.get('/azkar/random', getRandomZekr); - fastify.get('/azkar/search', searchAzkar); - fastify.get('/azkar/:category', getAzkarByCategory); +export default function azkarRoutes(router: RouteRegistrar) { + router.get('/azkar', getAzkarCategories); + router.get('/azkar/random', getRandomZekr); + router.get('/azkar/search', searchAzkar); + router.get('/azkar/:category', getAzkarByCategory); } diff --git a/src/modules/azkar/azkar.service.ts b/src/modules/azkar/azkar.service.ts index b237852..5821d4a 100644 --- a/src/modules/azkar/azkar.service.ts +++ b/src/modules/azkar/azkar.service.ts @@ -4,71 +4,32 @@ * MIT License */ -import type { AzkarCategory, AzkarItem } from '../../types/Items.js'; +import type { AzkarCategory } from '../../types/Items.js'; import type { ApiFunction } from '../../types/Api.js'; import { fetchJson, runWithFallback } from '../../utils/fallback.js'; import { memoize } from '../../utils/cache.js'; -import { requiredText, positiveInteger } from '../../utils/validation.js'; +import { requiredText } from '../../utils/validation.js'; import { AZKAR_CATEGORIES } from './azkar.metadata.js'; -export const azkarApis: ApiFunction[] = [ - async () => { - const json = await fetchJson>( - 'https://cdn.jsdelivr.net/gh/rn0x/hisn_almuslim_json@0405ee1797c2ccadfe82cd41845338d54978ccb9/hisn_almuslim.json', - 12000, - ); - return AZKAR_CATEGORIES.map((meta) => { - const texts = json[meta.mirrorName]?.text; - if (!Array.isArray(texts) || !texts.length) throw new Error('Incomplete Hisn al-Muslim mirror'); - return { - category: meta.name, - items: texts.map((text, i) => ({ id: i + 1, text: requiredText(text) })), - apiName: 'cdn.jsdelivr.net/rn0x/hisn_almuslim_json', - }; - }); +const sources = [ + { + url: 'https://cdn.jsdelivr.net/gh/rn0x/hisn_almuslim_json@0405ee1797c2ccadfe82cd41845338d54978ccb9/hisn_almuslim.json', + apiName: 'cdn.jsdelivr.net/rn0x/hisn_almuslim_json', }, - async () => { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 20000); - try { - const json = await fetchJson<{ العربية: { ID: number; TITLE: string }[] }>( - 'https://www.hisnmuslim.com/api/ar/husn_ar.json', - 8000, - controller.signal, - ); - if (json['العربية'].length !== 132 || new Set(json['العربية'].map((c) => c.ID)).size !== 132) throw new Error('Incomplete Hisn index'); - const pending = [...AZKAR_CATEGORIES]; - const categories = new Map(); - await Promise.all( - Array.from({ length: 6 }, async () => { - while (pending.length) { - const meta = pending.shift()!; - if (!json['العربية'].some((c) => c.ID === meta.id)) throw new Error('Missing Hisn chapter'); - const payload = await fetchJson>( - 'https://www.hisnmuslim.com/api/ar/' + meta.id + '.json', - 8000, - controller.signal, - ); - const rows = Object.values(payload).flat(); - if (!rows.length) throw new Error('Empty Hisn chapter'); - const items: AzkarItem[] = rows.map((row, i) => ({ - id: i + 1, - text: requiredText(row.ARABIC_TEXT), - count: positiveInteger(row.REPEAT), - })); - categories.set(meta.id, { category: meta.name, items, apiName: 'hisnmuslim.com' }); - } - }), - ); - return AZKAR_CATEGORIES.map((c) => categories.get(c.id)!); - } catch (error) { - controller.abort(); - throw error; - } finally { - clearTimeout(timer); - } + { + url: 'https://raw.githubusercontent.com/rn0x/hisn_almuslim_json/0405ee1797c2ccadfe82cd41845338d54978ccb9/hisn_almuslim.json', + apiName: 'raw.githubusercontent.com/rn0x/hisn_almuslim_json', }, -]; +] as const; + +export const azkarApis: ApiFunction[] = sources.map(({ url, apiName }) => async () => { + const json = await fetchJson>(url, 12000); + return AZKAR_CATEGORIES.map((meta) => { + const texts = json[meta.mirrorName]?.text; + if (!Array.isArray(texts) || !texts.length) throw new Error('Incomplete Hisn al-Muslim mirror'); + return { category: meta.name, items: texts.map((text, i) => ({ id: i + 1, text: requiredText(text) })), apiName }; + }); +}); export async function getAzkarContent(): Promise<{ categories: AzkarCategory[] }> { return { categories: await memoize('azkar:all', () => runWithFallback(azkarApis), { ttlMs: 1000 * 60 * 60 * 24 }) }; } diff --git a/src/modules/hadith/hadith.controller.ts b/src/modules/hadith/hadith.controller.ts index 77c9d76..7ff803e 100644 --- a/src/modules/hadith/hadith.controller.ts +++ b/src/modules/hadith/hadith.controller.ts @@ -4,12 +4,12 @@ * MIT License */ -import { FastifyReply, FastifyRequest } from 'fastify'; +import { type HttpReply, type HttpRequest } from '../../http/types.js'; import { getBook, getHadith, getRandomHadithItem, isSupportedBook, listBooks } from './hadith.service.js'; import { parseInteger } from '../../utils/validation.js'; import { fail, ok, unavailable } from '../../utils/http.js'; -export async function getHadithBooks(_req: FastifyRequest, reply: FastifyReply) { +export async function getHadithBooks(_req: HttpRequest, reply: HttpReply) { try { const books = await listBooks(); return ok(reply, books); @@ -18,10 +18,7 @@ export async function getHadithBooks(_req: FastifyRequest, reply: FastifyReply) } } -export async function getHadithBook( - req: FastifyRequest<{ Params: { book: string }; Querystring: { from?: string; to?: string } }>, - reply: FastifyReply, -) { +export async function getHadithBook(req: HttpRequest<{ Params: { book: string }; Querystring: { from?: string; to?: string } }>, reply: HttpReply) { const bookId = req.params.book.trim(); if (!bookId) return fail(reply, 400, 'Book id is required'); if (!isSupportedBook(bookId)) return fail(reply, 404, 'Book not found'); @@ -42,7 +39,7 @@ export async function getHadithBook( } } -export async function getHadithByNumber(req: FastifyRequest<{ Params: { book: string; number: string } }>, reply: FastifyReply) { +export async function getHadithByNumber(req: HttpRequest<{ Params: { book: string; number: string } }>, reply: HttpReply) { const bookId = req.params.book.trim(); const number = parseInteger(req.params.number); if (!bookId) return fail(reply, 400, 'Book id is required'); @@ -58,7 +55,7 @@ export async function getHadithByNumber(req: FastifyRequest<{ Params: { book: st } } -export async function getRandomHadith(req: FastifyRequest<{ Querystring: { book?: string } }>, reply: FastifyReply) { +export async function getRandomHadith(req: HttpRequest<{ Querystring: { book?: string } }>, reply: HttpReply) { try { const books = await listBooks(); if (books.length === 0) return fail(reply, 503, 'No hadith books available'); diff --git a/src/modules/hadith/hadith.route.ts b/src/modules/hadith/hadith.route.ts index 71b4ae4..ad56c5e 100644 --- a/src/modules/hadith/hadith.route.ts +++ b/src/modules/hadith/hadith.route.ts @@ -4,12 +4,12 @@ * MIT License */ -import { FastifyInstance } from 'fastify'; +import { type RouteRegistrar } from '../../http/types.js'; import { getHadithBook, getHadithBooks, getHadithByNumber, getRandomHadith } from './hadith.controller.js'; -export default async function hadithRoutes(fastify: FastifyInstance) { - fastify.get('/hadith', getHadithBooks); - fastify.get('/hadith/random', getRandomHadith); - fastify.get('/hadith/:book', getHadithBook); - fastify.get('/hadith/:book/:number', getHadithByNumber); +export default function hadithRoutes(router: RouteRegistrar) { + router.get('/hadith', getHadithBooks); + router.get('/hadith/random', getRandomHadith); + router.get('/hadith/:book', getHadithBook); + router.get('/hadith/:book/:number', getHadithByNumber); } diff --git a/src/modules/hijri/hijri.controller.ts b/src/modules/hijri/hijri.controller.ts index d54e357..6039b5d 100644 --- a/src/modules/hijri/hijri.controller.ts +++ b/src/modules/hijri/hijri.controller.ts @@ -4,7 +4,7 @@ * MIT License */ -import { FastifyReply, FastifyRequest } from 'fastify'; +import { type HttpReply, type HttpRequest } from '../../http/types.js'; import { gregorianToHijri, hijriToGregorian } from './hijri.service.js'; import { fail, ok, unavailable } from '../../utils/http.js'; import { validGregorianDate } from '../../utils/validation.js'; @@ -16,7 +16,7 @@ function todayDDMMYYYY(): string { return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`; } -export async function convertGregorianToHijri(req: FastifyRequest<{ Querystring: { date?: string } }>, reply: FastifyReply) { +export async function convertGregorianToHijri(req: HttpRequest<{ Querystring: { date?: string } }>, reply: HttpReply) { const date = req.query.date?.trim() || todayDDMMYYYY(); if (!validGregorianDate(date)) return fail(reply, 400, 'Date must be a valid date in DD-MM-YYYY format'); @@ -27,7 +27,7 @@ export async function convertGregorianToHijri(req: FastifyRequest<{ Querystring: } } -export async function convertHijriToGregorian(req: FastifyRequest<{ Querystring: { date?: string } }>, reply: FastifyReply) { +export async function convertHijriToGregorian(req: HttpRequest<{ Querystring: { date?: string } }>, reply: HttpReply) { const date = req.query.date?.trim(); if (!date) return fail(reply, 400, 'Query parameter "date" (DD-MM-YYYY) is required'); if (!DATE_RE.test(date)) return fail(reply, 400, 'Date must be in DD-MM-YYYY format'); @@ -41,7 +41,7 @@ export async function convertHijriToGregorian(req: FastifyRequest<{ Querystring: } } -export async function getToday(_req: FastifyRequest, reply: FastifyReply) { +export async function getToday(_req: HttpRequest, reply: HttpReply) { try { return ok(reply, await gregorianToHijri(todayDDMMYYYY())); } catch (err) { diff --git a/src/modules/hijri/hijri.route.ts b/src/modules/hijri/hijri.route.ts index 9e1025c..f46f863 100644 --- a/src/modules/hijri/hijri.route.ts +++ b/src/modules/hijri/hijri.route.ts @@ -4,11 +4,11 @@ * MIT License */ -import { FastifyInstance } from 'fastify'; +import { type RouteRegistrar } from '../../http/types.js'; import { convertGregorianToHijri, convertHijriToGregorian, getToday } from './hijri.controller.js'; -export default async function hijriRoutes(fastify: FastifyInstance) { - fastify.get('/hijri/today', getToday); - fastify.get('/hijri/from-gregorian', convertGregorianToHijri); - fastify.get('/hijri/to-gregorian', convertHijriToGregorian); +export default function hijriRoutes(router: RouteRegistrar) { + router.get('/hijri/today', getToday); + router.get('/hijri/from-gregorian', convertGregorianToHijri); + router.get('/hijri/to-gregorian', convertHijriToGregorian); } diff --git a/src/modules/prayer/prayer.controller.ts b/src/modules/prayer/prayer.controller.ts index c04eae7..ff2046a 100644 --- a/src/modules/prayer/prayer.controller.ts +++ b/src/modules/prayer/prayer.controller.ts @@ -4,7 +4,7 @@ * MIT License */ -import { FastifyReply, FastifyRequest } from 'fastify'; +import { type HttpReply, type HttpRequest } from '../../http/types.js'; import { getPrayerTimes, isSupportedMethod } from './prayer.service.js'; import { parseInteger, validGregorianDate } from '../../utils/validation.js'; import { fail, ok, unavailable } from '../../utils/http.js'; @@ -30,7 +30,7 @@ function parseFloatOpt(value: string | undefined): number | undefined { return Number.isFinite(num) ? num : NaN; } -export async function getTimings(req: FastifyRequest<{ Querystring: PrayerQS }>, reply: FastifyReply) { +export async function getTimings(req: HttpRequest<{ Querystring: PrayerQS }>, reply: HttpReply) { const date = req.query.date?.trim() || todayDDMMYYYY(); if (!validGregorianDate(date)) return fail(reply, 400, 'Date must be a valid date in DD-MM-YYYY format'); diff --git a/src/modules/prayer/prayer.route.ts b/src/modules/prayer/prayer.route.ts index 28d8fc0..827aa5c 100644 --- a/src/modules/prayer/prayer.route.ts +++ b/src/modules/prayer/prayer.route.ts @@ -4,9 +4,9 @@ * MIT License */ -import { FastifyInstance } from 'fastify'; +import { type RouteRegistrar } from '../../http/types.js'; import { getTimings } from './prayer.controller.js'; -export default async function prayerRoutes(fastify: FastifyInstance) { - fastify.get('/prayer/times', getTimings); +export default function prayerRoutes(router: RouteRegistrar) { + router.get('/prayer/times', getTimings); } diff --git a/src/modules/qibla/qibla.controller.ts b/src/modules/qibla/qibla.controller.ts index 7e8b999..50c6d11 100644 --- a/src/modules/qibla/qibla.controller.ts +++ b/src/modules/qibla/qibla.controller.ts @@ -4,11 +4,11 @@ * MIT License */ -import { FastifyReply, FastifyRequest } from 'fastify'; +import { type HttpReply, type HttpRequest } from '../../http/types.js'; import { getQibla } from './qibla.service.js'; import { fail, ok, unavailable } from '../../utils/http.js'; -export async function qiblaDirection(req: FastifyRequest<{ Querystring: { latitude?: string; longitude?: string } }>, reply: FastifyReply) { +export async function qiblaDirection(req: HttpRequest<{ Querystring: { latitude?: string; longitude?: string } }>, reply: HttpReply) { if (!req.query.latitude?.trim() || !req.query.longitude?.trim()) return fail(reply, 400, 'latitude and longitude are required'); const lat = Number(req.query.latitude); const lng = Number(req.query.longitude); diff --git a/src/modules/qibla/qibla.route.ts b/src/modules/qibla/qibla.route.ts index 7ff6421..c7f1d03 100644 --- a/src/modules/qibla/qibla.route.ts +++ b/src/modules/qibla/qibla.route.ts @@ -4,9 +4,9 @@ * MIT License */ -import { FastifyInstance } from 'fastify'; +import { type RouteRegistrar } from '../../http/types.js'; import { qiblaDirection } from './qibla.controller.js'; -export default async function qiblaRoutes(fastify: FastifyInstance) { - fastify.get('/qibla', qiblaDirection); +export default function qiblaRoutes(router: RouteRegistrar) { + router.get('/qibla', qiblaDirection); } diff --git a/src/modules/reciters/reciters.controller.ts b/src/modules/reciters/reciters.controller.ts index afb86df..f0b1586 100644 --- a/src/modules/reciters/reciters.controller.ts +++ b/src/modules/reciters/reciters.controller.ts @@ -4,13 +4,13 @@ * MIT License */ -import { FastifyReply, FastifyRequest } from 'fastify'; +import { type HttpReply, type HttpRequest } from '../../http/types.js'; import { getRadioContent, resolveReciterAudio } from './reciters.service.js'; import { parseInteger } from '../../utils/validation.js'; import { normalizeArabic } from '../../utils/arabic.js'; import { fail, ok, unavailable } from '../../utils/http.js'; -export async function getRadio(_req: FastifyRequest, reply: FastifyReply) { +export async function getRadio(_req: HttpRequest, reply: HttpReply) { try { const data = await getRadioContent(); return ok(reply, data); @@ -19,7 +19,7 @@ export async function getRadio(_req: FastifyRequest, reply: FastifyReply) { } } -export async function getReciterById(req: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) { +export async function getReciterById(req: HttpRequest<{ Params: { id: string } }>, reply: HttpReply) { const id = parseInteger(req.params.id); if (Number.isNaN(id) || id < 1) return fail(reply, 400, 'Invalid reciter id'); @@ -34,8 +34,8 @@ export async function getReciterById(req: FastifyRequest<{ Params: { id: string } export async function getReciterSurah( - req: FastifyRequest<{ Params: { id: string; surah: string }; Querystring: { moshaf?: string } }>, - reply: FastifyReply, + req: HttpRequest<{ Params: { id: string; surah: string }; Querystring: { moshaf?: string } }>, + reply: HttpReply, ) { const reciterId = parseInteger(req.params.id); const surahNum = parseInteger(req.params.surah); @@ -60,7 +60,7 @@ export async function getReciterSurah( } } -export async function getReciterByName(req: FastifyRequest<{ Querystring: { name?: string } }>, reply: FastifyReply) { +export async function getReciterByName(req: HttpRequest<{ Querystring: { name?: string } }>, reply: HttpReply) { const name = req.query.name?.trim(); if (!name) return fail(reply, 400, 'Query parameter "name" is required'); diff --git a/src/modules/reciters/reciters.route.ts b/src/modules/reciters/reciters.route.ts index ce4388b..57304e8 100644 --- a/src/modules/reciters/reciters.route.ts +++ b/src/modules/reciters/reciters.route.ts @@ -4,15 +4,15 @@ * MIT License */ -import { FastifyInstance } from 'fastify'; +import { type RouteRegistrar } from '../../http/types.js'; import { getRadio, getReciterById, getReciterByName, getReciterSurah } from './reciters.controller.js'; -export default async function radioRoutes(fastify: FastifyInstance) { - fastify.get('/reciters', getRadio); +export default function radioRoutes(router: RouteRegistrar) { + router.get('/reciters', getRadio); - fastify.get('/reciters/:id', getReciterById); + router.get('/reciters/:id', getReciterById); - fastify.get('/reciters/search', getReciterByName); + router.get('/reciters/search', getReciterByName); - fastify.get('/reciters/:id/surah/:surah', getReciterSurah); + router.get('/reciters/:id/surah/:surah', getReciterSurah); } diff --git a/src/modules/reciters/reciters.service.ts b/src/modules/reciters/reciters.service.ts index c11cd69..80bfbeb 100644 --- a/src/modules/reciters/reciters.service.ts +++ b/src/modules/reciters/reciters.service.ts @@ -77,7 +77,7 @@ export function buildReciterAudioApis(reciter: ReciterItem, surah: number, mosha const apis: (() => Promise)[] = [ async () => { const audio = audioUrl(new URL(String(surah).padStart(3, '0') + '.mp3', recording.server).toString(), 'mp3quran.net'); - const response = await fetchWithTimeout(audio, { method: 'HEAD', redirect: 'error' }); + const response = await fetchWithTimeout(audio, { method: 'HEAD', redirect: 'manual' }); if (!response.ok || !/^(audio\/|application\/octet-stream)/i.test(response.headers.get('content-type') ?? '')) throw new Error('Audio file unavailable'); return { reciter: reciter.name, surah, audio, moshafId: recording.id, rewayaId: recording.rewayaId, apiName: 'mp3quran.net' }; @@ -92,7 +92,7 @@ export function buildReciterAudioApis(reciter: ReciterItem, surah: number, mosha ); if (json.audio_file.chapter_id !== surah) throw new Error('Wrong audio chapter'); const audio = audioUrl(json.audio_file.audio_url, 'quranicaudio.com'); - const response = await fetchWithTimeout(audio, { method: 'HEAD', redirect: 'error' }); + const response = await fetchWithTimeout(audio, { method: 'HEAD', redirect: 'manual' }); if (!response.ok || !/^(audio\/|application\/octet-stream)/i.test(response.headers.get('content-type') ?? '')) throw new Error('Fallback audio file unavailable'); return { reciter: reciter.name, surah, audio, moshafId: recording.id, rewayaId: recording.rewayaId, apiName: 'quran.com' }; diff --git a/src/modules/surah/surah.controller.ts b/src/modules/surah/surah.controller.ts index de4582d..ed3a7c0 100644 --- a/src/modules/surah/surah.controller.ts +++ b/src/modules/surah/surah.controller.ts @@ -4,13 +4,13 @@ * MIT License */ -import { FastifyReply, FastifyRequest } from 'fastify'; +import { type HttpReply, type HttpRequest } from '../../http/types.js'; import { getSurahContent } from './surah.service.js'; import { normalizeArabic } from '../../utils/arabic.js'; import { fail, ok, unavailable } from '../../utils/http.js'; import { parseInteger } from '../../utils/validation.js'; -export async function getSurah(_req: FastifyRequest, reply: FastifyReply) { +export async function getSurah(_req: HttpRequest, reply: HttpReply) { try { const data = await getSurahContent(); return ok(reply, data); @@ -19,7 +19,7 @@ export async function getSurah(_req: FastifyRequest, reply: FastifyReply) { } } -export async function getSurahById(req: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) { +export async function getSurahById(req: HttpRequest<{ Params: { id: string } }>, reply: HttpReply) { const id = parseInteger(req.params.id); if (Number.isNaN(id) || id < 1 || id > 114) return fail(reply, 400, 'Surah id must be between 1 and 114'); @@ -33,7 +33,7 @@ export async function getSurahById(req: FastifyRequest<{ Params: { id: string } } } -export async function getSurahByName(req: FastifyRequest<{ Querystring: { name?: string } }>, reply: FastifyReply) { +export async function getSurahByName(req: HttpRequest<{ Querystring: { name?: string } }>, reply: HttpReply) { const name = req.query.name?.trim(); if (!name) return fail(reply, 400, 'Query parameter "name" is required'); diff --git a/src/modules/surah/surah.route.ts b/src/modules/surah/surah.route.ts index dc0a74c..5c2268a 100644 --- a/src/modules/surah/surah.route.ts +++ b/src/modules/surah/surah.route.ts @@ -4,13 +4,13 @@ * MIT License */ -import { FastifyInstance } from 'fastify'; +import { type RouteRegistrar } from '../../http/types.js'; import { getSurahById, getSurah, getSurahByName } from './surah.controller.js'; -export default async function surahRoutes(fastify: FastifyInstance) { - fastify.get('/surah', getSurah); +export default function surahRoutes(router: RouteRegistrar) { + router.get('/surah', getSurah); - fastify.get('/surah/:id', getSurahById); + router.get('/surah/:id', getSurahById); - fastify.get('/surah/search', getSurahByName); + router.get('/surah/search', getSurahByName); } diff --git a/src/modules/tafsir/tafsir.controller.ts b/src/modules/tafsir/tafsir.controller.ts index d37f04e..7fb597b 100644 --- a/src/modules/tafsir/tafsir.controller.ts +++ b/src/modules/tafsir/tafsir.controller.ts @@ -4,19 +4,19 @@ * MIT License */ -import { FastifyReply, FastifyRequest } from 'fastify'; +import { type HttpReply, type HttpRequest } from '../../http/types.js'; import { getTafsir, isSupportedEdition, listEditions } from './tafsir.service.js'; import { fail, ok, unavailable } from '../../utils/http.js'; import { parseInteger } from '../../utils/validation.js'; import { SURAH_METADATA } from '../surah/surah.metadata.js'; -export async function getTafsirEditions(_req: FastifyRequest, reply: FastifyReply) { +export async function getTafsirEditions(_req: HttpRequest, reply: HttpReply) { return ok(reply, listEditions()); } export async function getTafsirForSurah( - req: FastifyRequest<{ Params: { edition: string; surah: string }; Querystring: { aya?: string } }>, - reply: FastifyReply, + req: HttpRequest<{ Params: { edition: string; surah: string }; Querystring: { aya?: string } }>, + reply: HttpReply, ) { const { edition, surah } = req.params; if (!isSupportedEdition(edition)) return fail(reply, 400, 'Unsupported tafsir edition'); @@ -42,7 +42,7 @@ export async function getTafsirForSurah( } } -export async function getTafsirForAya(req: FastifyRequest<{ Params: { edition: string; surah: string; aya: string } }>, reply: FastifyReply) { +export async function getTafsirForAya(req: HttpRequest<{ Params: { edition: string; surah: string; aya: string } }>, reply: HttpReply) { const { edition, surah, aya } = req.params; if (!isSupportedEdition(edition)) return fail(reply, 400, 'Unsupported tafsir edition'); diff --git a/src/modules/tafsir/tafsir.route.ts b/src/modules/tafsir/tafsir.route.ts index 9309b5d..500aa7d 100644 --- a/src/modules/tafsir/tafsir.route.ts +++ b/src/modules/tafsir/tafsir.route.ts @@ -4,11 +4,11 @@ * MIT License */ -import { FastifyInstance } from 'fastify'; +import { type RouteRegistrar } from '../../http/types.js'; import { getTafsirEditions, getTafsirForAya, getTafsirForSurah } from './tafsir.controller.js'; -export default async function tafsirRoutes(fastify: FastifyInstance) { - fastify.get('/tafsir', getTafsirEditions); - fastify.get('/tafsir/:edition/:surah', getTafsirForSurah); - fastify.get('/tafsir/:edition/:surah/:aya', getTafsirForAya); +export default function tafsirRoutes(router: RouteRegistrar) { + router.get('/tafsir', getTafsirEditions); + router.get('/tafsir/:edition/:surah', getTafsirForSurah); + router.get('/tafsir/:edition/:surah/:aya', getTafsirForAya); } diff --git a/src/types/Items.ts b/src/types/Items.ts index 29ae0a9..a04e2db 100644 --- a/src/types/Items.ts +++ b/src/types/Items.ts @@ -7,7 +7,7 @@ export type SurahApiSource = 'mp3quran.net' | 'alquran.cloud' | 'quran.com' | 'local'; export type ReciterApiSource = 'mp3quran.net' | 'local'; export type AyatApiSource = 'alquran.cloud' | 'cdn.jsdelivr.net/fawazahmed0/quran-api' | 'quran.com'; -export type AzkarApiSource = 'hisnmuslim.com' | 'cdn.jsdelivr.net/rn0x/hisn_almuslim_json'; +export type AzkarApiSource = 'raw.githubusercontent.com/rn0x/hisn_almuslim_json' | 'cdn.jsdelivr.net/rn0x/hisn_almuslim_json'; export type TafsirApiSource = 'alquran.cloud' | 'quranenc.com' | 'quran.com' | 'cdn.jsdelivr.net/spa5k/tafsir_api'; export type HadithApiSource = 'cdn.jsdelivr.net/gadingnst/hadith-api' | 'raw.githubusercontent.com/gadingnst/hadith-api' | 'local'; export type PrayerApiSource = 'aladhan.com' | 'local'; diff --git a/src/utils/cache.ts b/src/utils/cache.ts index 1226731..d63eeb8 100644 --- a/src/utils/cache.ts +++ b/src/utils/cache.ts @@ -4,6 +4,8 @@ * MIT License */ +import { AsyncLocalStorage } from 'node:async_hooks'; + interface CacheEntry { value: T; expiresAt: number; @@ -12,6 +14,12 @@ interface CacheEntry { const store = new Map>(); const inflight = new Map>(); +let generation = 0; +const cacheContext = new AsyncLocalStorage<{ inflight: Map>; waitUntil: (task: Promise) => void }>(); + +export function withCacheContext(waitUntil: (task: Promise) => void, run: () => T): T { + return cacheContext.run({ inflight: new Map(), waitUntil }, run); +} export interface MemoizeOptions { ttlMs?: number; @@ -28,38 +36,45 @@ export async function memoize(key: string, loader: () => Promise, options: const cached = store.get(key) as CacheEntry | undefined; if (cached && cached.expiresAt > now) return cached.value; if (cached && cached.staleUntil > now) { - void refresh(key, loader, ttl, staleWhileRevalidateMs, maxEntries).catch(() => undefined); + const task = refresh(key, loader, ttl, staleWhileRevalidateMs, maxEntries).catch(() => undefined); + cacheContext.getStore()?.waitUntil(task); return cached.value; } - const existing = inflight.get(key) as Promise | undefined; + const existing = (cacheContext.getStore()?.inflight ?? inflight).get(key) as Promise | undefined; if (existing) return existing; return refresh(key, loader, ttl, staleWhileRevalidateMs, maxEntries); } export function invalidate(key: string): void { + generation++; store.delete(key); inflight.delete(key); + cacheContext.getStore()?.inflight.delete(key); } export function clearCache(): void { + generation++; store.clear(); inflight.clear(); + cacheContext.getStore()?.inflight.clear(); } export function getCacheStats(): { entries: number; inflight: number } { - return { entries: store.size, inflight: inflight.size }; + return { entries: store.size, inflight: (cacheContext.getStore()?.inflight ?? inflight).size }; } function refresh(key: string, loader: () => Promise, ttl: number, staleWhileRevalidateMs: number, maxEntries: number): Promise { - const existing = inflight.get(key) as Promise | undefined; + const active = cacheContext.getStore()?.inflight ?? inflight; + const startedGeneration = generation; + const existing = active.get(key) as Promise | undefined; if (existing) return existing; const task = Promise.resolve().then(async () => { try { const value = await loader(); - if (inflight.get(key) === task) { + if (active.get(key) === task && startedGeneration === generation) { store.set(key, { value, expiresAt: Date.now() + ttl, @@ -69,11 +84,11 @@ function refresh(key: string, loader: () => Promise, ttl: number, staleWhi } return value; } finally { - if (inflight.get(key) === task) inflight.delete(key); + if (active.get(key) === task) active.delete(key); } }); - inflight.set(key, task); + active.set(key, task); return task; } diff --git a/src/utils/http.ts b/src/utils/http.ts index 79142fe..c50e3b5 100644 --- a/src/utils/http.ts +++ b/src/utils/http.ts @@ -4,7 +4,7 @@ * MIT License */ -import { FastifyReply } from 'fastify'; +import { type HttpReply } from '../http/types.js'; export interface OkBody { success: true; @@ -23,11 +23,11 @@ export interface FailBody { }; } -export function ok(reply: FastifyReply, data: T, status = 200): FastifyReply { +export function ok(reply: HttpReply, data: T, status = 200): HttpReply { return reply.status(status).send({ success: true, data } satisfies OkBody); } -export function fail(reply: FastifyReply, status: number, message: string, code = codeForStatus(status)): FastifyReply { +export function fail(reply: HttpReply, status: number, message: string, code = codeForStatus(status)): HttpReply { return reply.status(status).send({ success: false, message, @@ -39,7 +39,7 @@ export function fail(reply: FastifyReply, status: number, message: string, code } satisfies FailBody); } -export function unavailable(reply: FastifyReply, err: unknown): FastifyReply { +export function unavailable(reply: HttpReply, err: unknown): HttpReply { reply.log.warn({ err }, 'All upstream sources failed'); return fail(reply, 503, 'All upstream sources are unavailable', 'ALL_SOURCES_FAILED'); } diff --git a/src/worker.ts b/src/worker.ts index 9c1ac07..9056ea3 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -4,78 +4,16 @@ * MIT License */ -import { DurableObject } from 'cloudflare:workers'; +import { createWorkerApp } from './http/worker-app.js'; +import { withCacheContext } from './utils/cache.js'; -const CONTAINER_PORT = 3000; -const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000; -const STARTUP_TIMEOUT_MS = 30_000; - -export class BonyanApiContainer extends DurableObject { - private startPromise?: Promise; - private ready = false; - - private async ensureContainerRunning(): Promise { - const container = this.ctx.container; - if (!container) { - throw new Error('BonyanApiContainer is missing its container binding'); - } - - if (!container.running) { - this.ready = false; - } - - if (this.ready) { - return; - } - - this.startPromise ??= (async () => { - if (!container.running) { - container.start(); - } - await container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS); - await this.waitForReadiness(container); - this.ready = true; - })().finally(() => { - this.startPromise = undefined; - }); - - await this.startPromise; - } - - private async waitForReadiness(container: Container): Promise { - const port = container.getTcpPort(CONTAINER_PORT); - const deadline = Date.now() + STARTUP_TIMEOUT_MS; - - while (Date.now() < deadline) { - try { - const response = await port.fetch('http://container/ready', { - signal: AbortSignal.timeout(1000), - }); - const ready = response.ok; - await response.body?.cancel(); - if (ready) return; - } catch { - // The process can be running before Fastify starts listening. - } - await new Promise((resolve) => setTimeout(resolve, 250)); - } - - throw new Error('Bonyan API container did not become ready within 30 seconds'); - } - - async fetch(request: Request) { - await this.ensureContainerRunning(); - return this.ctx.container!.getTcpPort(CONTAINER_PORT).fetch(request); - } -} - -interface Env { - BONYAN_API: DurableObjectNamespace; -} +const app = createWorkerApp(); export default { - async fetch(request: Request, env: Env) { - const id = env.BONYAN_API.idFromName('default'); - return env.BONYAN_API.get(id).fetch(request); + fetch(request: Request, env: Record, ctx: ExecutionContext): Promise { + return withCacheContext( + (task) => ctx.waitUntil(task), + () => app.fetch(request, env), + ); }, }; diff --git a/tests/cache.test.ts b/tests/cache.test.ts index 28b17ca..cd3271d 100644 --- a/tests/cache.test.ts +++ b/tests/cache.test.ts @@ -5,11 +5,60 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { memoize, invalidate, clearCache } from '../src/utils/cache'; +import { memoize, invalidate, clearCache, withCacheContext } from '../src/utils/cache'; describe('memoize', () => { beforeEach(() => clearCache()); + it('isolates pending I/O between Worker requests while sharing settled values', async () => { + const loader = vi.fn(async () => 42); + const waitUntil = vi.fn(); + const first = withCacheContext(waitUntil, () => Promise.all([memoize('worker', loader), memoize('worker', loader)])); + const second = withCacheContext(waitUntil, () => memoize('worker', loader)); + expect(await first).toEqual([42, 42]); + expect(await second).toBe(42); + expect(loader).toHaveBeenCalledTimes(2); + await withCacheContext(waitUntil, () => memoize('worker', loader)); + expect(loader).toHaveBeenCalledTimes(2); + }); + + it('extends a stale refresh lifetime with the Worker context', async () => { + await memoize('stale', async () => 'old', { ttlMs: -1, staleWhileRevalidateMs: 60_000 }); + const tasks: Promise[] = []; + const value = await withCacheContext( + (task) => tasks.push(task), + () => memoize('stale', async () => 'new'), + ); + expect(value).toBe('old'); + expect(tasks).toHaveLength(1); + await Promise.all(tasks); + expect(await memoize('stale', async () => 'unexpected')).toBe('new'); + }); + + it('does not restore invalidated data from another Worker request', async () => { + let finishOld!: (value: string) => void; + const old = withCacheContext( + () => {}, + () => + memoize( + 'worker-race', + () => + new Promise((resolve) => { + finishOld = resolve; + }), + ), + ); + await Promise.resolve(); + invalidate('worker-race'); + await withCacheContext( + () => {}, + () => memoize('worker-race', async () => 'new'), + ); + finishOld('old'); + await old; + expect(await memoize('worker-race', async () => 'unexpected')).toBe('new'); + }); + it('caches the loader result for subsequent calls', async () => { const loader = vi.fn(async () => ({ value: 1 })); const a = await memoize('k', loader); diff --git a/tests/openapi.test.ts b/tests/openapi.test.ts index 56cc2f7..c94032e 100644 --- a/tests/openapi.test.ts +++ b/tests/openapi.test.ts @@ -9,6 +9,7 @@ import { parse } from 'yaml'; import Ajv from 'ajv/dist/2020.js'; import { beforeAll, afterAll, describe, expect, it, vi } from 'vitest'; import { buildApp } from '../src/app'; +import { createWorkerApp } from '../src/http/worker-app'; import { clearCache } from '../src/utils/cache'; import { SURAH_METADATA } from '../src/modules/surah/surah.metadata'; import { AZKAR_CATEGORIES } from '../src/modules/azkar/azkar.metadata'; @@ -47,8 +48,24 @@ function validateResponse(path: string, status: number, body: unknown): void { expect(validate(body), JSON.stringify(validate.errors)).toBe(true); } -describe('OpenAPI response contracts', () => { - let app: Awaited>; +async function contractApp(adapter: string) { + if (adapter === 'node') return buildApp({ logger: false }); + const worker = createWorkerApp(); + return { + async inject(url: string) { + const response = await worker.fetch(new Request('https://api.example' + url), { + RATE_LIMIT_MAX: process.env.RATE_LIMIT_MAX ?? '1000', + }); + const body = await response.text(); + return { statusCode: response.status, body, headers: Object.fromEntries(response.headers), json: () => JSON.parse(body) }; + }, + async ready() {}, + async close() {}, + }; +} + +describe.each(['node', 'worker'])('OpenAPI response contracts (%s)', (adapter) => { + let app: Awaited>; beforeAll(async () => { clearCache(); vi.mocked(fetch).mockImplementation(async (input, init) => { @@ -120,7 +137,7 @@ describe('OpenAPI response contracts', () => { if (url.pathname.includes('/qibla/')) return json({ data: { latitude: 24.7136, longitude: 46.6753, direction: 243.8 } }); throw new Error('Unexpected fixture request: ' + url.hostname + url.pathname); }); - app = await buildApp({ logger: false }); + app = await contractApp(adapter); await app.ready(); }); afterAll(async () => { @@ -230,7 +247,7 @@ describe('OpenAPI response contracts', () => { it('returns the same error contract for rate-limited requests', async () => { vi.stubEnv('RATE_LIMIT_MAX', '1'); - const limited = await buildApp({ logger: false }); + const limited = await contractApp(adapter); try { await limited.inject('/health'); const response = await limited.inject('/health'); diff --git a/tests/upstream-contract.test.ts b/tests/upstream-contract.test.ts index 94f4359..5ee78b8 100644 --- a/tests/upstream-contract.test.ts +++ b/tests/upstream-contract.test.ts @@ -9,7 +9,7 @@ import { clearCache } from '../src/utils/cache'; import { fetchJson, runWithFallback } from '../src/utils/fallback'; import { surahApis } from '../src/modules/surah/surah.service'; import { ayatApis, getAyatContent } from '../src/modules/ayat/ayat.service'; -import { reciterApis, resolveReciterAudio } from '../src/modules/reciters/reciters.service'; +import { reciterApis, resolveReciterAudio, buildReciterAudioApis } from '../src/modules/reciters/reciters.service'; import { buildTafsirApis, isSupportedEdition } from '../src/modules/tafsir/tafsir.service'; import { buildHadithApis, getRandomHadithItem } from '../src/modules/hadith/hadith.service'; import { buildApis } from '../src/modules/prayer/prayer.service'; @@ -115,6 +115,21 @@ describe('Upstream contracts and failover', () => { expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1); }); + it('rejects audio redirects without following an unapproved host in either adapter', async () => { + const reciter = (await reciterApis[1]!()).find((r) => r.id === 123)!; + const apis = buildReciterAudioApis(reciter, 1); + vi.mocked(fetch).mockImplementation(async (_input, init) => { + if (init?.method === 'HEAD') { + expect(init.redirect).toBe('manual'); + return new Response(null, { status: 302, headers: { location: 'https://unapproved.example/audio.mp3' } }); + } + return response({ audio_file: { chapter_id: 1, audio_url: 'https://download.quranicaudio.com/test.mp3' } }); + }); + await expect(apis[0]!()).rejects.toThrow('Audio file unavailable'); + await expect(apis[1]!()).rejects.toThrow('Fallback audio file unavailable'); + expect(fetch).toHaveBeenCalledTimes(3); + }); + it('converts QuranEnc string references and keeps the requested edition name', async () => { vi.mocked(fetch).mockResolvedValue( response({ result: Array.from({ length: 7 }, (_, i) => ({ sura: '1', aya: String(i + 1), translation: 'تفسير اختبار' })) }), @@ -151,15 +166,17 @@ describe('Upstream contracts and failover', () => { expect(categories.some((c) => c.category === 'أذكار الصباح والمساء')).toBe(true); }); - it('follows Hisn category resources rather than treating the index as supplications', async () => { + it('fails over to the same pinned Hisn corpus within the Workers subrequest budget', async () => { vi.mocked(fetch).mockImplementation(async (input) => { - if (String(input).endsWith('husn_ar.json')) return response({ العربية: AZKAR_CATEGORIES.map((c) => ({ ID: c.id, TITLE: c.name })) }); - return response({ category: [{ ID: 75, ARABIC_TEXT: 'ذكر اختبار', REPEAT: 3 }] }); + if (String(input).includes('cdn.jsdelivr.net')) return new Response(null, { status: 503 }); + return response(Object.fromEntries(AZKAR_CATEGORIES.map((c) => [c.mirrorName, { text: ['ذكر اختبار'] }]))); }); - const categories = await azkarApis[1]!(); + const categories = await runWithFallback(azkarApis); expect(categories).toHaveLength(132); - expect(categories[0]?.items[0]).toEqual({ id: 1, text: 'ذكر اختبار', count: 3 }); - expect(fetch).toHaveBeenCalledTimes(133); + expect(categories[0]?.items[0]).toEqual({ id: 1, text: 'ذكر اختبار' }); + expect(categories.every((c) => c.apiName === 'raw.githubusercontent.com/rn0x/hisn_almuslim_json')).toBe(true); + expect(fetch).toHaveBeenCalledTimes(2); + expect(vi.mocked(fetch).mock.calls[1]?.[0]).toContain('0405ee1797c2ccadfe82cd41845338d54978ccb9'); }); it('uses pinned hadith arrays on the second host and chooses only existing numbers', async () => { diff --git a/tests/worker.test.ts b/tests/worker.test.ts index 5a9b0cf..8134c87 100644 --- a/tests/worker.test.ts +++ b/tests/worker.test.ts @@ -4,85 +4,94 @@ * MIT License */ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { buildApp } from '../src/app'; +import { createWorkerApp } from '../src/http/worker-app'; +import { clearCache } from '../src/utils/cache'; -vi.mock('cloudflare:workers', () => ({ - DurableObject: class { - constructor(protected ctx: unknown) {} - }, -})); - -import { BonyanApiContainer } from '../src/worker.js'; +let node: Awaited>; +let worker: ReturnType; +beforeEach(async () => { + clearCache(); + node = await buildApp({ logger: false }); + worker = createWorkerApp(); +}); +afterEach(async () => { + await node.close(); + clearCache(); + vi.unstubAllEnvs(); + vi.useRealTimers(); +}); -function createContainer(running = false) { - const port = { fetch: vi.fn().mockImplementation(async () => new Response('ok')) }; - const container = { - running, - start: vi.fn(() => { - container.running = true; - }), - setInactivityTimeout: vi.fn().mockResolvedValue(undefined), - getTcpPort: vi.fn(() => port), - }; - const object = new BonyanApiContainer({ container } as unknown as DurableObjectState, {}); - return { object, container, port }; +async function request(path: string, init: RequestInit = {}, env: Record = {}) { + return worker.fetch(new Request('https://api.example' + path, init), env); } -afterEach(() => vi.useRealTimers()); +describe('native Worker HTTP adapter', () => { + it.each(['/surah/search', '/reciters/search', '/ayat/search', '/azkar/search', '/hadith/random', '/surah/1abc', '/unknown'])( + 'preserves static route precedence and error envelopes for %s', + async (path) => { + const expected = await node.inject(path); + const response = await request(path); + expect(response.status).toBe(expected.statusCode); + const body = await response.json(); + expect(body).toMatchObject({ success: false, message: expected.json().message, error: { code: expected.json().error.code } }); + }, + ); -describe('container request readiness', () => { - it('waits for readiness before forwarding concurrent requests', async () => { - const { object, container, port } = createContainer(); - let resolveReady!: (response: Response) => void; - port.fetch.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveReady = resolve; - }), - ); + it('decodes Arabic path segments and prioritizes search over ID routes', async () => { + const path = '/surah/search?name=' + encodeURIComponent('الفاتحة'); + const expected = await node.inject(path); + const response = await request(path); + expect(response.status).toBe(200); + expect(await response.json()).toEqual(expected.json()); + }); - const first = new Request('https://api.example/health'); - const second = new Request('https://api.example/ready'); - const requests = [object.fetch(first), object.fetch(second)]; - await vi.waitFor(() => expect(port.fetch).toHaveBeenCalledTimes(1)); - expect(container.start).toHaveBeenCalledTimes(1); - expect(port.fetch.mock.calls[0]?.[0]).toBe('http://container/ready'); + it('retains case-sensitive routes and rejects malformed URL encoding', async () => { + expect((await request('/HEALTH')).status).toBe(404); + expect((await request('/azkar/%E0%A4%A')).status).toBe(400); + expect((await request('/surah/')).status).toBe((await node.inject('/surah/')).statusCode); + }); - resolveReady(new Response('ready')); - await Promise.all(requests); - expect(port.fetch).toHaveBeenCalledWith(first); - expect(port.fetch).toHaveBeenCalledWith(second); + it('preserves repeated query parameters instead of silently selecting a value', async () => { + const path = '/qibla?latitude=24&latitude=25&longitude=46'; + const expected = await node.inject(path); + const response = await request(path); + expect(response.status).toBe(expected.statusCode); + expect(await response.json()).toMatchObject({ message: expected.json().message }); }); - it('checks an existing container and reapplies its inactivity timeout', async () => { - const { object, container, port } = createContainer(true); - await object.fetch(new Request('https://api.example/health')); - expect(container.start).not.toHaveBeenCalled(); - expect(container.setInactivityTimeout).toHaveBeenCalledWith(600_000); - expect(port.fetch.mock.calls[0]?.[0]).toBe('http://container/ready'); + it('returns HEAD headers with an empty response body', async () => { + const response = await request('/health', { method: 'HEAD' }); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('application/json'); + expect(await response.text()).toBe(''); + expect((await request('/health', { method: 'POST' })).status).toBe(404); }); - it('rechecks readiness after a stopped container restarts', async () => { - const { object, container, port } = createContainer(); - await object.fetch(new Request('https://api.example/health')); - container.running = false; - await object.fetch(new Request('https://api.example/health')); - expect(container.start).toHaveBeenCalledTimes(2); - expect(port.fetch.mock.calls.filter(([input]) => input === 'http://container/ready')).toHaveLength(2); + it('reflects allowed CORS origins and handles preflight', async () => { + const headers = { origin: 'https://client.example', 'access-control-request-method': 'GET', 'access-control-request-headers': 'x-test' }; + const response = await request('/health', { method: 'OPTIONS', headers }, { CORS_ORIGIN: 'https://client.example' }); + expect(response.status).toBe(204); + expect(response.headers.get('access-control-allow-origin')).toBe(headers.origin); + expect(response.headers.get('access-control-allow-headers')).toBe('x-test'); + expect(response.headers.get('vary')).toContain('Access-Control-Request-Headers'); + const denied = await request('/health', { headers: { origin: 'https://other.example' } }, { CORS_ORIGIN: headers.origin }); + expect(denied.headers.has('access-control-allow-origin')).toBe(false); + expect((await request('/health', { method: 'OPTIONS' })).status).toBe(400); }); - it('times out without forwarding and allows a later request to retry', async () => { + it('limits each client IP and resets after the configured window', async () => { vi.useFakeTimers(); - const { object, port } = createContainer(); - port.fetch.mockRejectedValue(new Error('connection refused')); - const request = new Request('https://api.example/health'); - const pending = expect(object.fetch(request)).rejects.toThrow('did not become ready'); - await vi.advanceTimersByTimeAsync(30_000); - await pending; - expect(port.fetch).not.toHaveBeenCalledWith(request); - - port.fetch.mockResolvedValue(new Response('ok')); - await object.fetch(request); - expect(port.fetch).toHaveBeenCalledWith(request); + const env = { RATE_LIMIT_MAX: '1', RATE_LIMIT_WINDOW: '2 seconds' }; + const init = { headers: { 'cf-connecting-ip': '192.0.2.1' } }; + expect((await request('/health', init, env)).status).toBe(200); + const limited = await request('/health', init, env); + expect(limited.status).toBe(429); + expect(limited.headers.get('retry-after')).toBe('2'); + expect(await limited.json()).toMatchObject({ error: { code: 'RATE_LIMITED' } }); + expect((await request('/health', { headers: { 'cf-connecting-ip': '192.0.2.2' } }, env)).status).toBe(200); + vi.advanceTimersByTime(2000); + expect((await request('/health', init, env)).status).toBe(200); }); }); diff --git a/wrangler.toml b/wrangler.toml index 2f99ed2..e394891 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -2,16 +2,19 @@ name = "bonyan-api" main = "src/worker.ts" compatibility_date = "2026-10-04" compatibility_flags = ["nodejs_compat"] +workers_dev = false +preview_urls = true -[[containers]] -class_name = "BonyanApiContainer" -image = "./Dockerfile" -max_instances = 3 +[[routes]] +pattern = "api.bonyanoss.org" +custom_domain = true -[[durable_objects.bindings]] -name = "BONYAN_API" -class_name = "BonyanApiContainer" +[previews] [[migrations]] tag = "v1" new_sqlite_classes = ["BonyanApiContainer"] + +[[migrations]] +tag = "v2" +deleted_classes = ["BonyanApiContainer"]