Skip to content

Commit b175810

Browse files
ryan-williamsclaude
andcommitted
add Cloudflare Workers as first-class runtime (default)
New `runtime` config field: `"cloudflare"` (default) or `"lambda"`. Deploy dispatches to the appropriate backend based on config. Cloudflare Workers support: - `deploy-cf.ts`: deploy/destroy/list via CF REST API (no wrangler dep) - `cloudflare.ts`: Worker entry point using shared `handler.ts` logic - Config injected into bundle at deploy time via placeholder replacement - `cloudflare.*` config fields for account ID, worker name, routes Refactored deploy layer: - `deploy.ts` → `deploy-lambda.ts` (Lambda-specific) - New `deploy.ts` dispatcher routes to CF or Lambda - `tags.ts` lists proxies across both runtimes - `cli.ts` shows runtime in output, `ls --runtime` filter Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 89f57e7 commit b175810

File tree

11 files changed

+905
-366
lines changed

11 files changed

+905
-366
lines changed

README.md

Lines changed: 60 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
# cors-prxy
22

3-
Minimal, security-focused Lambda CORS proxy — deploy per-project allowlisted trampolines via CLI or GitHub Actions.
3+
Minimal, security-focused CORS proxy — deploy per-project allowlisted trampolines to Cloudflare Workers or AWS Lambda via CLI or GitHub Actions.
44

55
## Why
66

7-
Frontend apps need CORS proxies to fetch OG metadata, RSS feeds, and other cross-origin resources. Public proxies are unreliable and a security risk. Self-hosting is easy but repetitive — every project reinvents the same Lambda + allowlist pattern.
7+
Frontend apps need CORS proxies to fetch OG metadata, RSS feeds, and other cross-origin resources. Public proxies are unreliable and a security risk. Self-hosting is easy but repetitive — every project reinvents the same proxy + allowlist pattern.
88

9-
`cors-prxy` gives each project its own Lambda proxy with an explicit domain allowlist, deployed via CLI or GitHub Actions.
9+
`cors-prxy` gives each project its own proxy with an explicit domain allowlist, deployed via CLI or GitHub Actions.
1010

1111
## Install
1212

@@ -32,18 +32,35 @@ Create `.cors-prxy.json` in your project root:
3232
}
3333
```
3434

35+
This deploys to **Cloudflare Workers** by default. For AWS Lambda, add `"runtime": "lambda"`.
36+
37+
### Config fields
38+
3539
| Field | Type | Default | Description |
3640
|-------|------|---------|-------------|
37-
| `name` | `string` | (required) | Lambda function name / resource identifier |
38-
| `region` | `string` | `us-east-1` | AWS region |
41+
| `name` | `string` | (required) | Proxy name / resource identifier |
42+
| `runtime` | `"cloudflare" \| "lambda"` | `"cloudflare"` | Deployment target |
3943
| `allow` | `(string \| AllowRule)[]` | (required) | Allowlisted domains/paths |
44+
| `region` | `string` | `us-east-1` | AWS region (Lambda only) |
45+
| `methods` | `string[]` | `["GET", "HEAD"]` | Allowed HTTP methods (`["*"]` for any) |
46+
| `forwardHeaders` | `string[]` | `[]` | Request headers to forward upstream |
47+
| `urlMode` | `"query" \| "path"` | `"query"` | `?url=` vs `/<host>/<path>` routing |
4048
| `rateLimit.perIp` | `number` | `60` | Max requests per IP per window |
4149
| `rateLimit.window` | `string` | `"1m"` | Rate limit window (`"30s"`, `"1m"`, `"1h"`) |
4250
| `cors.origins` | `string[]` | `["*"]` | Allowed CORS origins (globs) |
4351
| `cors.maxAge` | `number` | `86400` | `Access-Control-Max-Age` in seconds |
4452
| `cache.ttl` | `number` | `300` | Response cache TTL in seconds |
4553
| `cache.maxSize` | `number` | `1000` | Max cached responses (LRU) |
46-
| `tags` | `Record<string, string>` | `{}` | Additional AWS resource tags |
54+
| `tags` | `Record<string, string>` | `{}` | Additional resource tags |
55+
56+
### Cloudflare config
57+
58+
| Field | Type | Default | Description |
59+
|-------|------|---------|-------------|
60+
| `cloudflare.accountId` | `string` | `$CLOUDFLARE_ACCOUNT_ID` | CF account ID |
61+
| `cloudflare.workerName` | `string` | `cors-prxy-{name}` | Worker script name |
62+
| `cloudflare.route` | `string` || Custom route pattern |
63+
| `cloudflare.compatibilityDate` | `string` | `"2024-01-01"` | CF compatibility date |
4764

4865
### Allow rules
4966

@@ -52,21 +69,38 @@ Create `.cors-prxy.json` in your project root:
5269

5370
All non-matching requests return `403`.
5471

72+
### Full proxy mode
73+
74+
By default, only GET/HEAD are proxied (read-only). For APIs that need POST/PUT/etc:
75+
76+
```json
77+
{
78+
"name": "my-api-proxy",
79+
"allow": ["api.example.com"],
80+
"methods": ["*"],
81+
"forwardHeaders": ["content-type", "authorization"],
82+
"urlMode": "path",
83+
"cache": { "ttl": 0, "maxSize": 0 }
84+
}
85+
```
86+
87+
Request bodies are forwarded automatically for non-GET/HEAD methods. Only configured headers are forwarded — no cookies or credentials leak by default.
88+
5589
## CLI
5690

5791
```sh
58-
cors-prxy deploy # create or update Lambda proxy
92+
cors-prxy deploy # deploy proxy (CF Workers or Lambda)
5993
cors-prxy deploy -c custom.json
6094

