diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a963354..39bf2b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/DOCKERFILE b/Dockerfile similarity index 93% rename from DOCKERFILE rename to Dockerfile index dbb5d0f..99b8e2c 100644 --- a/DOCKERFILE +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 0e8b2c1..79f4c44 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ 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 ``` @@ -27,14 +27,18 @@ The default address is `http://localhost:3000`. Configuration names are listed i ### 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 diff --git a/package.json b/package.json index ad6c9c8..4515a2e 100644 --- a/package.json +++ b/package.json @@ -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": [ @@ -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", @@ -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" diff --git a/scripts/check-worker-startup.ts b/scripts/check-worker-startup.ts new file mode 100644 index 0000000..735072f --- /dev/null +++ b/scripts/check-worker-startup.ts @@ -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).'); diff --git a/src/cloudflare-node.d.ts b/src/cloudflare-node.d.ts deleted file mode 100644 index 32bd0d6..0000000 --- a/src/cloudflare-node.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Bonyan-API – Quran & Azkar API - * Copyright (c) 2026 BonyanOSS - * MIT License - */ - -declare module 'cloudflare:node' { - import type { Server } from 'node:http'; - - export function httpServerHandler(server: Server): unknown; -} diff --git a/src/worker.ts b/src/worker.ts index 8879e21..9c1ac07 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -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; + 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; +} + +export default { + async fetch(request: Request, env: Env) { + const id = env.BONYAN_API.idFromName('default'); + return env.BONYAN_API.get(id).fetch(request); + }, +}; diff --git a/tests/worker.test.ts b/tests/worker.test.ts new file mode 100644 index 0000000..5a9b0cf --- /dev/null +++ b/tests/worker.test.ts @@ -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((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); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 4abc74b..c5af7cd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "noImplicitAny": true, "noUncheckedIndexedAccess": true, "skipLibCheck": true, + "types": ["node", "@cloudflare/workers-types"], "outDir": "dist", "rootDir": "src", "resolveJsonModule": true, diff --git a/wrangler.toml b/wrangler.toml index 457c87c..2f99ed2 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -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"]