Skip to content

Commit 256f2c5

Browse files
committed
Add experimental cors proxy for WebContainers
1 parent 748527a commit 256f2c5

File tree

3 files changed

+144
-1
lines changed

3 files changed

+144
-1
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "openworkers-api",
3-
"version": "1.3.1",
3+
"version": "1.3.2",
44
"license": "MIT",
55
"module": "src/index.ts",
66
"type": "module",

src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import kv from './routes/kv';
1313
import storage from './routes/storage';
1414
import ai from './routes/ai';
1515
import apiKeys from './routes/api-keys';
16+
import corsProxy from './routes/cors-proxy';
1617
import pkg from '../package.json';
1718
import { sql } from './services/db/client';
1819

@@ -72,6 +73,10 @@ v1.route('/api-keys', apiKeys);
7273
v1.route('/', users);
7374

7475
api.route('/v1', v1);
76+
77+
// CORS proxy for Anthropic API (no auth - tokens are in the proxied requests)
78+
api.route('/cors-proxy', corsProxy);
79+
7580
app.route('/api', api);
7681

7782
import { nodeEnv, port } from './config';

src/routes/cors-proxy.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { Hono } from 'hono';
2+
3+
/**
4+
* Simple CORS proxy for Anthropic API
5+
*
6+
* This is a transparent passthrough proxy that:
7+
* - Does NOT store or cache any tokens
8+
* - Does NOT modify requests (except removing origin headers)
9+
* - Simply forwards requests to api.anthropic.com
10+
*
11+
* This is needed because the Anthropic API doesn't have CORS headers
12+
* for browser-based requests (WebContainers).
13+
*/
14+
15+
const ALLOWED_HOSTS = ['api.anthropic.com', 'console.anthropic.com'];
16+
17+
// Allowed origins for CORS (dashboard domains + WebContainers)
18+
const ALLOWED_ORIGINS = [
19+
'https://dash.openworkers.dev',
20+
'https://dash.openworkers.com',
21+
'https://dash.dev.localhost',
22+
// WebContainers uses dynamic subdomains
23+
/^https:\/\/.*\.webcontainer-api\.io$/,
24+
/^https:\/\/.*\.staticblitz\.com$/,
25+
/^https:\/\/.*\.w-credentialless-staticblitz\.com$/
26+
];
27+
28+
function isOriginAllowed(origin: string | null): boolean {
29+
if (!origin) return false;
30+
31+
return ALLOWED_ORIGINS.some((allowed) => {
32+
if (typeof allowed === 'string') {
33+
return origin === allowed;
34+
}
35+
36+
return allowed.test(origin);
37+
});
38+
}
39+
40+
function getCorsOrigin(origin: string | null): string {
41+
if (origin && isOriginAllowed(origin)) {
42+
return origin;
43+
}
44+
45+
return 'https://dash.openworkers.dev'; // default fallback
46+
}
47+
48+
const corsProxy = new Hono();
49+
50+
// Proxy all requests to Anthropic
51+
corsProxy.all('/*', async (c) => {
52+
const targetPath = c.req.path.replace('/cors-proxy', '');
53+
const url = new URL(c.req.url);
54+
const targetHost = url.searchParams.get('host') || 'api.anthropic.com';
55+
56+
console.log(`[cors-proxy] ${c.req.method} ${targetPath} -> ${targetHost}`);
57+
58+
// Validate target host
59+
if (!ALLOWED_HOSTS.includes(targetHost)) {
60+
return c.json({ error: 'Host not allowed' }, 403);
61+
}
62+
63+
const targetUrl = `https://${targetHost}${targetPath}`;
64+
65+
// Get original headers, remove browser-specific ones
66+
const headers = new Headers();
67+
68+
for (const [key, value] of c.req.raw.headers.entries()) {
69+
const lowerKey = key.toLowerCase();
70+
71+
// Skip headers that would break the proxy
72+
if (
73+
lowerKey === 'host' ||
74+
lowerKey === 'origin' ||
75+
lowerKey === 'referer' ||
76+
lowerKey === 'connection' ||
77+
lowerKey === 'content-length'
78+
) {
79+
continue;
80+
}
81+
82+
headers.set(key, value);
83+
}
84+
85+
try {
86+
// Forward the request
87+
const response = await fetch(targetUrl, {
88+
method: c.req.method,
89+
headers,
90+
body: c.req.method !== 'GET' && c.req.method !== 'HEAD' ? await c.req.raw.clone().arrayBuffer() : undefined
91+
});
92+
93+
// Build response with CORS headers
94+
const origin = c.req.header('Origin') || null;
95+
const responseHeaders = new Headers(response.headers);
96+
responseHeaders.set('Access-Control-Allow-Origin', getCorsOrigin(origin));
97+
responseHeaders.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
98+
responseHeaders.set('Access-Control-Allow-Headers', '*');
99+
responseHeaders.set('Access-Control-Expose-Headers', '*');
100+
// Private Network Access (PNA)
101+
responseHeaders.set('Access-Control-Allow-Private-Network', 'true');
102+
103+
// Handle streaming responses
104+
if (response.headers.get('content-type')?.includes('text/event-stream')) {
105+
responseHeaders.set('Content-Type', 'text/event-stream');
106+
responseHeaders.set('Cache-Control', 'no-cache');
107+
responseHeaders.set('Connection', 'keep-alive');
108+
}
109+
110+
return new Response(response.body, {
111+
status: response.status,
112+
statusText: response.statusText,
113+
headers: responseHeaders
114+
});
115+
} catch (error) {
116+
console.error('[cors-proxy] Error:', error);
117+
return c.json({ error: 'Proxy request failed' }, 502);
118+
}
119+
});
120+
121+
// Handle CORS preflight (including Private Network Access)
122+
corsProxy.options('/*', (c) => {
123+
const origin = c.req.header('Origin') || null;
124+
125+
return new Response(null, {
126+
status: 204,
127+
headers: {
128+
'Access-Control-Allow-Origin': getCorsOrigin(origin),
129+
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
130+
'Access-Control-Allow-Headers': '*',
131+
'Access-Control-Max-Age': '86400',
132+
// Private Network Access (PNA) - allows requests from public to private networks
133+
'Access-Control-Allow-Private-Network': 'true'
134+
}
135+
});
136+
});
137+
138+
export default corsProxy;

0 commit comments

Comments
 (0)