61-
cors-prxy ls # list all cors-prxy Lambdas in account
62-
cors-prxy ls -r us-east-1,eu-west-1
95+
cors-prxy ls # list all proxies (both runtimes)
96+
cors-prxy ls --runtime cloudflare
6397
cors-prxy ls --json
6498

6599
cors-prxy status # show current project's proxy info
66-
cors-prxy logs # tail CloudWatch logs
100+
cors-prxy logs # tail logs (CloudWatch for Lambda, wrangler for CF)
67101
cors-prxy logs -f # follow
68102

69-
cors-prxy destroy # remove Lambda + IAM role
103+
cors-prxy destroy # remove proxy
70104
cors-prxy destroy -y # skip confirmation
71105

72106
cors-prxy dev # local proxy on :3849
@@ -75,9 +109,14 @@ cors-prxy dev -p 4000 # custom port
75109

76110
### What `deploy` creates
77111

78-
- **Lambda function** (Node.js 22.x, ESM) with a [Function URL] (no API Gateway)
79-
- **IAM execution role** with CloudWatch Logs permissions
80-
- All resources tagged for discovery (no local state file needed)
112+
**Cloudflare Workers** (default):
113+
- Worker script on `workers.dev` subdomain
114+
- Requires `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` (or `cloudflare.accountId` in config)
115+
116+
**AWS Lambda**:
117+
- Lambda function (Node.js 22.x, ESM) with a [Function URL] (no API Gateway)
118+
- IAM execution role with CloudWatch Logs permissions
119+
- All resources tagged for discovery
81120

82121
`deploy` is idempotent: creates if missing, updates if changed.
83122

