Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
Expand Down
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions SOURCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
68 changes: 59 additions & 9 deletions scripts/check-worker-startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,76 @@
* 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,
port: 0,
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();
}
20 changes: 2 additions & 18 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down
32 changes: 32 additions & 0 deletions src/http/routes.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
28 changes: 28 additions & 0 deletions src/http/types.ts
Original file line number Diff line number Diff line change
@@ -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<Route extends RequestFields = RequestFields> {
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<Route extends RequestFields = RequestFields> = (request: HttpRequest<Route>, reply: HttpReply) => unknown | Promise<unknown>;

export interface RouteRegistrar {
get<Route extends RequestFields>(path: string, handler: HttpHandler<Route>): unknown;
}
Loading
Loading