diff --git a/assets/composition-failover/README.md b/assets/composition-failover/README.md index cce3e28..cd8e4c0 100644 --- a/assets/composition-failover/README.md +++ b/assets/composition-failover/README.md @@ -1,6 +1,6 @@ # Readiness-gated origin routing on Workers Free -This local ES-module Worker asset selects between two origins for one public HTTPS hostname. It implements the ingress part of the [composition replication RFC](../../docs/rfcs/composition-replication.md). It is not deployed and does not establish production high availability. Deploy one instance per public hostname, with independently verified origin routes and readiness. Activate only after the declared backend failure behavior and authenticated application readiness are verified. The Worker does not promote databases or activate a standby. +This local ES-module Worker asset selects between two origins for one public HTTPS hostname. It implements the ingress part of the [composition replication RFC](../../docs/rfcs/composition-replication.md). It is not deployed and does not establish production high availability. Use the multi-host entrypoint below when hostnames share origins and a readiness secret. Verify origin routes and readiness independently for every hostname. Activate only after the declared backend failure behavior and authenticated application readiness are verified. The Worker does not promote databases or activate a standby. The module has no runtime dependencies, paid bindings, KV, R2, storage or scheduled jobs. This directory does not enable billing, enroll an account, upload a Worker or change DNS/routes. @@ -86,3 +86,17 @@ node --test assets/composition-failover/worker.test.mjs ``` Tested with Node v24.11.1 using its built-in test runner and Web APIs. Tests call the exported handler and replace only the external fetch boundary, with fake timers for probe deadlines. They do not call Cloudflare or use credentials. No npm install, new dependency or change to the default Python test workflow is required. Native Cloudflare deployment is deliberately outside this local test's proof. + +## Several hostnames in one Worker + +Use `multi-host.mjs` as the main module and upload `worker.mjs` alongside it when +several hostnames share the same origins and readiness secret. Attach one Route +per hostname to that single Worker. Set `APEX_PUBLIC_HOSTS` to a JSON object of +allowed hostnames and exact health status strings, for example +`{"sso.example.com":"204","app.example.com":"200"}`. It replaces the +`APEX_PUBLIC_HOST` and `APEX_READY_STATUS` bindings; all other bindings and +deployment gates above still apply. Unknown hostnames are rejected before any +origin request. Each allowed hostname uses the existing single-host handler, +including its authentication, caching, streaming and no-replay behavior. + +Verify both entrypoints with `node --test assets/composition-failover/*.test.mjs`. diff --git a/assets/composition-failover/multi-host.mjs b/assets/composition-failover/multi-host.mjs new file mode 100644 index 0000000..0575eb7 --- /dev/null +++ b/assets/composition-failover/multi-host.mjs @@ -0,0 +1,23 @@ +import singleHost from './worker.mjs'; + +function unavailable(status) { + return new Response(status === 421 ? 'Misdirected request' : 'Service unavailable', { + status, headers: { 'Cache-Control': 'private, no-store' }, + }); +} + +export default { + async fetch(request, env) { + let hosts; + try { hosts = JSON.parse(env?.APEX_PUBLIC_HOSTS); } catch { return unavailable(503); } + if (!hosts || Array.isArray(hosts) || typeof hosts !== 'object') return unavailable(503); + const entries = Object.entries(hosts); + if (!entries.length || entries.length > 64 || entries.some(([name, status]) => + name !== name.toLowerCase() || name.length > 253 || !name.includes('.') + || !name.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)) + || !['200', '204'].includes(status))) return unavailable(503); + const host = new URL(request.url).hostname; + if (!Object.hasOwn(hosts, host)) return unavailable(421); + return singleHost.fetch(request, { ...env, APEX_PUBLIC_HOST: host, APEX_READY_STATUS: hosts[host] }); + }, +}; diff --git a/assets/composition-failover/multi-host.test.mjs b/assets/composition-failover/multi-host.test.mjs new file mode 100644 index 0000000..105fe5d --- /dev/null +++ b/assets/composition-failover/multi-host.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import worker from './multi-host.mjs'; + +const settings = { + APEX_ZONE: 'example.com', + APEX_PUBLIC_HOSTS: JSON.stringify({ 'sso.example.com': '204', 'app.example.com': '200' }), + APEX_ORIGIN_PRIMARY: 'one.example.com', + APEX_ORIGIN_SECONDARY: 'two.example.com', + APEX_READY_PATH: '/.well-known/apex-ready', + APEX_READY_TOKEN: 'synthetic-private-token', +}; + +test('each allowed hostname uses its own health status and preserves the application request', async () => { + const original = globalThis.fetch; + try { + for (const [host, health] of [['sso.example.com', 204], ['app.example.com', 200]]) { + const calls = []; + globalThis.fetch = async (request) => { + calls.push(request); + return calls.length === 1 ? new Response(null, { status: health }) : new Response('accepted'); + }; + const result = await worker.fetch(new Request(`https://${host}/callback`, { method: 'POST', body: 'one-use-body' }), settings); + assert.equal(result.status, 200); + assert.equal(calls.length, 2); + assert.equal(new URL(calls[0].url).hostname, host); + assert.equal(calls[1].method, 'POST'); + assert.equal(await calls[1].text(), 'one-use-body'); + } + } finally { globalThis.fetch = original; } +}); + +test('unknown hosts do not probe or dispatch', async () => { + const original = globalThis.fetch; + globalThis.fetch = async () => { throw new Error('unexpected dispatch'); }; + try { + const response = await worker.fetch(new Request('https://other.example.com/'), settings); + assert.equal(response.status, 421); + } finally { globalThis.fetch = original; } +}); + +test('missing and malformed host policies fail closed before dispatch', async () => { + const original = globalThis.fetch; + let calls = 0; + globalThis.fetch = async () => { calls++; return new Response('unexpected'); }; + try { + for (const value of [undefined, '{', '[]', '{}', '{"app.example.com":"302"}', '{"APP.example.com":"200"}']) { + const response = await worker.fetch(new Request('https://app.example.com/'), { ...settings, APEX_PUBLIC_HOSTS: value }); + assert.equal(response.status, 503); + } + assert.equal(calls, 0); + } finally { globalThis.fetch = original; } +});