@@ -112,61 +151,37 @@ Supports both OIDC and static credentials (`aws-access-key-id` / `aws-secret-acc
112151
## How it works
113152

114153
```
115-
Browser → Lambda Function URL → Upstream
116-
(allowlist check) (fetch + cache)
154+
Browser → CF Worker / Lambda → Upstream
155+
(allowlist check) (fetch + cache)
117156
(CORS headers)
118157
(rate limit)
119158
```
120159
121-
Request: `GET /?url=<encoded-url>`
160+
Request: `GET /?url=<encoded-url>` (query mode) or `GET /<host>/<path>` (path mode)
122161
123162
1. Parse + validate URL against allowlist
124163
2. If denied: `403 { error: "Domain not allowed", allowed: [...] }`
125-
3. Check in-memory LRU cache
164+
3. Check in-memory LRU cache (GET/HEAD only)
126165
4. Fetch upstream (10s timeout, 5MB size limit)
127166
5. Return response with CORS headers, cache result
128167
129-
### Response headers
130-
131-
```
132-
Access-Control-Allow-Origin: <matched origin>
133-
Access-Control-Allow-Methods: GET, HEAD, OPTIONS
134-
Access-Control-Max-Age: <from config>
135-
Cache-Control: public, max-age=<ttl>
136-
X-Cors-Prxy: <function-name>
137-
```
138-
139168
### Security
140169
141170
- **Domain allowlist**: only configured domains are proxied, glob matching via [picomatch]
142171
- **Path allowlist**: optional per-domain path restrictions
143172
- **Rate limiting**: per-IP, in-memory (resets on cold start)
144-
- **Read-only**: only `GET` and `HEAD` are proxied
173+
- **Methods**: configurable — read-only by default, opt-in for mutations
174+
- **Header forwarding**: explicit allowlist only — no cookies/credentials forwarded by default
145175
- **Size limit**: responses >5MB rejected
146176
- **Timeout**: 10s upstream fetch timeout
147-
- **No credential forwarding**: request headers/cookies are not forwarded
148177
149178
[picomatch]: https://github.com/micromatch/picomatch
150179
151-
## Resource tagging
152-
153-
All AWS resources are tagged for discovery:
154-
155-
| Tag | Example |
156-
|-----|---------|
157-
| `cors-prxy` | `true` |
158-
| `cors-prxy:name` | `my-app` |
159-
| `cors-prxy:version` | `0.1.0` |
160-
| `cors-prxy:allow` | `github.com,*.github.com` |
161-
| `cors-prxy:repo` | `user/my-app` |
162-
163-
`cors-prxy ls` discovers proxies via these tags — no local state file needed.
164-
165180
## Development
166181
167182
```sh
168183
pnpm install
169-
pnpm build # tsc + esbuild Lambda bundle
184+
pnpm build # tsc + esbuild bundles (Lambda + CF Worker)
170185
pnpm test # vitest
171186
```
172187

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
"dist"
1111
],
1212
"scripts": {
13-
"build": "tsc && node scripts/bundle-lambda.js",
14-
"bundle": "node scripts/bundle-lambda.js",
13+
"build": "tsc && node scripts/bundle-lambda.js && node scripts/bundle-cloudflare.js",
14+
"bundle": "node scripts/bundle-lambda.js && node scripts/bundle-cloudflare.js",
1515
"clean": "rm -rf node_modules/.vite dist",
1616
"test": "vitest run",
1717
"prepublishOnly": "pnpm build"

scripts/bundle-cloudflare.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { build } from "esbuild"
2+
import { resolve, dirname } from "node:path"
3+
import { fileURLToPath } from "node:url"
4+
5+
const __dirname = dirname(fileURLToPath(import.meta.url))
6+
7+
await build({
8+
entryPoints: [resolve(__dirname, "../src/cloudflare.ts")],
9+
bundle: true,
10+
platform: "browser",
11+
target: "es2022",
12+
format: "esm",
13+
outfile: resolve(__dirname, "../dist/cloudflare-bundle/index.mjs"),
14+
minify: true,
15+
external: ["node:*"],
16+
conditions: ["worker", "browser"],
17+
})
18+
19+
console.log("Cloudflare Worker bundle written to dist/cloudflare-bundle/index.mjs")
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
# Cloudflare Workers runtime
2+
3+
## Summary
4+
5+
Add first-class Cloudflare Workers support as a deployment target alongside Lambda. CFW becomes the default runtime — better cold-start performance, global edge deployment, simpler auth model.
6+
7+
## Config changes
8+
9+
### `runtime` field
10+
11+
```typescript
12+
interface CorsProxyConfig {
13+
// ... existing fields ...
14+
15+
/** Deployment runtime. Default: "cloudflare" */
16+
runtime?: "cloudflare" | "lambda"
17+
18+
/** Cloudflare-specific config (when runtime is "cloudflare") */
19+
cloudflare?: {
20+
/** CF account ID. Can also be set via CLOUDFLARE_ACCOUNT_ID env var. */
21+
accountId?: string
22+
/** Worker name. Defaults to config `name`. */
23+
workerName?: string
24+
/** Custom route pattern, e.g. "proxy.example.com/*". Optional — uses workers.dev by default. */
25+
route?: string
26+
/** Compatibility date. Default: "2024-01-01" */
27+
compatibilityDate?: string
28+
}
29+
}
30+
```
31+
32+
When `runtime` is `"lambda"` (or unset in legacy configs that already have `region`), behavior is unchanged. When `runtime` is `"cloudflare"` (or unset and no `region`), deploy as a CF Worker.
33+
34+
### Default runtime detection
35+
36+
- If `runtime` is set explicitly: use it
37+
- If `region` is set but `runtime` is not: assume `"lambda"` (backwards compat)
38+
- Otherwise: default to `"cloudflare"`
39+
40+
## CLI behavior
41+
42+
No new subcommands. Existing commands dispatch on runtime:
43+
44+
| Command | Lambda | Cloudflare |
45+
|---------|--------|------------|
46+
| `deploy` | Create/update Lambda + Function URL + IAM role | Create/update CF Worker via API |
47+
| `destroy` | Delete Lambda + IAM role | Delete CF Worker |
48+
| `status` | Query Lambda tags | Query CF Worker metadata |
49+
| `logs` | CloudWatch Logs | `wrangler tail` or CF API |
50+
| `ls` | Scan tagged Lambdas | Scan CF Workers by naming convention / tags |
51+
| `dev` | Local HTTP server (unchanged) | Local HTTP server (unchanged) |
52+
53+
`ls` scans both backends by default. `--runtime lambda|cloudflare` flag to filter.
54+
55+
## CF Workers deploy implementation
56+
57+
### Auth
58+
59+
CF API token via `CLOUDFLARE_API_TOKEN` env var (same as wrangler). Account ID from config or `CLOUDFLARE_ACCOUNT_ID` env var.
60+
61+
No CF SDK needed — the CF API is simple enough to call via `fetch`:
62+
- `PUT /client/v4/accounts/{account_id}/workers/scripts/{script_name}` — upload worker
63+
- `GET /client/v4/accounts/{account_id}/workers/scripts/{script_name}` — check existence
64+
- `DELETE /client/v4/accounts/{account_id}/workers/scripts/{script_name}` — delete
65+
- `GET /client/v4/accounts/{account_id}/workers/scripts` — list workers
66+
- `PUT /client/v4/accounts/{account_id}/workers/scripts/{script_name}/subdomain` — enable workers.dev subdomain
67+
68+
### Worker bundle
69+
70+
The worker code is the same `handler.ts` logic, wrapped in a CF Workers entry point:
71+
72+
```typescript
73+
import { handleProxyRequest } from "./handler.js"
74+
75+
const config = CONFIG_JSON // replaced at bundle time by esbuild `define`
76+
77+
export default {
78+
async fetch(request: Request): Promise<Response> {
79+
const url = new URL(request.url)
80+
const result = await handleProxyRequest({
81+
method: request.method,
82+
url: url.pathname + url.search,
83+
origin: request.headers.get("origin") ?? undefined,
84+
ip: request.headers.get("cf-connecting-ip") ?? undefined,
85+
body: request.body,
86+
headers: Object.fromEntries(request.headers.entries()),
87+
}, config)
88+
return new Response(result.body as BodyInit, {
89+
status: result.status,
90+
headers: result.headers,
91+
})
92+
},
93+
}
94+
```
95+
96+
Bundle with esbuild, inject config as a `define` constant (similar to Lambda's env var approach but baked into the bundle — CF Workers don't have a great env var story for large configs).
97+
98+
### Upload format
99+
100+
CF Workers API expects a multipart form upload with:
101+
- `metadata` part: JSON with `{ "main_module": "index.mjs", "compatibility_date": "..." }`
102+
- `index.mjs` part: the bundled worker code
103+
104+
### Endpoint
105+
106+
After deploy, the worker is available at `https://{worker-name}.{account-subdomain}.workers.dev`. The deploy command prints this URL.
107+
108+
### Worker metadata / discovery
109+
110+
For `ls` to discover CF Workers deployed by cors-prxy, embed metadata in the worker script name or use the CF Workers API to list scripts and check for a naming convention: `cors-prxy-{name}`.
111+
112+
## New files
113+
114+
```
115+
src/
116+
cloudflare.ts # CF Workers entry point (bundled + uploaded)
117+
deploy-cf.ts # CF API interactions (deploy, destroy, list)
118+
scripts/
119+
bundle-cloudflare.js # esbuild bundler for CF Worker
120+
```
121+
122+
`deploy.ts` becomes `deploy-lambda.ts` (rename for clarity), and `deploy.ts` becomes a dispatcher:
123+
124+
```typescript
125+
export async function deploy(config: CorsProxyConfig) {
126+
const runtime = resolveRuntime(config)
127+
if (runtime === "cloudflare") {
128+
const { deployCf } = await import("./deploy-cf.js")
129+
return deployCf(config)
130+
}
131+
const { deployLambda } = await import("./deploy-lambda.js")
132+
return deployLambda(config)
133+
}
134+
```
135+
136+
Same pattern for `destroy`.
137+
138+
## `ls` across runtimes
139+
140+
```sh
141+
cors-prxy ls # list all (both runtimes)
142+
cors-prxy ls --runtime lambda # Lambda only
143+
cors-prxy ls --runtime cloudflare # CF only
144+
```
145+
146+
Output table adds a RUNTIME column:
147+
148+
```
149+
NAME RUNTIME ENDPOINT ALLOW
150+
aws-static-sso cloudflare https://cors-prxy-aws-static-sso.x.workers.dev oidc.*.amazonaws.com
151+
og-crd lambda https://abc123.lambda-url.us-east-1.on.aws github.com
152+
```
153+
154+
## Migration path
155+
156+
Existing `.cors-prxy.json` configs without `runtime` that have `region` set continue to deploy as Lambda (backwards compat). New configs default to Cloudflare.
157+
158+
To migrate an existing Lambda proxy to CF:
159+
1. Add `"runtime": "cloudflare"` + `cloudflare.accountId` to config
160+
2. `cors-prxy deploy`
161+
3. Update consumers to new endpoint
162+
4. `cors-prxy destroy --runtime lambda` to clean up old Lambda
163+
164+
## Environment variables
165+
166+
| Var | Description |
167+
|-----|-------------|
168+
| `CLOUDFLARE_API_TOKEN` | CF API token (same as wrangler) |
169+
| `CLOUDFLARE_ACCOUNT_ID` | CF account ID (fallback if not in config) |

0 commit comments

Comments
 (0)