Skip to content
Draft
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
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions config/domain-map.example.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
},
Expand Down
148 changes: 148 additions & 0 deletions src/domainMap.ts
Original file line number Diff line number Diff line change
@@ -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<string, DomainMapEntry>

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<DomainMapEntry, 'mosSecretKey'> {
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)
}
Comment on lines +57 to +60

// 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<string, unknown>)[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<string, Omit<DomainMapEntry, 'mosSecretKey'>>, 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<string, Omit<DomainMapEntry, 'mosSecretKey'>> = {}
for (const [host, value] of Object.entries(parsed as Record<string, RawDomainMapEntry>)) {
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
}
14 changes: 14 additions & 0 deletions src/htmlRewriter.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
52 changes: 2 additions & 50 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
return proxy.handle(request)
},
} satisfies ExportedHandler<Env>

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)
}
}
75 changes: 75 additions & 0 deletions src/proxyConfig.ts
Original file line number Diff line number Diff line change
@@ -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<MOSConfigInput> {
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,
Comment on lines +31 to +33
},
}))

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()
}
Loading
Loading