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
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,22 @@ jobs:
- name: Run TypeScript build
run: pnpm build

- name: Validate Cloudflare Worker and container 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
timeout-minutes: 5
run: |
docker build -t bonyan-api-ci .
docker run --detach --name bonyan-api-ci --publish 127.0.0.1:3000:3000 bonyan-api-ci
trap 'docker logs bonyan-api-ci; docker rm --force bonyan-api-ci' EXIT
curl --fail --silent --show-error --retry 30 --retry-delay 1 --retry-all-errors --retry-max-time 60 --max-time 5 http://127.0.0.1:3000/health --output /tmp/bonyan-health.json
node -e "const fs = require('node:fs'); const assert = require('node:assert/strict'); assert.equal(JSON.parse(fs.readFileSync('/tmp/bonyan-health.json', 'utf8')).status, 'ok');"

- name: Run Vitest tests with coverage
run: pnpm test:coverage

Expand Down
2 changes: 1 addition & 1 deletion DOCKERFILE → Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ WORKDIR /src
COPY tsconfig.json ./
COPY src ./src
RUN pnpm run build
RUN pnpm prune --prod
RUN pnpm prune --prod --ignore-scripts

FROM node:24-alpine
ENV NODE_ENV=production
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,26 @@ Copy `.env.example` only when creating a new configuration. Keep an existing `.e
```sh
pnpm build
pnpm start
docker build -f DOCKERFILE -t bonyan-api .
docker build -f Dockerfile -t bonyan-api .
docker run --rm -p 3000:3000 --env-file .env bonyan-api
```

The default address is `http://localhost:3000`. Configuration names are listed in `.env.example`.

### Cloudflare Workers

This is an API Worker, not a static Pages site. The repository includes `wrangler.toml` and a `src/worker.ts` entrypoint. In Cloudflare's project settings use:
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:

```text
Build command: pnpm run build
Deploy command: pnpm run deploy:cloudflare
```

Set production variables and secrets in the Worker settings. Do not upload `.env`. The Worker uses Node.js compatibility for Fastify's HTTP server; Cloudflare documents this integration through `httpServerHandler`. For a local deployment, run `pnpm build` before `pnpm 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.

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.

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.

## API

Expand Down
10 changes: 6 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"check:upstreams": "tsx scripts/check-upstreams.ts",
"deploy:cloudflare": "wrangler deploy",
"cloudflare:check": "pnpm exec wrangler deploy --dry-run --containers-rollout=none --config wrangler.toml",
"cloudflare:startup": "tsx scripts/check-worker-startup.ts",
"deploy:cloudflare": "pnpm exec wrangler deploy --config wrangler.toml",
"prepare": "husky"
},
"keywords": [
Expand Down Expand Up @@ -42,9 +44,9 @@
"fastify": "^5.12.1"
},
"devDependencies": {
"@cloudflare/workers-types": "^5.20261004.1",
"@eslint/js": "^10.0.1",
"@types/node": "^24.19.1",
"@cloudflare/workers-types": "^5.20261004.1",
"@vitest/coverage-v8": "^4.1.9",
"ajv": "^8.20.0",
"eslint": "^10.6.0",
Expand All @@ -56,8 +58,8 @@
"typescript": "^6.0.3",
"typescript-eslint": "^8.62.0",
"vitest": "^4.1.9",
"yaml": "^2.9.1",
"wrangler": "^4.147.0"
"wrangler": "^4.147.0",
"yaml": "^2.9.1"
},
"engines": {
"node": ">=24 <25"
Expand Down
29 changes: 29 additions & 0 deletions scripts/check-worker-startup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Bonyan-API – Quran & Azkar API
* Copyright (c) 2026 BonyanOSS
* MIT License
*/

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.
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,
},
});

await worker.stop();
console.log('Worker startup passed in workerd (container execution checked separately).');
11 changes: 0 additions & 11 deletions src/cloudflare-node.d.ts

This file was deleted.

78 changes: 73 additions & 5 deletions src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,78 @@
* MIT License
*/

import { httpServerHandler } from 'cloudflare:node';
import { buildApp } from './app.js';
import { DurableObject } from 'cloudflare:workers';

const app = await buildApp({ logger: false });
await app.ready();
const CONTAINER_PORT = 3000;
const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
const STARTUP_TIMEOUT_MS = 30_000;

export default httpServerHandler(app.server);
export class BonyanApiContainer extends DurableObject {
private startPromise?: Promise<void>;
private ready = false;

private async ensureContainerRunning(): Promise<void> {
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<void> {
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;
}

export default {
async fetch(request: Request, env: Env) {
const id = env.BONYAN_API.idFromName('default');
return env.BONYAN_API.get(id).fetch(request);
},
};
88 changes: 88 additions & 0 deletions tests/worker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* Bonyan-API – Quran & Azkar API
* Copyright (c) 2026 BonyanOSS
* MIT License
*/

import { afterEach, describe, expect, it, vi } from 'vitest';

vi.mock('cloudflare:workers', () => ({
DurableObject: class {
constructor(protected ctx: unknown) {}
},
}));

import { BonyanApiContainer } from '../src/worker.js';

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 };
}

afterEach(() => vi.useRealTimers());

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<Response>((resolve) => {
resolveReady = resolve;
}),
);

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');

resolveReady(new Response('ready'));
await Promise.all(requests);
expect(port.fetch).toHaveBeenCalledWith(first);
expect(port.fetch).toHaveBeenCalledWith(second);
});

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('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('times out without forwarding and allows a later request to retry', 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);
});
});
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"noImplicitAny": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"types": ["node", "@cloudflare/workers-types"],
"outDir": "dist",
"rootDir": "src",
"resolveJsonModule": true,
Expand Down
17 changes: 15 additions & 2 deletions wrangler.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
name = "bonyan-api"
main = "dist/worker.js"
main = "src/worker.ts"
compatibility_date = "2026-10-04"
compatibility_flags = ["nodejs_compat", "enable_nodejs_http_server_modules"]
compatibility_flags = ["nodejs_compat"]

[[containers]]
class_name = "BonyanApiContainer"
image = "./Dockerfile"
max_instances = 3

[[durable_objects.bindings]]
name = "BONYAN_API"
class_name = "BonyanApiContainer"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["BonyanApiContainer"]
Loading