@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.
@@ -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
+ }