From 4110bbf5eada7a5f4247e0e2cbc59b8a8d927b2a Mon Sep 17 00:00:00 2001 From: Filipe Boleto Date: Mon, 10 Aug 2026 14:17:33 +0100 Subject: [PATCH] feature/md-2326-allow-multi-domains-per-worker --- README.md | 36 +++++++- config/domain-map.example.json | 12 +++ package.json | 3 + src/domainMap.ts | 148 +++++++++++++++++++++++++++++++++ src/htmlRewriter.ts | 14 ++++ src/index.ts | 52 +----------- src/proxyConfig.ts | 75 +++++++++++++++++ test/domainMap.spec.ts | 97 +++++++++++++++++++++ test/proxyConfig.spec.ts | 52 ++++++++++++ worker-configuration.d.ts | 5 ++ 10 files changed, 443 insertions(+), 51 deletions(-) create mode 100644 config/domain-map.example.json create mode 100644 src/domainMap.ts create mode 100644 src/htmlRewriter.ts create mode 100644 src/proxyConfig.ts create mode 100644 test/domainMap.spec.ts diff --git a/README.md b/README.md index 9b10b5c..1f12e9f 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Click deploy to Cloudflare to get started or fork this repo to customize it for ## Required Variables -This worker requires the following environment variables to be set in your Cloudflare configuration: +**By default, the worker runs in single-domain mode.** Set these environment variables in your Cloudflare configuration: - `MONETIZATION_OS_SECRET_KEY`: Your MonetizationOS secret key. [Get your secret key](https://docs.monetizationos.com/docs/guides/environments/managing-environments#api-keys). - `ORIGIN_URL`: The origin URL for your proxied website. @@ -34,6 +34,40 @@ This worker requires the following environment variables to be set in your Cloud Bindings should be set in your `wrangler.jsonc`, or a `.dev.vars.local` file when [working locally](https://developers.cloudflare.com/workers/development-testing/). +## Optional: multi-domain mode + +Use this when **one Worker** must proxy several public hostnames, each with its own origin, surface slug, and secret key. Set `DOMAIN_MAP` and omit (or ignore) `ORIGIN_URL`, `SURFACE_SLUG`, and `MONETIZATION_OS_SECRET_KEY` — those come from the map instead. Cookie names and other shared settings still come from Worker vars. + +In `wrangler.jsonc` `vars` (see also [`config/domain-map.example.json`](config/domain-map.example.json)): + +```jsonc +"DOMAIN_MAP": { + "site-a.example.com": { + "originUrl": "https://origin-a.example", + "surfaceSlug": "web", + "mosSecretKeyEnvVar": "MONETIZATION_OS_SECRET_KEY_SITE_A" + }, + "site-b.example.com": { + "originUrl": "https://origin-b.example", + "surfaceSlug": "other-web", + "mosSecretKeyEnvVar": "MONETIZATION_OS_SECRET_KEY_SITE_B" + } +} +``` + +Each map key is the public hostname clients send in the `Host` header (port is ignored, casing is normalized). Each value provides that domain's origin, surface slug, and the **name of a Worker secret/var** that holds its MOS secret key. Secret values are never stored in `DOMAIN_MAP`. + +Field names can use either camelCase (`originUrl`, `surfaceSlug`, `mosSecretKeyEnvVar`) or env-var-style aliases (`ORIGIN_URL`, `SURFACE_SLUG`, `MONETIZATION_OS_SECRET_KEY_ENV`). Trailing slashes on `originUrl` are stripped. In the Cloudflare dashboard, `DOMAIN_MAP` may also be a JSON string. + +Set one secret (or var) per site. The variable **names** are committed in `DOMAIN_MAP`; the **values** are configured only as Worker secrets: + +```bash +npx wrangler secret put MONETIZATION_OS_SECRET_KEY_SITE_A +npx wrangler secret put MONETIZATION_OS_SECRET_KEY_SITE_B +``` + +Attach every public hostname to the same Worker (Custom Domains or routes). Unknown hosts return `404` with `No proxy configuration for this host`. + ## Optional: paths that skip surface decisions `SURFACE_DECISIONS_IGNORE_PATHS` is a comma-separated list of regular expressions. Matching pathnames still proxy to the origin and rewrite origin links, but skip MonetizationOS surface decisions and component transforms. diff --git a/config/domain-map.example.json b/config/domain-map.example.json new file mode 100644 index 0000000..e3a2361 --- /dev/null +++ b/config/domain-map.example.json @@ -0,0 +1,12 @@ +{ + "site-a.example.com": { + "originUrl": "https://origin-a.example", + "surfaceSlug": "web", + "mosSecretKeyEnvVar": "MONETIZATION_OS_SECRET_KEY_SITE_A" + }, + "site-b.example.com": { + "originUrl": "https://origin-b.example", + "surfaceSlug": "other-web", + "mosSecretKeyEnvVar": "MONETIZATION_OS_SECRET_KEY_SITE_B" + } +} diff --git a/package.json b/package.json index 571904c..1c9e213 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,9 @@ }, "ORIGIN_REQUEST_HEADERS": { "description": "Optional JSON object mapping header names to values added to every origin request (e.g. {\"X-Api-Key\":\"secret\"}). Use {} when unset." + }, + "DOMAIN_MAP": { + "description": "Optional multi-domain map (JSON object). Each key is a public hostname; each value has originUrl, surfaceSlug, and mosSecretKeyEnvVar naming a Worker secret for that domain. When set, ORIGIN_URL, SURFACE_SLUG, and MONETIZATION_OS_SECRET_KEY are ignored." } } }, diff --git a/src/domainMap.ts b/src/domainMap.ts new file mode 100644 index 0000000..fcf520e --- /dev/null +++ b/src/domainMap.ts @@ -0,0 +1,148 @@ +/** Per-domain origin, surface slug, and MOS secret key entry in a domain map. */ +export interface DomainMapEntry { + originUrl: string + surfaceSlug: string + /** Worker env/secret binding name that stores this domain's MOS secret key. */ + mosSecretKeyEnvVar: string + /** Resolved at startup from `env[mosSecretKeyEnvVar]`. */ + mosSecretKey: string +} + +/** Hostname -> domain config. */ +export type DomainMap = Record + +type RawDomainMapEntry = { + originUrl?: string + surfaceSlug?: string + mosSecretKeyEnvVar?: string + ORIGIN_URL?: string + SURFACE_SLUG?: string + MONETIZATION_OS_SECRET_KEY_ENV?: string +} + +function normalizeEntry(host: string, raw: RawDomainMapEntry): Omit { + const originUrl = raw.originUrl ?? raw.ORIGIN_URL + const surfaceSlug = raw.surfaceSlug ?? raw.SURFACE_SLUG + const mosSecretKeyEnvVar = raw.mosSecretKeyEnvVar ?? raw.MONETIZATION_OS_SECRET_KEY_ENV + + if (!originUrl || originUrl === '') { + throw new Error(`Domain map entry for "${host}" is missing originUrl (or ORIGIN_URL)`) + } + if (!surfaceSlug || surfaceSlug === '') { + throw new Error(`Domain map entry for "${host}" is missing surfaceSlug (or SURFACE_SLUG)`) + } + if (!mosSecretKeyEnvVar || mosSecretKeyEnvVar === '') { + throw new Error(`Domain map entry for "${host}" is missing mosSecretKeyEnvVar (or MONETIZATION_OS_SECRET_KEY_ENV)`) + } + + let parsedOrigin: URL + try { + parsedOrigin = new URL(originUrl) + } catch { + throw new Error(`Domain map entry for "${host}" has invalid originUrl: ${originUrl}`) + } + + return { + originUrl: parsedOrigin.href.replace(/\/$/, '') || parsedOrigin.href, + surfaceSlug, + mosSecretKeyEnvVar, + } +} + +/** Strip port and lowercase for consistent Host header lookups. */ +export function normalizeHostname(host: string): string { + const lower = host.toLowerCase() + + // Bracketed IPv6 literal, optionally with a port (e.g. "[::1]:8787"). + if (lower.startsWith('[')) { + const end = lower.indexOf(']') + return end === -1 ? lower : lower.slice(1, end) + } + + // Strip :port only when there's exactly one ':' (e.g. example.com:443, 127.0.0.1:8787). + const match = lower.match(/^(.*):(\d+)$/) + if (match?.[1] !== undefined && match[1].indexOf(':') === -1) return match[1] + + // IPv6 literals from URL.hostname contain ':' but no port. + return lower +} + +/** Resolve a domain map entry from a request URL's hostname. */ +export function lookupDomainEntry(domainMap: DomainMap, requestUrl: string | URL): DomainMapEntry | undefined { + const hostname = normalizeHostname(new URL(requestUrl).hostname) + return domainMap[hostname] +} + +function readEnvString(env: object, name: string): string | undefined { + const value = (env as Record)[name] + return typeof value === 'string' && value !== '' ? value : undefined +} + +/** + * Resolve each domain's MOS secret key from the Worker env/secret named in the map. + * Fails fast when a referenced binding is missing or empty. + */ +export function resolveDomainMapSecrets(domainMap: Record>, env: object): DomainMap { + const resolved: DomainMap = {} + + for (const [host, entry] of Object.entries(domainMap)) { + const mosSecretKey = readEnvString(env, entry.mosSecretKeyEnvVar) + if (mosSecretKey === undefined) { + throw new Error(`Missing required environment variable "${entry.mosSecretKeyEnvVar}" for domain "${host}"`) + } + + resolved[host] = { + ...entry, + mosSecretKey, + } + } + + return resolved +} + +/** + * Parse and validate a domain map from a Worker binding (JSON object or JSON string). + * Keys are hostnames; values provide origin, surface slug, and an env/secret name for the MOS secret key. + */ +export function loadDomainMap(raw: unknown, env: object): DomainMap { + let parsed: unknown = raw + + if (typeof raw === 'string') { + try { + parsed = JSON.parse(raw) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to parse DOMAIN_MAP JSON: ${message}`) + } + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('DOMAIN_MAP must be a JSON object') + } + + const unresolved: Record> = {} + for (const [host, value] of Object.entries(parsed as Record)) { + const normalizedHost = normalizeHostname(host) + if (!normalizedHost) { + throw new Error('DOMAIN_MAP contains an empty hostname key') + } + if (unresolved[normalizedHost]) { + throw new Error(`DOMAIN_MAP contains duplicate hostname "${normalizedHost}" after normalization`) + } + unresolved[normalizedHost] = normalizeEntry(normalizedHost, value ?? {}) + } + + if (Object.keys(unresolved).length === 0) { + throw new Error('DOMAIN_MAP must contain at least one domain') + } + + return resolveDomainMapSecrets(unresolved, env) +} + +/** True when DOMAIN_MAP is set to a non-empty value (object with keys, or non-empty string). */ +export function hasDomainMapBinding(raw: unknown): boolean { + if (raw === undefined || raw === null) return false + if (typeof raw === 'string') return raw.trim() !== '' + if (typeof raw === 'object' && !Array.isArray(raw)) return Object.keys(raw).length > 0 + return true +} diff --git a/src/htmlRewriter.ts b/src/htmlRewriter.ts new file mode 100644 index 0000000..3a9227d --- /dev/null +++ b/src/htmlRewriter.ts @@ -0,0 +1,14 @@ +import type { ElementHandlers, HtmlRewriterSession } from '@monetizationos/proxy' + +export class CloudflareHtmlRewriterSession implements HtmlRewriterSession { + private readonly rewriter = new HTMLRewriter() + + on(selector: string, handlers: ElementHandlers): HtmlRewriterSession { + this.rewriter.on(selector, handlers as HTMLRewriterElementContentHandlers) + return this + } + + transform(response: Response): Response { + return this.rewriter.transform(response) + } +} diff --git a/src/index.ts b/src/index.ts index c8ecab3..5f4374a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,58 +1,10 @@ import { env } from 'cloudflare:workers' -import { type ElementHandlers, type HtmlRewriterSession, MOSProxyBuilder } from '@monetizationos/proxy' +import { createProxy } from './proxyConfig' -const proxy = new MOSProxyBuilder() - .withConfig({ - originUrl: env.ORIGIN_URL || 'https://example.local', - surfaceSlug: env.SURFACE_SLUG ?? '', - mosHost: env.MONETIZATION_OS_HOST || 'https://api.monetizationos.com', - mosSecretKey: env.MONETIZATION_OS_SECRET_KEY ?? '', - mosEndpointsPrefix: env.MONETIZATION_OS_ENDPOINTS_PREFIX || '/mos-endpoints/', - anonymousSessionCookieName: env.ANONYMOUS_SESSION_COOKIE_NAME, - authenticatedUserJwtCookieName: env.AUTHENTICATED_USER_JWT_COOKIE_NAME, - injectScriptUrl: env.INJECT_SCRIPT_URL || undefined, - surfaceDecisionsIgnorePaths: env.SURFACE_DECISIONS_IGNORE_PATHS, - surfaceDecisionsCookies: env.SURFACE_DECISIONS_COOKIES, - originRequestHeaders: env.ORIGIN_REQUEST_HEADERS ?? {}, - }) - .withOriginFetcher(fetch) - .withApiFetcher(fetch) - .withHtmlRewriter({ - capabilities: { - onEndTag: true, - nthChild: true, - }, - create() { - return new CloudflareHtmlRewriterSession() - }, - }) - .withClientMetadata({ - build(request) { - return { - cloudflare: { - cf: request.cf, - }, - } - }, - }) - .withClientIP((request) => request.headers.get('CF-Connecting-IP') ?? undefined) - .build() +const proxy = createProxy(env) export default { async fetch(request): Promise { return proxy.handle(request) }, } satisfies ExportedHandler - -class CloudflareHtmlRewriterSession implements HtmlRewriterSession { - private readonly rewriter = new HTMLRewriter() - - on(selector: string, handlers: ElementHandlers): HtmlRewriterSession { - this.rewriter.on(selector, handlers as HTMLRewriterElementContentHandlers) - return this - } - - transform(response: Response): Response { - return this.rewriter.transform(response) - } -} diff --git a/src/proxyConfig.ts b/src/proxyConfig.ts new file mode 100644 index 0000000..6fb4637 --- /dev/null +++ b/src/proxyConfig.ts @@ -0,0 +1,75 @@ +import { type ConfigFactory, hostPathMatcher, type MOSConfigInput, type MOSProxy, MOSProxyBuilder } from '@monetizationos/proxy' +import { hasDomainMapBinding, loadDomainMap } from './domainMap' +import { CloudflareHtmlRewriterSession } from './htmlRewriter' + +/** Build shared MOS config fields that apply in both single- and multi-domain modes. */ +function buildSharedConfig(workerEnv: Env): Partial { + return { + mosHost: workerEnv.MONETIZATION_OS_HOST || 'https://api.monetizationos.com', + mosEndpointsPrefix: workerEnv.MONETIZATION_OS_ENDPOINTS_PREFIX || '/mos-endpoints/', + anonymousSessionCookieName: workerEnv.ANONYMOUS_SESSION_COOKIE_NAME, + authenticatedUserJwtCookieName: workerEnv.AUTHENTICATED_USER_JWT_COOKIE_NAME, + injectScriptUrl: workerEnv.INJECT_SCRIPT_URL || undefined, + surfaceDecisionsIgnorePaths: workerEnv.SURFACE_DECISIONS_IGNORE_PATHS, + surfaceDecisionsCookies: workerEnv.SURFACE_DECISIONS_COOKIES, + originRequestHeaders: workerEnv.ORIGIN_REQUEST_HEADERS ?? {}, + } +} + +/** + * Resolve static config or a host-based factory. + * Multi-domain mode is enabled when `DOMAIN_MAP` is set; otherwise single-domain env vars are used. + */ +export function resolveConfigSource(workerEnv: Env): MOSConfigInput | ConfigFactory { + const shared = buildSharedConfig(workerEnv) + + if (hasDomainMapBinding(workerEnv.DOMAIN_MAP)) { + const domainMap = loadDomainMap(workerEnv.DOMAIN_MAP, workerEnv) + const rules = Object.entries(domainMap).map(([host, entry]) => ({ + host, + config: { + originUrl: entry.originUrl, + surfaceSlug: entry.surfaceSlug, + mosSecretKey: entry.mosSecretKey, + }, + })) + + return hostPathMatcher(rules, shared) + } + + return { + ...shared, + originUrl: workerEnv.ORIGIN_URL || 'https://example.local', + surfaceSlug: workerEnv.SURFACE_SLUG ?? '', + mosSecretKey: workerEnv.MONETIZATION_OS_SECRET_KEY ?? '', + } +} + +/** Build the MOS proxy for the given Worker env (single- or multi-domain). */ +export function createProxy(workerEnv: Env): MOSProxy { + return new MOSProxyBuilder() + .withConfig(resolveConfigSource(workerEnv)) + .withUnresolvedConfigHandler(() => new Response('No proxy configuration for this host', { status: 404 })) + .withOriginFetcher(fetch) + .withApiFetcher(fetch) + .withHtmlRewriter({ + capabilities: { + onEndTag: true, + nthChild: true, + }, + create() { + return new CloudflareHtmlRewriterSession() + }, + }) + .withClientMetadata({ + build(request) { + return { + cloudflare: { + cf: request.cf, + }, + } + }, + }) + .withClientIP((request) => request.headers.get('CF-Connecting-IP') ?? undefined) + .build() +} diff --git a/test/domainMap.spec.ts b/test/domainMap.spec.ts new file mode 100644 index 0000000..fc8f46d --- /dev/null +++ b/test/domainMap.spec.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import { hasDomainMapBinding, loadDomainMap, lookupDomainEntry, normalizeHostname, resolveDomainMapSecrets } from '../src/domainMap' + +describe('domainMap', () => { + it('normalizes hostnames by stripping port and lowercasing', () => { + expect(normalizeHostname('Example.COM:443')).toBe('example.com') + }) + + it('loads and resolves secrets from Worker env vars named in the map', () => { + expect( + loadDomainMap( + { + 'Proxy.Example:8787': { + ORIGIN_URL: 'https://origin.example/', + SURFACE_SLUG: 'web', + MONETIZATION_OS_SECRET_KEY_ENV: 'MOS_SECRET_PROXY_EXAMPLE', + }, + }, + { MOS_SECRET_PROXY_EXAMPLE: 'sk_test_example' }, + ), + ).toStrictEqual({ + 'proxy.example': { + originUrl: 'https://origin.example', + surfaceSlug: 'web', + mosSecretKeyEnvVar: 'MOS_SECRET_PROXY_EXAMPLE', + mosSecretKey: 'sk_test_example', + }, + }) + }) + + it('parses DOMAIN_MAP from a JSON string', () => { + expect( + loadDomainMap( + JSON.stringify({ + 'proxy.example': { + originUrl: 'https://origin.example', + surfaceSlug: 'web', + mosSecretKeyEnvVar: 'MOS_SECRET_PROXY_EXAMPLE', + }, + }), + { MOS_SECRET_PROXY_EXAMPLE: 'sk_test_example' }, + ), + ).toMatchObject({ + 'proxy.example': { + mosSecretKey: 'sk_test_example', + }, + }) + }) + + it('fails when a referenced secret env var is missing', () => { + expect(() => + resolveDomainMapSecrets( + { + 'proxy.example': { + originUrl: 'https://origin.example', + surfaceSlug: 'web', + mosSecretKeyEnvVar: 'MOS_SECRET_MISSING', + }, + }, + {}, + ), + ).toThrow(/MOS_SECRET_MISSING/) + }) + + it('rejects invalid domain maps', () => { + expect(() => loadDomainMap([], {})).toThrow(/must be a JSON object/) + expect(() => loadDomainMap('[]', {})).toThrow(/must be a JSON object/) + expect(() => loadDomainMap({}, {})).toThrow(/at least one domain/) + }) + + it('looks up entries by request hostname', () => { + const domainMap = { + 'proxy.example': { + originUrl: 'https://origin.example', + surfaceSlug: 'web', + mosSecretKeyEnvVar: 'MOS_SECRET_PROXY_EXAMPLE', + mosSecretKey: 'sk_test_example', + }, + } + + expect(lookupDomainEntry(domainMap, 'https://Proxy.Example:8787/page')).toStrictEqual({ + originUrl: 'https://origin.example', + surfaceSlug: 'web', + mosSecretKeyEnvVar: 'MOS_SECRET_PROXY_EXAMPLE', + mosSecretKey: 'sk_test_example', + }) + expect(lookupDomainEntry(domainMap, 'https://unknown.example/')).toBeUndefined() + }) + + it('detects when DOMAIN_MAP is set', () => { + expect(hasDomainMapBinding(undefined)).toBe(false) + expect(hasDomainMapBinding('')).toBe(false) + expect(hasDomainMapBinding({})).toBe(false) + expect(hasDomainMapBinding({ 'a.example': {} })).toBe(true) + expect(hasDomainMapBinding('{"a.example":{}}')).toBe(true) + }) +}) diff --git a/test/proxyConfig.spec.ts b/test/proxyConfig.spec.ts index 74f72b6..51aebfd 100644 --- a/test/proxyConfig.spec.ts +++ b/test/proxyConfig.spec.ts @@ -127,4 +127,56 @@ describe('proxy config', () => { expect(res.status).toBe(200) expect(await res.json()).toEqual({ success: true }) }) + + it('routes by Host from DOMAIN_MAP in multi-domain mode', async () => { + fetchMock + .get('https://origin-a.example') + .intercept({ path: '/page', method: 'GET' }) + .reply(200, { site: 'a' }, { headers: { 'Content-Type': 'application/json' } }) + fetchMock + .get('https://origin-b.example') + .intercept({ path: '/page', method: 'GET' }) + .reply(200, { site: 'b' }, { headers: { 'Content-Type': 'application/json' } }) + + const multiDomainEnv = { + DOMAIN_MAP: { + 'proxy-a.example': { + originUrl: 'https://origin-a.example', + surfaceSlug: 'surface-a', + mosSecretKeyEnvVar: 'MOS_SECRET_A', + }, + 'proxy-b.example': { + originUrl: 'https://origin-b.example', + surfaceSlug: 'surface-b', + mosSecretKeyEnvVar: 'MOS_SECRET_B', + }, + }, + MOS_SECRET_A: 'sk_test_a', + MOS_SECRET_B: 'sk_test_b', + } as Partial + + const resA = await fetchWithFreshWorker(new Request('https://proxy-a.example/page'), multiDomainEnv) + expect(resA.status).toBe(200) + expect(await resA.json()).toEqual({ site: 'a' }) + + const resB = await fetchWithFreshWorker(new Request('https://proxy-b.example/page'), multiDomainEnv) + expect(resB.status).toBe(200) + expect(await resB.json()).toEqual({ site: 'b' }) + }) + + it('returns 404 for unknown hosts in multi-domain mode', async () => { + const res = await fetchWithFreshWorker(new Request('https://unknown.example/page'), { + DOMAIN_MAP: { + 'proxy.example': { + originUrl: 'https://origin.example', + surfaceSlug: 'web', + mosSecretKeyEnvVar: 'MOS_SECRET_PROXY', + }, + }, + MOS_SECRET_PROXY: 'sk_test_example', + } as Partial) + + expect(res.status).toBe(404) + expect(await res.text()).toBe('No proxy configuration for this host') + }) }) diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index f95bc95..681c9bd 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -17,6 +17,11 @@ declare namespace Cloudflare { SURFACE_DECISIONS_IGNORE_PATHS: string; SURFACE_DECISIONS_COOKIES: string; ORIGIN_REQUEST_HEADERS: Record; + /** + * Optional multi-domain map: hostname -> { originUrl, surfaceSlug, mosSecretKeyEnvVar }. + * May be a JSON object (wrangler vars) or a JSON string (dashboard). + */ + DOMAIN_MAP?: Record | string; } } interface Env extends Cloudflare.Env {}