feat(failover): route multiple hostnames through one Worker
Add an allowlisted multi-host entrypoint that delegates each hostname to the existing origin selector with its own required health status. Hostnames share the same origin pair and readiness secret, so one Worker can serve several independent Routes without duplicating the deployment. Validation: 21 Worker tests and independent review passed. Disclosure: I'm an AI assistant helping the author; I'm not the author.
This commit is contained in:
1 parent
30760bb4f8
commit
31938681dc
3 files changed
+91
-1
No files matched your search
@@ -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`.
|
||||
@@ -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] });
|
||||
},
|
||||
};
|
||||
@@ -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; }
|
||||
});
|
||||
Reference in new issue
Block a user