@rkat/web 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/events.d.ts +20 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +33 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/mob.d.ts +67 -0
- package/dist/mob.d.ts.map +1 -0
- package/dist/mob.js +89 -0
- package/dist/mob.js.map +1 -0
- package/dist/runtime.d.ts +107 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +171 -0
- package/dist/runtime.js.map +1 -0
- package/dist/session.d.ts +29 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +56 -0
- package/dist/session.js.map +1 -0
- package/dist/types.d.ts +278 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +11 -0
- package/dist/types.js.map +1 -0
- package/package.json +36 -0
- package/proxy/cli.mjs +52 -0
- package/proxy/index.mjs +253 -0
package/proxy/index.mjs
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @rkat/web provider proxy — auth-injecting reverse proxy for LLM providers.
|
|
3
|
+
*
|
|
4
|
+
* Sits between browser WASM clients and LLM provider APIs. Injects real API
|
|
5
|
+
* keys server-side so secrets never reach the browser. Streams request and
|
|
6
|
+
* response bodies without buffering.
|
|
7
|
+
*
|
|
8
|
+
* Usage (standalone):
|
|
9
|
+
* ANTHROPIC_API_KEY=sk-... npx @rkat/web proxy --port 3100
|
|
10
|
+
*
|
|
11
|
+
* Usage (compose into existing server):
|
|
12
|
+
* import { createProxyHandler, corsHeaders } from '@rkat/web/proxy';
|
|
13
|
+
* app.all('/anthropic/*', createProxyHandler('anthropic'));
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createServer } from 'node:http';
|
|
17
|
+
|
|
18
|
+
// ─── Provider configuration ─────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const PROVIDERS = {
|
|
21
|
+
anthropic: {
|
|
22
|
+
target: 'https://api.anthropic.com',
|
|
23
|
+
envKey: 'ANTHROPIC_API_KEY',
|
|
24
|
+
injectAuth(headers, apiKey) {
|
|
25
|
+
headers.set('x-api-key', apiKey);
|
|
26
|
+
// Remove browser-only header — the proxy is server-side
|
|
27
|
+
headers.delete('anthropic-dangerous-direct-browser-access');
|
|
28
|
+
},
|
|
29
|
+
rewriteUrl(url, _apiKey) { return url; },
|
|
30
|
+
},
|
|
31
|
+
openai: {
|
|
32
|
+
target: 'https://api.openai.com',
|
|
33
|
+
envKey: 'OPENAI_API_KEY',
|
|
34
|
+
injectAuth(headers, apiKey) {
|
|
35
|
+
headers.set('authorization', `Bearer ${apiKey}`);
|
|
36
|
+
},
|
|
37
|
+
rewriteUrl(url, _apiKey) { return url; },
|
|
38
|
+
},
|
|
39
|
+
gemini: {
|
|
40
|
+
target: 'https://generativelanguage.googleapis.com',
|
|
41
|
+
envKey: 'GEMINI_API_KEY',
|
|
42
|
+
injectAuth(headers, apiKey) {
|
|
43
|
+
headers.set('x-goog-api-key', apiKey);
|
|
44
|
+
// Remove any client-side dummy key from query string
|
|
45
|
+
},
|
|
46
|
+
rewriteUrl(url, _apiKey) {
|
|
47
|
+
// Strip any ?key= query param (legacy client-side auth)
|
|
48
|
+
const u = new URL(url);
|
|
49
|
+
u.searchParams.delete('key');
|
|
50
|
+
return u.toString();
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// ─── CORS headers ───────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Default CORS headers. Override `allowOrigin` for production.
|
|
59
|
+
* @param {string} [allowOrigin='*']
|
|
60
|
+
*/
|
|
61
|
+
export function corsHeaders(allowOrigin = '*') {
|
|
62
|
+
return {
|
|
63
|
+
'access-control-allow-origin': allowOrigin,
|
|
64
|
+
'access-control-allow-methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
|
65
|
+
'access-control-allow-headers': 'content-type, authorization, x-api-key, anthropic-version, anthropic-beta, anthropic-dangerous-direct-browser-access',
|
|
66
|
+
'access-control-expose-headers': '*',
|
|
67
|
+
'access-control-max-age': '86400',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Proxy handler ──────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Create a proxy handler for a specific provider.
|
|
75
|
+
*
|
|
76
|
+
* Returns a function `(req: Request, pathPrefix: string) => Promise<Response>`
|
|
77
|
+
* compatible with any framework that uses the Web Fetch API Request/Response.
|
|
78
|
+
*
|
|
79
|
+
* @param {keyof PROVIDERS} provider - Provider name ('anthropic', 'openai', 'gemini')
|
|
80
|
+
* @param {{ apiKey?: string, allowOrigin?: string }} [opts]
|
|
81
|
+
*/
|
|
82
|
+
export function createProxyHandler(provider, opts = {}) {
|
|
83
|
+
const config = PROVIDERS[provider];
|
|
84
|
+
if (!config) throw new Error(`Unknown provider: ${provider}. Use: ${Object.keys(PROVIDERS).join(', ')}`);
|
|
85
|
+
|
|
86
|
+
const apiKey = opts.apiKey || process.env[config.envKey];
|
|
87
|
+
if (!apiKey) throw new Error(`Missing API key for ${provider}: set ${config.envKey} env var or pass opts.apiKey`);
|
|
88
|
+
|
|
89
|
+
const allowOrigin = opts.allowOrigin || '*';
|
|
90
|
+
const cors = corsHeaders(allowOrigin);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {Request} req - Incoming request
|
|
94
|
+
* @param {string} strippedPath - URL path with provider prefix removed (e.g. '/v1/messages')
|
|
95
|
+
*/
|
|
96
|
+
return async function proxyHandler(req, strippedPath) {
|
|
97
|
+
// Build target URL
|
|
98
|
+
let targetUrl = `${config.target}${strippedPath}`;
|
|
99
|
+
// Preserve query string
|
|
100
|
+
const qIdx = req.url.indexOf('?');
|
|
101
|
+
if (qIdx !== -1) {
|
|
102
|
+
const qs = req.url.slice(qIdx);
|
|
103
|
+
targetUrl += qs;
|
|
104
|
+
}
|
|
105
|
+
targetUrl = config.rewriteUrl(targetUrl, apiKey);
|
|
106
|
+
|
|
107
|
+
// Copy headers, inject auth
|
|
108
|
+
const headers = new Headers();
|
|
109
|
+
for (const [key, value] of req.headers) {
|
|
110
|
+
// Skip hop-by-hop and host headers
|
|
111
|
+
if (['host', 'connection', 'keep-alive', 'transfer-encoding'].includes(key.toLowerCase())) continue;
|
|
112
|
+
headers.set(key, value);
|
|
113
|
+
}
|
|
114
|
+
config.injectAuth(headers, apiKey);
|
|
115
|
+
|
|
116
|
+
// Forward request
|
|
117
|
+
const fetchOpts = {
|
|
118
|
+
method: req.method,
|
|
119
|
+
headers,
|
|
120
|
+
};
|
|
121
|
+
// Only include body for methods that have one
|
|
122
|
+
if (req.method !== 'GET' && req.method !== 'HEAD' && req.body) {
|
|
123
|
+
fetchOpts.body = req.body;
|
|
124
|
+
fetchOpts.duplex = 'half';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const upstream = await fetch(targetUrl, fetchOpts);
|
|
128
|
+
|
|
129
|
+
// Build response headers: upstream headers + CORS
|
|
130
|
+
const respHeaders = new Headers();
|
|
131
|
+
for (const [key, value] of upstream.headers) {
|
|
132
|
+
respHeaders.set(key, value);
|
|
133
|
+
}
|
|
134
|
+
for (const [key, value] of Object.entries(cors)) {
|
|
135
|
+
respHeaders.set(key, value);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return new Response(upstream.body, {
|
|
139
|
+
status: upstream.status,
|
|
140
|
+
statusText: upstream.statusText,
|
|
141
|
+
headers: respHeaders,
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── Node.js HTTP server adapter ────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Start a standalone proxy server.
|
|
150
|
+
*
|
|
151
|
+
* @param {{ port?: number, allowOrigin?: string }} [opts]
|
|
152
|
+
*/
|
|
153
|
+
export function startProxy(opts = {}) {
|
|
154
|
+
const port = opts.port || parseInt(process.env.PORT || '3100', 10);
|
|
155
|
+
const allowOrigin = opts.allowOrigin || '*';
|
|
156
|
+
const cors = corsHeaders(allowOrigin);
|
|
157
|
+
|
|
158
|
+
// Build handlers for each provider that has a key configured
|
|
159
|
+
const handlers = {};
|
|
160
|
+
for (const [name, config] of Object.entries(PROVIDERS)) {
|
|
161
|
+
const apiKey = process.env[config.envKey];
|
|
162
|
+
if (apiKey) {
|
|
163
|
+
handlers[name] = createProxyHandler(name, { apiKey, allowOrigin });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (Object.keys(handlers).length === 0) {
|
|
168
|
+
console.error('No API keys found. Set at least one of: ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY');
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
console.log(`Proxy providers: ${Object.keys(handlers).join(', ')}`);
|
|
173
|
+
|
|
174
|
+
const server = createServer(async (nodeReq, nodeRes) => {
|
|
175
|
+
try {
|
|
176
|
+
// CORS preflight
|
|
177
|
+
if (nodeReq.method === 'OPTIONS') {
|
|
178
|
+
nodeRes.writeHead(204, cors);
|
|
179
|
+
nodeRes.end();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Match provider from path: /anthropic/v1/messages → provider='anthropic', rest='/v1/messages'
|
|
184
|
+
const url = new URL(nodeReq.url, `http://localhost:${port}`);
|
|
185
|
+
const match = url.pathname.match(/^\/([^/]+)(\/.*)?$/);
|
|
186
|
+
if (!match || !handlers[match[1]]) {
|
|
187
|
+
nodeRes.writeHead(404, { 'content-type': 'application/json', ...cors });
|
|
188
|
+
nodeRes.end(JSON.stringify({
|
|
189
|
+
error: 'not_found',
|
|
190
|
+
message: `Unknown path. Use: /${Object.keys(handlers).join('/, /')}/`,
|
|
191
|
+
}));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const provider = match[1];
|
|
196
|
+
const strippedPath = match[2] || '/';
|
|
197
|
+
|
|
198
|
+
// Convert Node.js request to Web Request
|
|
199
|
+
const reqHeaders = new Headers();
|
|
200
|
+
for (let i = 0; i < nodeReq.rawHeaders.length; i += 2) {
|
|
201
|
+
reqHeaders.append(nodeReq.rawHeaders[i], nodeReq.rawHeaders[i + 1]);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const webReq = new Request(url.toString(), {
|
|
205
|
+
method: nodeReq.method,
|
|
206
|
+
headers: reqHeaders,
|
|
207
|
+
body: nodeReq.method !== 'GET' && nodeReq.method !== 'HEAD'
|
|
208
|
+
? nodeReq
|
|
209
|
+
: undefined,
|
|
210
|
+
duplex: 'half',
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Proxy
|
|
214
|
+
const resp = await handlers[provider](webReq, strippedPath);
|
|
215
|
+
|
|
216
|
+
// Write response back to Node.js
|
|
217
|
+
const respHeaders = {};
|
|
218
|
+
for (const [key, value] of resp.headers) {
|
|
219
|
+
respHeaders[key] = value;
|
|
220
|
+
}
|
|
221
|
+
nodeRes.writeHead(resp.status, respHeaders);
|
|
222
|
+
|
|
223
|
+
if (resp.body) {
|
|
224
|
+
// Stream the response body
|
|
225
|
+
const reader = resp.body.getReader();
|
|
226
|
+
try {
|
|
227
|
+
while (true) {
|
|
228
|
+
const { done, value } = await reader.read();
|
|
229
|
+
if (done) break;
|
|
230
|
+
nodeRes.write(value);
|
|
231
|
+
}
|
|
232
|
+
} finally {
|
|
233
|
+
reader.releaseLock();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
nodeRes.end();
|
|
237
|
+
} catch (err) {
|
|
238
|
+
console.error('Proxy error:', err);
|
|
239
|
+
if (!nodeRes.headersSent) {
|
|
240
|
+
nodeRes.writeHead(502, { 'content-type': 'application/json', ...cors });
|
|
241
|
+
nodeRes.end(JSON.stringify({ error: 'proxy_error', message: String(err.message || err) }));
|
|
242
|
+
} else {
|
|
243
|
+
nodeRes.end();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
server.listen(port, () => {
|
|
249
|
+
console.log(`@rkat/web proxy listening on http://localhost:${port}`);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
return server;
|
|
253
|
+
}
|