From 4fc898aa24761758d3d80f67f9f1588435980a9f Mon Sep 17 00:00:00 2001 From: "John V. Pataki" Date: Sat, 6 Jun 2026 21:35:56 -0700 Subject: [PATCH 01/13] Harden proxy CORS/error handling and upstream timeouts --- server.cjs | 67 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 49 insertions(+), 18 deletions(-) diff --git a/server.cjs b/server.cjs index 82a7bd8..c21bc28 100644 --- a/server.cjs +++ b/server.cjs @@ -13,26 +13,35 @@ const GITHUB_TOKEN = process.env.GITHUB_TOKEN; const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY; const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY; const DEEPSEEK_URL = process.env.DEEPSEEK_URL ; +const UPSTREAM_TIMEOUT_MS = Number(process.env.UPSTREAM_TIMEOUT_MS || 30000); function logMissingEnvWarning(routeLabel, envName) { console.log(`${routeLabel} route disabled until ${envName} is set.`); } -function sendJson(res, statusCode, payload) { - res.writeHead(statusCode, { - "Content-Type": "application/json", +function buildCorsHeaders(contentType) { + return { + "Content-Type": contentType, "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET,POST,OPTIONS", "Access-Control-Allow-Headers": "Content-Type" - }); + }; +} + +function sendJson(res, statusCode, payload) { + res.writeHead(statusCode, buildCorsHeaders("application/json")); res.end(JSON.stringify(payload)); } +function sendText(res, statusCode, payload, contentType = "text/plain") { + res.writeHead(statusCode, buildCorsHeaders(contentType)); + res.end(payload); +} + function sendFile(res, filePath) { fs.readFile(filePath, (err, data) => { if (err) { - res.writeHead(404, { "Content-Type": "text/plain" }); - res.end("Not Found"); + sendText(res, 404, "Not Found"); return; } @@ -44,11 +53,23 @@ function sendFile(res, filePath) { ".json": "application/json" }; - res.writeHead(200, { "Content-Type": types[ext] || "application/octet-stream" }); + res.writeHead(200, buildCorsHeaders(types[ext] || "application/octet-stream")); res.end(data); }); } +async function parseUpstreamJson(response) { + try { + return await response.json(); + } catch { + return null; + } +} + +function isTimeoutError(error) { + return error && (error.name === "TimeoutError" || error.name === "AbortError"); +} + function readRequestBody(req) { return new Promise((resolve, reject) => { let body = ""; @@ -74,16 +95,17 @@ async function handleGeminiPrompt(req, res) { { method: "POST", headers: { "Content-Type": "application/json" }, - body: body + body: body, + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS) } ); - const data = await response.json(); + const data = await parseUpstreamJson(response); if (!response.ok) { const message = data && data.error && data.error.message ? data.error.message - : "Upstream API error"; + : `Upstream API error (HTTP ${response.status})`; sendJson(res, response.status, { error: message }); return; } @@ -94,7 +116,11 @@ async function handleGeminiPrompt(req, res) { sendJson(res, 200, { text }); } catch (error) { - sendJson(res, 500, { error: error.message }); + if (isTimeoutError(error)) { + sendJson(res, 504, { error: `Upstream request timed out after ${UPSTREAM_TIMEOUT_MS} ms.` }); + return; + } + sendJson(res, 502, { error: error.message || "Upstream request failed." }); } } @@ -119,16 +145,17 @@ function handleOpenAIModelsPrompt(token, url, tokenName) { "Content-Type": "application/json", "Authorization": `Bearer ${token}` }, - body: body + body: body, + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS) } ); - const data = await response.json(); + const data = await parseUpstreamJson(response); if (!response.ok) { const message = data && data.error && data.error.message ? data.error.message - : "Upstream API error"; + : `Upstream API error (HTTP ${response.status})`; sendJson(res, response.status, { error: message }); return; } @@ -139,7 +166,11 @@ function handleOpenAIModelsPrompt(token, url, tokenName) { sendJson(res, 200, { text }); } catch (error) { - sendJson(res, 500, { error: error.message }); + if (isTimeoutError(error)) { + sendJson(res, 504, { error: `Upstream request timed out after ${UPSTREAM_TIMEOUT_MS} ms.` }); + return; + } + sendJson(res, 502, { error: error.message || "Upstream request failed." }); } } } @@ -150,7 +181,8 @@ var handleDeepSeekPrompt = handleOpenAIModelsPrompt(DEEPSEEK_API_KEY, DEEPSEEK_U const server = http.createServer((req, res) => { if (req.method === "OPTIONS") { - sendJson(res, 204, {}); + res.writeHead(204, buildCorsHeaders("application/json")); + res.end(); return; } @@ -181,8 +213,7 @@ const server = http.createServer((req, res) => { sendFile(res, filePath); return; } - res.writeHead(405, { "Content-Type": "text/plain" }); - res.end("Method Not Allowed"); + sendText(res, 405, "Method Not Allowed"); }); server.listen(PORT, () => { From 765c3ce8bf702ff818b0beb3a022d1d7cf99c100 Mon Sep 17 00:00:00 2001 From: "John V. Pataki" Date: Thu, 11 Jun 2026 14:50:24 -0700 Subject: [PATCH 02/13] Add dual-runtime port: shared core, Node adapter, Cloudflare Worker adapter - Phase plan doc: DUAL_RUNTIME_PORT_PLAN.md - src/core/: runtime-agnostic config, http helpers, proxy logic, routes - src/node/server.js: Node adapter (local + DO App path) - src/worker/worker.js: Cloudflare Worker adapter - wrangler.toml: aiproxy-dev config with numerus.app route - server.cjs: shim delegating to Node adapter - package.json: worker:dev and worker:deploy scripts - README: dual-runtime status, Worker setup, test matrix All tests passing: - unit: 11/11 - live Node local: 4/4 - live CF workers.dev: 4/4 - live CF aiproxy-dev.numerus.app: 4/4 --- DUAL_RUNTIME_PORT_PLAN.md | 157 +++++++++++++++++++++++++ README.md | 49 ++++++++ package.json | 2 + server.cjs | 241 +------------------------------------- src/core/config.js | 25 ++++ src/core/http.js | 30 +++++ src/core/proxy.js | 154 ++++++++++++++++++++++++ src/core/routes.js | 20 ++++ src/node/server.js | 124 ++++++++++++++++++++ src/worker/worker.js | 42 +++++++ wrangler.toml | 24 ++++ 11 files changed, 630 insertions(+), 238 deletions(-) create mode 100644 DUAL_RUNTIME_PORT_PLAN.md create mode 100644 src/core/config.js create mode 100644 src/core/http.js create mode 100644 src/core/proxy.js create mode 100644 src/core/routes.js create mode 100644 src/node/server.js create mode 100644 src/worker/worker.js create mode 100644 wrangler.toml diff --git a/DUAL_RUNTIME_PORT_PLAN.md b/DUAL_RUNTIME_PORT_PLAN.md new file mode 100644 index 0000000..02eab04 --- /dev/null +++ b/DUAL_RUNTIME_PORT_PLAN.md @@ -0,0 +1,157 @@ +# Dual Runtime Port Plan (DO App + Cloudflare Worker + Local) + +## Goal + +Keep one repository that can run in three environments without changing client contracts: + +1. Local machine (Richard) +2. DigitalOcean App Platform (Node runtime) +3. Cloudflare Worker (edge runtime) + +The public API contract remains: + +- `POST /api/gemprompt` +- `POST /api/ghprompt` +- `POST /api/orprompt` +- `POST /api/dsprompt` + +Success and error response normalization remains unchanged: + +- success: `{ "text": "..." }` +- error: `{ "error": "..." }` + +## Non-Goals + +- No model/provider contract redesign. +- No frontend/client payload migration. +- No immediate removal of existing Node entrypoint until parity is verified. + +## Phase 0: Baseline And Safety (Done First) + +### Objectives + +- Work on an isolated branch. +- Capture implementation plan before code changes. + +### Deliverables + +- Feature branch for dual-runtime work. +- This plan document committed. + +### Exit Criteria + +- Plan is reviewed and accepted before refactor starts. + +## Phase 1: Extract Runtime-Agnostic Core + +### Objectives + +- Move proxy/provider logic into shared modules independent of Node `http` APIs. +- Keep behavior parity with current server implementation. + +### Deliverables + +- Shared core request handlers (Gemini, GitHub Models, OpenRouter, DeepSeek). +- Shared utilities for: + - CORS headers + - request body handling (where runtime-neutral) + - upstream fetch/timeout/error normalization + - JSON/text response helper shape + +### Exit Criteria + +- Node adapter can call shared core and return same endpoint behavior. +- Existing test suite still passes for Node runtime. + +## Phase 2: Add Runtime Adapters + +### Objectives + +- Keep Node adapter for local and DO App deployment. +- Add Cloudflare Worker adapter using `fetch` event style runtime. + +### Deliverables + +- Node runtime entrypoint that maps Node req/res to core. +- Worker runtime entrypoint that maps Request/Response to core. +- Shared route map to avoid drift between runtimes. + +### Exit Criteria + +- Both runtimes expose the same routes and response normalization. +- Worker code compiles and runs in local Worker dev mode. + +## Phase 3: Configuration And Deployment Setup + +### Objectives + +- Standardize env var contract across runtimes. +- Add Worker deployment configuration. + +### Deliverables + +- `wrangler.toml` with environment variable and secret binding guidance. +- npm scripts for worker dev/deploy flow. +- Updated docs showing local Node, DO App Node, and Worker setup. + +### Exit Criteria + +- Clear run/deploy commands documented for all targets. +- No required variable ambiguity. + +## Phase 4: Testing Matrix Expansion + +### Objectives + +- Preserve Node unit + live tests. +- Add worker smoke/live test path using base URL override. + +### Deliverables + +- Test instructions for: + - Node local + - Worker local dev endpoint +- Optional worker-specific smoke checks if needed. + +### Exit Criteria + +- Tests can be pointed at either runtime with the same contract assertions. + +## Phase 5: Rollout And Decision Gate + +### Objectives + +- Evaluate whether to adopt dual runtime long-term. +- Keep rollback path simple. + +### Deliverables + +- Comparison checklist: + - operational complexity + - reliability + - cost profile + - latency profile +- Recommendation on maintaining both targets or selecting one. + +### Exit Criteria + +- Team decision recorded before merging to main. + +## Risks And Mitigations + +1. Runtime differences (Node vs Worker) around streaming, timeouts, and filesystem +- Mitigation: keep provider logic in core and isolate runtime mechanics in adapters. + +2. Env management differences +- Mitigation: one canonical variable list with runtime-specific setup sections. + +3. Behavior regression during refactor +- Mitigation: preserve existing endpoint contract and run current tests at each phase. + +## Implementation Order For This Branch + +1. Phase 1 implementation starts now. +2. Phase 2 minimal Worker adapter next. +3. Validate behavior and tests. +4. Continue with Phase 3 docs/config updates. +5. Add Phase 4 test matrix adjustments. diff --git a/README.md b/README.md index 5e6e6e0..9bd2fdf 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,22 @@ This folder contains a lightweight Node.js reverse proxy server that forwards mo The server also serves static files from this folder for `GET` requests. +## Dual-Runtime Port Status + +This repo now includes a phased port toward dual runtime support: + +- Node runtime for local development and DigitalOcean App Platform +- Cloudflare Worker runtime adapter + +Implementation phases, scope, and checkpoints are documented in: + +- `DUAL_RUNTIME_PORT_PLAN.md` + +Current branch-level implementation status: + +- Phase 1 started: shared runtime-agnostic core modules added under `src/core/` +- Phase 2 started: runtime adapters added for Node (`src/node/server.js`) and Cloudflare Worker (`src/worker/worker.js`) + ## What It Does - Accepts `POST` requests from browser or test clients @@ -92,6 +108,33 @@ npm start The server will fail to reach upstream providers if the required variables are not already set — there is no interactive prompt in this path. +## Cloudflare Worker (Dev / Deploy) + +The Worker adapter uses the same API route contract as Node for `POST` endpoints. + +### Configuration + +- Worker config: `wrangler.toml` +- Set secrets in Cloudflare for tokens: + - `wrangler secret put GEMINI_API_KEY` + - `wrangler secret put GITHUB_TOKEN` + - `wrangler secret put OPENROUTER_API_KEY` + - `wrangler secret put DEEPSEEK_API_KEY` +- Set non-secret vars in `wrangler.toml` under `[vars]`: + - `GEMINI_URL`, `GH_URL`, `OPENROUTER_URL`, `DEEPSEEK_URL`, `UPSTREAM_TIMEOUT_MS` + +### Run Worker Locally + +```bash +npm run worker:dev +``` + +### Deploy Worker + +```bash +npm run worker:deploy +``` + ### Base URL ```text @@ -161,6 +204,12 @@ You can also target a deployed environment by overriding the test base URL: TEST_BASE_URL=https://${AIPROXY_DEPLOYED_URL} npm run test:live ``` +For Cloudflare Worker local dev, point tests to the wrangler dev URL: + +```bash +TEST_BASE_URL=http://127.0.0.1:8787 npm run test:live +``` + Requirements: - the local proxy server is already running (via `bash start.sh`, `npm run start:dev`, or `npm start` with env vars pre-set) diff --git a/package.json b/package.json index a8a8ee5..af2be42 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "scripts": { "start": "node server.cjs", "start:dev": "bash start.sh", + "worker:dev": "npx wrangler dev", + "worker:deploy": "npx wrangler deploy", "test": "node --test test/unit.test.js", "test:live": "node --test test/test.js", "test:all": "node --test test/unit.test.js test/test.js" diff --git a/server.cjs b/server.cjs index c21bc28..e6a2e9f 100644 --- a/server.cjs +++ b/server.cjs @@ -1,239 +1,4 @@ -const http = require("http"); -const fs = require("fs"); -const path = require("path"); - -const PORT = Number(process.env.PORT || 3000); -const GEMINI_MODEL = process.env.GEMINI_MODEL; -// const GEMINI_MODEL = "gemini-pro"; -const GH_URL = process.env.GH_URL; -const GEMINI_URL = process.env.GEMINI_URL; -const OPENROUTER_URL = process.env.OPENROUTER_URL; -const GEMINI_API_KEY = process.env.GEMINI_API_KEY; -const GITHUB_TOKEN = process.env.GITHUB_TOKEN; -const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY; -const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY; -const DEEPSEEK_URL = process.env.DEEPSEEK_URL ; -const UPSTREAM_TIMEOUT_MS = Number(process.env.UPSTREAM_TIMEOUT_MS || 30000); - -function logMissingEnvWarning(routeLabel, envName) { - console.log(`${routeLabel} route disabled until ${envName} is set.`); -} - -function buildCorsHeaders(contentType) { - return { - "Content-Type": contentType, - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET,POST,OPTIONS", - "Access-Control-Allow-Headers": "Content-Type" - }; -} - -function sendJson(res, statusCode, payload) { - res.writeHead(statusCode, buildCorsHeaders("application/json")); - res.end(JSON.stringify(payload)); -} - -function sendText(res, statusCode, payload, contentType = "text/plain") { - res.writeHead(statusCode, buildCorsHeaders(contentType)); - res.end(payload); -} - -function sendFile(res, filePath) { - fs.readFile(filePath, (err, data) => { - if (err) { - sendText(res, 404, "Not Found"); - return; - } - - const ext = path.extname(filePath).toLowerCase(); - const types = { - ".html": "text/html", - ".js": "text/javascript", - ".css": "text/css", - ".json": "application/json" - }; - - res.writeHead(200, buildCorsHeaders(types[ext] || "application/octet-stream")); - res.end(data); - }); -} - -async function parseUpstreamJson(response) { - try { - return await response.json(); - } catch { - return null; - } -} - -function isTimeoutError(error) { - return error && (error.name === "TimeoutError" || error.name === "AbortError"); -} - -function readRequestBody(req) { - return new Promise((resolve, reject) => { - let body = ""; - req.on("data", (chunk) => { - body += chunk; - }); - req.on("end", () => { - resolve(body); - }); - req.on("error", reject); - }); -} - -async function handleGeminiPrompt(req, res) { - if (!GEMINI_API_KEY) { - sendJson(res, 500, { error: "Missing GEMINI_API_KEY environment variable." }); - return; - } - - try { - const body = await readRequestBody(req); - const response = await fetch(GEMINI_URL, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: body, - signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS) - } - ); - - const data = await parseUpstreamJson(response); - - if (!response.ok) { - const message = data && data.error && data.error.message - ? data.error.message - : `Upstream API error (HTTP ${response.status})`; - sendJson(res, response.status, { error: message }); - return; - } - - const text = data.candidates && data.candidates[0] && data.candidates[0].content && data.candidates[0].content.parts && data.candidates[0].content.parts[0] - ? data.candidates[0].content.parts[0].text - : "No response text returned."; - - sendJson(res, 200, { text }); - } catch (error) { - if (isTimeoutError(error)) { - sendJson(res, 504, { error: `Upstream request timed out after ${UPSTREAM_TIMEOUT_MS} ms.` }); - return; - } - sendJson(res, 502, { error: error.message || "Upstream request failed." }); - } -} - -function handleOpenAIModelsPrompt(token, url, tokenName) { - return async function (req, res) { - if (!token) { - sendJson(res, 500, { error: `Missing ${tokenName} environment variable.` }); - return; - } - - if (!url) { - sendJson(res, 500, { error: "Missing upstream URL environment variable." }); - return; - } - - try { - const body = await readRequestBody(req); - const response = await fetch(url, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${token}` - }, - body: body, - signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS) - } - ); - - const data = await parseUpstreamJson(response); - - if (!response.ok) { - const message = data && data.error && data.error.message - ? data.error.message - : `Upstream API error (HTTP ${response.status})`; - sendJson(res, response.status, { error: message }); - return; - } - - const text = data.choices && data.choices[0] && data.choices[0].message - ? data.choices[0].message.content - : "No response text returned."; - - sendJson(res, 200, { text }); - } catch (error) { - if (isTimeoutError(error)) { - sendJson(res, 504, { error: `Upstream request timed out after ${UPSTREAM_TIMEOUT_MS} ms.` }); - return; - } - sendJson(res, 502, { error: error.message || "Upstream request failed." }); - } - } -} - -var handleGitHubModelsPrompt = handleOpenAIModelsPrompt(GITHUB_TOKEN, GH_URL, "GITHUB_TOKEN"); -var handleOpenRouterModelsPrompt = handleOpenAIModelsPrompt(OPENROUTER_API_KEY, OPENROUTER_URL, "OPENROUTER_API_KEY"); -var handleDeepSeekPrompt = handleOpenAIModelsPrompt(DEEPSEEK_API_KEY, DEEPSEEK_URL, "DEEPSEEK_API_KEY"); - -const server = http.createServer((req, res) => { - if (req.method === "OPTIONS") { - res.writeHead(204, buildCorsHeaders("application/json")); - res.end(); - return; - } - - const parsedUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`); - const pathname = parsedUrl.pathname; - - if (req.method === "POST") { - switch (pathname) { - case "/api/gemprompt": - handleGeminiPrompt(req, res); - return; - case "/api/ghprompt": - handleGitHubModelsPrompt(req, res); - return; - case "/api/orprompt": - handleOpenRouterModelsPrompt(req, res); - return; - case "/api/dsprompt": - handleDeepSeekPrompt(req, res); - return; - default: - break; - } - } - if (req.method === "GET") { - const safePath = pathname === "/" ? "/gemini-node.html" : pathname; - const filePath = path.join(__dirname, safePath); - sendFile(res, filePath); - return; - } - sendText(res, 405, "Method Not Allowed"); -}); - -server.listen(PORT, () => { - console.log(`Unified server running on http://localhost:${PORT}`); - if (!GEMINI_API_KEY) { - logMissingEnvWarning("Gemini", "GEMINI_API_KEY"); - } - if (!GEMINI_URL) { - logMissingEnvWarning("Gemini", "GEMINI_URL"); - } - if (!GITHUB_TOKEN) { - logMissingEnvWarning("GitHub Models", "GITHUB_TOKEN"); - } - if (!GH_URL) { - logMissingEnvWarning("GitHub Models", "GH_URL"); - } - if (!OPENROUTER_API_KEY) { - logMissingEnvWarning("OpenRouter", "OPENROUTER_API_KEY"); - } - if (!OPENROUTER_URL) { - logMissingEnvWarning("OpenRouter", "OPENROUTER_URL"); - } +import("./src/node/server.js").catch((error) => { + console.error("Failed to start Node adapter:", error); + process.exit(1); }); diff --git a/src/core/config.js b/src/core/config.js new file mode 100644 index 0000000..33ace20 --- /dev/null +++ b/src/core/config.js @@ -0,0 +1,25 @@ +function toNumber(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function resolveConfig(envSource = {}) { + return { + PORT: toNumber(envSource.PORT, 3000), + GEMINI_API_KEY: envSource.GEMINI_API_KEY, + GEMINI_MODEL: envSource.GEMINI_MODEL, + GEMINI_API_BASE: envSource.GEMINI_API_BASE || "https://generativelanguage.googleapis.com", + GEMINI_URL: envSource.GEMINI_URL, + GITHUB_TOKEN: envSource.GITHUB_TOKEN, + GH_URL: envSource.GH_URL, + OPENROUTER_API_KEY: envSource.OPENROUTER_API_KEY, + OPENROUTER_URL: envSource.OPENROUTER_URL, + DEEPSEEK_API_KEY: envSource.DEEPSEEK_API_KEY, + DEEPSEEK_URL: envSource.DEEPSEEK_URL, + UPSTREAM_TIMEOUT_MS: toNumber(envSource.UPSTREAM_TIMEOUT_MS, 30000) + }; +} + +export { + resolveConfig +}; diff --git a/src/core/http.js b/src/core/http.js new file mode 100644 index 0000000..10f295f --- /dev/null +++ b/src/core/http.js @@ -0,0 +1,30 @@ +function buildCorsHeaders(contentType) { + return { + "Content-Type": contentType, + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET,POST,OPTIONS", + "Access-Control-Allow-Headers": "Content-Type" + }; +} + +function toJsonResponse(status, payload) { + return { + status, + headers: buildCorsHeaders("application/json"), + body: JSON.stringify(payload) + }; +} + +function toTextResponse(status, text, contentType = "text/plain") { + return { + status, + headers: buildCorsHeaders(contentType), + body: text + }; +} + +export { + buildCorsHeaders, + toJsonResponse, + toTextResponse +}; diff --git a/src/core/proxy.js b/src/core/proxy.js new file mode 100644 index 0000000..c5dfb29 --- /dev/null +++ b/src/core/proxy.js @@ -0,0 +1,154 @@ +import { toJsonResponse } from "./http.js"; + +function createTimeoutSignal(timeoutMs) { + if (typeof AbortSignal === "undefined" || typeof AbortSignal.timeout !== "function") { + return undefined; + } + return AbortSignal.timeout(timeoutMs); +} + +function isTimeoutError(error) { + return error && (error.name === "TimeoutError" || error.name === "AbortError"); +} + +async function parseUpstreamJson(response) { + try { + return await response.json(); + } catch { + return null; + } +} + +function buildGeminiUrl(config, token) { + if (config.GEMINI_URL) { + if (/([?&])key=/.test(config.GEMINI_URL)) { + return config.GEMINI_URL; + } + + const separator = config.GEMINI_URL.includes("?") ? "&" : "?"; + return `${config.GEMINI_URL}${separator}key=${encodeURIComponent(token)}`; + } + + if (!config.GEMINI_MODEL) { + return null; + } + + return `${config.GEMINI_API_BASE}/v1beta/models/${encodeURIComponent(config.GEMINI_MODEL)}:generateContent?key=${encodeURIComponent(token)}`; +} + +function getProviderConfig(provider, config) { + switch (provider) { + case "gemini": + { + const upstreamUrl = buildGeminiUrl(config, config.GEMINI_API_KEY || ""); + return { + token: config.GEMINI_API_KEY, + tokenName: "GEMINI_API_KEY", + upstreamUrl, + upstreamUrlName: "GEMINI_URL", + buildHeaders: () => ({ "Content-Type": "application/json" }), + extractText: (data) => { + return data?.candidates?.[0]?.content?.parts?.[0]?.text || "No response text returned."; + } + }; + } + case "github": + return { + token: config.GITHUB_TOKEN, + tokenName: "GITHUB_TOKEN", + upstreamUrl: config.GH_URL, + upstreamUrlName: "GH_URL", + buildHeaders: (token) => ({ + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }), + extractText: (data) => { + return data?.choices?.[0]?.message?.content || "No response text returned."; + } + }; + case "openrouter": + return { + token: config.OPENROUTER_API_KEY, + tokenName: "OPENROUTER_API_KEY", + upstreamUrl: config.OPENROUTER_URL, + upstreamUrlName: "OPENROUTER_URL", + buildHeaders: (token) => ({ + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }), + extractText: (data) => { + return data?.choices?.[0]?.message?.content || "No response text returned."; + } + }; + case "deepseek": + return { + token: config.DEEPSEEK_API_KEY, + tokenName: "DEEPSEEK_API_KEY", + upstreamUrl: config.DEEPSEEK_URL, + upstreamUrlName: "DEEPSEEK_URL", + buildHeaders: (token) => ({ + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }), + extractText: (data) => { + return data?.choices?.[0]?.message?.content || "No response text returned."; + } + }; + default: + return null; + } +} + +function validateProviderConfig(providerConfig) { + if (!providerConfig) { + return toJsonResponse(404, { error: "Unknown API route." }); + } + + if (!providerConfig.token) { + return toJsonResponse(500, { error: `Missing ${providerConfig.tokenName} environment variable.` }); + } + + if (!providerConfig.upstreamUrl) { + return toJsonResponse(500, { error: `Missing ${providerConfig.upstreamUrlName} environment variable.` }); + } + + return null; +} + +async function proxyProviderRequest({ provider, body, config, fetchImpl = fetch }) { + const providerConfig = getProviderConfig(provider, config); + const validationError = validateProviderConfig(providerConfig); + if (validationError) { + return validationError; + } + + try { + const response = await fetchImpl(providerConfig.upstreamUrl, { + method: "POST", + headers: providerConfig.buildHeaders(providerConfig.token), + body, + signal: createTimeoutSignal(config.UPSTREAM_TIMEOUT_MS) + }); + + const data = await parseUpstreamJson(response); + + if (!response.ok) { + const message = data?.error?.message || `Upstream API error (HTTP ${response.status})`; + return toJsonResponse(response.status, { error: message }); + } + + const text = providerConfig.extractText(data); + return toJsonResponse(200, { text }); + } catch (error) { + if (isTimeoutError(error)) { + return toJsonResponse(504, { error: `Upstream request timed out after ${config.UPSTREAM_TIMEOUT_MS} ms.` }); + } + + return toJsonResponse(502, { error: error?.message || "Upstream request failed." }); + } +} + +export { + getProviderConfig, + proxyProviderRequest +}; diff --git a/src/core/routes.js b/src/core/routes.js new file mode 100644 index 0000000..e3110b5 --- /dev/null +++ b/src/core/routes.js @@ -0,0 +1,20 @@ +const ROUTES = { + "/api/gemprompt": "gemini", + "/api/ghprompt": "github", + "/api/orprompt": "openrouter", + "/api/dsprompt": "deepseek" +}; + +function resolveProvider(pathname) { + return ROUTES[pathname] || null; +} + +function isProxyPath(pathname) { + return Boolean(resolveProvider(pathname)); +} + +export { + ROUTES, + isProxyPath, + resolveProvider +}; diff --git a/src/node/server.js b/src/node/server.js new file mode 100644 index 0000000..75a5bfb --- /dev/null +++ b/src/node/server.js @@ -0,0 +1,124 @@ +import http from "node:http"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { resolveConfig } from "../core/config.js"; +import { buildCorsHeaders, toTextResponse } from "../core/http.js"; +import { proxyProviderRequest } from "../core/proxy.js"; +import { resolveProvider } from "../core/routes.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, "../.."); + +const config = resolveConfig(process.env); + +function applyResponse(res, response) { + res.writeHead(response.status, response.headers); + res.end(response.body); +} + +function sendFile(res, filePath) { + fs.readFile(filePath, (err, data) => { + if (err) { + applyResponse(res, toTextResponse(404, "Not Found")); + return; + } + + const ext = path.extname(filePath).toLowerCase(); + const types = { + ".html": "text/html", + ".js": "text/javascript", + ".css": "text/css", + ".json": "application/json" + }; + + res.writeHead(200, buildCorsHeaders(types[ext] || "application/octet-stream")); + res.end(data); + }); +} + +function readRequestBody(req) { + return new Promise((resolve, reject) => { + let body = ""; + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + resolve(body); + }); + req.on("error", reject); + }); +} + +function logMissingEnvWarning(routeLabel, envName) { + console.log(`${routeLabel} route disabled until ${envName} is set.`); +} + +function logStartupWarnings() { + if (!config.GEMINI_API_KEY) { + logMissingEnvWarning("Gemini", "GEMINI_API_KEY"); + } + if (!config.GEMINI_URL) { + logMissingEnvWarning("Gemini", "GEMINI_URL"); + } + if (!config.GITHUB_TOKEN) { + logMissingEnvWarning("GitHub Models", "GITHUB_TOKEN"); + } + if (!config.GH_URL) { + logMissingEnvWarning("GitHub Models", "GH_URL"); + } + if (!config.OPENROUTER_API_KEY) { + logMissingEnvWarning("OpenRouter", "OPENROUTER_API_KEY"); + } + if (!config.OPENROUTER_URL) { + logMissingEnvWarning("OpenRouter", "OPENROUTER_URL"); + } + if (!config.DEEPSEEK_API_KEY) { + logMissingEnvWarning("DeepSeek", "DEEPSEEK_API_KEY"); + } + if (!config.DEEPSEEK_URL) { + logMissingEnvWarning("DeepSeek", "DEEPSEEK_URL"); + } +} + +const server = http.createServer(async (req, res) => { + if (req.method === "OPTIONS") { + res.writeHead(204, buildCorsHeaders("application/json")); + res.end(); + return; + } + + const parsedUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`); + const pathname = parsedUrl.pathname; + + if (req.method === "POST") { + const provider = resolveProvider(pathname); + if (provider) { + const body = await readRequestBody(req); + const response = await proxyProviderRequest({ + provider, + body, + config, + fetchImpl: fetch + }); + applyResponse(res, response); + return; + } + } + + if (req.method === "GET") { + const safePath = pathname === "/" ? "/gemini-node.html" : pathname; + const filePath = path.join(projectRoot, safePath); + sendFile(res, filePath); + return; + } + + applyResponse(res, toTextResponse(405, "Method Not Allowed")); +}); + +server.listen(config.PORT, () => { + console.log(`Unified server running on http://localhost:${config.PORT}`); + logStartupWarnings(); +}); diff --git a/src/worker/worker.js b/src/worker/worker.js new file mode 100644 index 0000000..60bc79f --- /dev/null +++ b/src/worker/worker.js @@ -0,0 +1,42 @@ +import { resolveConfig } from "../core/config.js"; +import { buildCorsHeaders, toTextResponse } from "../core/http.js"; +import { proxyProviderRequest } from "../core/proxy.js"; +import { resolveProvider } from "../core/routes.js"; + +function toFetchResponse(response) { + return new Response(response.body, { + status: response.status, + headers: response.headers + }); +} + +export default { + async fetch(request, env) { + if (request.method === "OPTIONS") { + return new Response(null, { + status: 204, + headers: buildCorsHeaders("application/json") + }); + } + + const url = new URL(request.url); + const pathname = url.pathname; + + if (request.method === "POST") { + const provider = resolveProvider(pathname); + if (provider) { + const body = await request.text(); + const config = resolveConfig(env || {}); + const response = await proxyProviderRequest({ + provider, + body, + config, + fetchImpl: fetch + }); + return toFetchResponse(response); + } + } + + return toFetchResponse(toTextResponse(404, "Not Found")); + } +}; diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..139ee1a --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,24 @@ +name = "aiproxy-dev" +main = "src/worker/worker.js" +compatibility_date = "2026-06-11" +account_id = "d647acf6b7120bc9675f258cbaf8af99" +workers_dev = true +preview_urls = false + +[[routes]] +pattern = "aiproxy-dev.numerus.app/*" +zone_name = "numerus.app" + +[vars] +GEMINI_MODEL = "gemini-2.5-flash" +GEMINI_API_BASE = "https://generativelanguage.googleapis.com" +GH_URL = "https://models.inference.ai.azure.com/chat/completions" +OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" +DEEPSEEK_URL = "https://api.deepseek.com/chat/completions" +UPSTREAM_TIMEOUT_MS = "30000" + +# Configure secrets with: +# wrangler secret put GEMINI_API_KEY +# wrangler secret put GITHUB_TOKEN +# wrangler secret put OPENROUTER_API_KEY +# wrangler secret put DEEPSEEK_API_KEY From 26183f1001c1476b034fcdc5b911b79085cc93d6 Mon Sep 17 00:00:00 2001 From: "John V. Pataki" Date: Thu, 11 Jun 2026 14:56:19 -0700 Subject: [PATCH 03/13] Phase 3+4: production Worker env, explicit deploy scripts, full test matrix Phase 3 - wrangler.toml: - Add [env.production] block: worker name 'aiproxy', route aiproxy-worker.numerus.app (aiproxy.numerus.app reserved for DO App; swap route when cutting over to Worker) - workers_dev=false for production - Explicit --env flags on all deploy scripts to suppress multi-env ambiguity warning Phase 4 - README: - Rewrite Testing section with full matrix table (unit, Node local, Worker local, CF dev, CF prod, DO App) all using TEST_BASE_URL override - Rewrite Cloudflare Worker section: env table, per-env secret setup commands, DNS CNAME requirement and cutover note - Update dual-runtime status block: phases 1-4 complete --- README.md | 113 +++++++++++++++++++++++++++++++------------------- package.json | 5 ++- wrangler.toml | 33 ++++++++++++--- 3 files changed, 102 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 9bd2fdf..8c91356 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,10 @@ Implementation phases, scope, and checkpoints are documented in: Current branch-level implementation status: -- Phase 1 started: shared runtime-agnostic core modules added under `src/core/` -- Phase 2 started: runtime adapters added for Node (`src/node/server.js`) and Cloudflare Worker (`src/worker/worker.js`) +- Phase 1 complete: shared runtime-agnostic core modules under `src/core/` +- Phase 2 complete: Node adapter (`src/node/server.js`) and Worker adapter (`src/worker/worker.js`) +- Phase 3 complete: `wrangler.toml` has `dev` (default) and `production` environments with separate worker names and routes +- Phase 4 complete: full test matrix documented below ## What It Does @@ -108,33 +110,64 @@ npm start The server will fail to reach upstream providers if the required variables are not already set — there is no interactive prompt in this path. -## Cloudflare Worker (Dev / Deploy) +## Cloudflare Worker -The Worker adapter uses the same API route contract as Node for `POST` endpoints. +The Worker adapter uses the same API route contract as Node for `POST` endpoints. Static file serving is not supported in the Worker — it handles API routes only. -### Configuration +`wrangler.toml` defines two environments: -- Worker config: `wrangler.toml` -- Set secrets in Cloudflare for tokens: - - `wrangler secret put GEMINI_API_KEY` - - `wrangler secret put GITHUB_TOKEN` - - `wrangler secret put OPENROUTER_API_KEY` - - `wrangler secret put DEEPSEEK_API_KEY` -- Set non-secret vars in `wrangler.toml` under `[vars]`: - - `GEMINI_URL`, `GH_URL`, `OPENROUTER_URL`, `DEEPSEEK_URL`, `UPSTREAM_TIMEOUT_MS` +| Environment | Worker name | Custom domain | Command | +|---|---|---|---| +| dev (default) | `aiproxy-dev` | `aiproxy-dev.numerus.app` | `npm run worker:deploy` | +| production | `aiproxy` | `aiproxy-worker.numerus.app` | `npm run worker:deploy:prod` | -### Run Worker Locally +> `aiproxy.numerus.app` currently points to the DigitalOcean App. When you decide to cut over to the Worker, update the production route in `wrangler.toml` to `aiproxy.numerus.app/*`, redeploy with `npm run worker:deploy:prod`, and update the DNS CNAME. No other changes needed. + +### First-time secret setup + +Run once per environment. Secrets are stored in Cloudflare and never in `wrangler.toml`. + +```bash +# Dev secrets (default) +npx wrangler secret put GEMINI_API_KEY +npx wrangler secret put GITHUB_TOKEN +npx wrangler secret put OPENROUTER_API_KEY +npx wrangler secret put DEEPSEEK_API_KEY + +# Production secrets +npx wrangler secret put GEMINI_API_KEY -e production +npx wrangler secret put GITHUB_TOKEN -e production +npx wrangler secret put OPENROUTER_API_KEY -e production +npx wrangler secret put DEEPSEEK_API_KEY -e production +``` + +### Run Worker locally (emulated) ```bash npm run worker:dev ``` -### Deploy Worker +Wrangler runs the Worker on `http://127.0.0.1:8787` using a local runtime emulation layer. + +### Deploy to Cloudflare ```bash +# Deploy to dev environment npm run worker:deploy + +# Deploy to production environment +npm run worker:deploy:prod ``` +### Custom domain DNS + +Cloudflare route bindings in `wrangler.toml` do not create DNS records automatically. Each hostname needs a CNAME in the `numerus.app` zone: + +| Name | Target | Proxy | +|---|---|---| +| `aiproxy-dev` | `aiproxy-dev.numerus.workers.dev` | Proxied | +| `aiproxy-worker` | `aiproxy.numerus.workers.dev` | Proxied | + ### Base URL ```text @@ -178,51 +211,47 @@ Errors are normalized to: ## Testing -The test suite is split into deterministic unit tests and opt-in live integration tests. +The test suite has two layers and can target any runtime via `TEST_BASE_URL`. -### Unit Tests +### Test Matrix + +| Layer | What it tests | Command | Prerequisites | +|---|---|---|---| +| Unit | Local logic, retry, history rollback | `npm test` | None | +| Live — Node local | Full proxy via local Node server | `npm run test:live` | `npm run start:dev` running | +| Live — Worker local | Full proxy via wrangler emulation | `TEST_BASE_URL=http://127.0.0.1:8787 npm run test:live` | `npm run worker:dev` running | +| Live — CF dev | Full proxy via deployed dev Worker | `TEST_BASE_URL=https://aiproxy-dev.numerus.app npm run test:live` | Worker deployed, DNS live | +| Live — CF prod | Full proxy via deployed prod Worker | `TEST_BASE_URL=https://aiproxy.numerus.app npm run test:live` | Worker deployed, DNS live | +| Live — DO App | Full proxy via deployed DO App | `TEST_BASE_URL=https:// npm run test:live` | DO App running | +| All local | Unit + Node live together | `npm run test:all` | `npm run start:dev` running | -Run local logic only: +### Unit Tests ```bash npm test ``` -These tests do not call the network. They cover retry behavior, empty-response handling, rollback of failed user turns, and conversation-history updates. This is the CI-friendly layer because failures here usually mean local code regressed. +No network required. Covers retry behavior, empty-response handling, rollback of failed user turns, and conversation-history updates. CI-safe. ### Live Integration Tests -Run the real end-to-end path through the local proxy and upstream providers: - ```bash +# Node local (requires: npm run start:dev in another terminal) npm run test:live -``` - -You can also target a deployed environment by overriding the test base URL: - -```bash -TEST_BASE_URL=https://${AIPROXY_DEPLOYED_URL} npm run test:live -``` -For Cloudflare Worker local dev, point tests to the wrangler dev URL: - -```bash +# Worker local emulation (requires: npm run worker:dev in another terminal) TEST_BASE_URL=http://127.0.0.1:8787 npm run test:live -``` -Requirements: +# Deployed Cloudflare dev Worker +TEST_BASE_URL=https://aiproxy-dev.numerus.app npm run test:live -- the local proxy server is already running (via `bash start.sh`, `npm run start:dev`, or `npm start` with env vars pre-set) -- valid provider credentials are available -- upstream providers are reachable - -The live test helpers in this folder now cover Gemini, GitHub Models, OpenRouter, and DeepSeek through the local proxy endpoints. - -These tests can skip when a provider returns a transient overload response such as Gemini high demand. +# Deployed Cloudflare production Worker +TEST_BASE_URL=https://aiproxy.numerus.app npm run test:live +``` -### Run Everything +Tests can skip individual providers when an upstream returns a transient overload (e.g. Gemini high demand). -Run both layers: +### Run Unit + Node Live Together ```bash npm run test:all diff --git a/package.json b/package.json index af2be42..272f42b 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,9 @@ "scripts": { "start": "node server.cjs", "start:dev": "bash start.sh", - "worker:dev": "npx wrangler dev", - "worker:deploy": "npx wrangler deploy", + "worker:dev": "npx wrangler dev --env=\"\"", + "worker:deploy": "npx wrangler deploy --env=\"\"", + "worker:deploy:prod": "npx wrangler deploy -e production", "test": "node --test test/unit.test.js", "test:live": "node --test test/test.js", "test:all": "node --test test/unit.test.js test/test.js" diff --git a/wrangler.toml b/wrangler.toml index 139ee1a..1d1155b 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,3 +1,4 @@ +# Default environment = dev name = "aiproxy-dev" main = "src/worker/worker.js" compatibility_date = "2026-06-11" @@ -17,8 +18,30 @@ OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" DEEPSEEK_URL = "https://api.deepseek.com/chat/completions" UPSTREAM_TIMEOUT_MS = "30000" -# Configure secrets with: -# wrangler secret put GEMINI_API_KEY -# wrangler secret put GITHUB_TOKEN -# wrangler secret put OPENROUTER_API_KEY -# wrangler secret put DEEPSEEK_API_KEY +# Secrets (set once per environment, never stored here): +# npx wrangler secret put GEMINI_API_KEY +# npx wrangler secret put GITHUB_TOKEN +# npx wrangler secret put OPENROUTER_API_KEY +# npx wrangler secret put DEEPSEEK_API_KEY +# +# For production environment add -e production to each command above. + +# --------------------------------------------------------------------------- +# Production environment (npx wrangler deploy -e production) +# --------------------------------------------------------------------------- +[env.production] +name = "aiproxy" +workers_dev = false +preview_urls = false + +[[env.production.routes]] +pattern = "aiproxy-worker.numerus.app/*" +zone_name = "numerus.app" + +[env.production.vars] +GEMINI_MODEL = "gemini-2.5-flash" +GEMINI_API_BASE = "https://generativelanguage.googleapis.com" +GH_URL = "https://models.inference.ai.azure.com/chat/completions" +OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" +DEEPSEEK_URL = "https://api.deepseek.com/chat/completions" +UPSTREAM_TIMEOUT_MS = "30000" From 80fd6f24a7a6ac8bdfc890521bedc754fdd69be8 Mon Sep 17 00:00:00 2001 From: "John V. Pataki" Date: Thu, 11 Jun 2026 15:20:33 -0700 Subject: [PATCH 04/13] Make Worker setup generic and add restore script - Replace public Worker config with generic placeholders - Add untracked local config example for account/zone/route details - Add scripts/worker.sh to restore or deploy dev/prod Workers after wrangler login - Document local prerequisites and cutover workflow in README - Keep repository free of Numerus-specific values in committed config --- .gitignore | 3 + .worker.local.env.example | 19 ++++++ README.md | 33 ++++++---- package.json | 6 +- scripts/worker.sh | 129 ++++++++++++++++++++++++++++++++++++++ wrangler.toml | 20 +++--- 6 files changed, 186 insertions(+), 24 deletions(-) create mode 100644 .worker.local.env.example create mode 100644 scripts/worker.sh diff --git a/.gitignore b/.gitignore index 511d124..cf521b4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ .env .env.modelkeys .env.modelspecs +.worker.local.env +.worker.local.env.* +!.worker.local.env.example node_modules/ npm-debug.log* yarn-debug.log* diff --git a/.worker.local.env.example b/.worker.local.env.example new file mode 100644 index 0000000..afb97e4 --- /dev/null +++ b/.worker.local.env.example @@ -0,0 +1,19 @@ +CF_ACCOUNT_ID=your_cloudflare_account_id +CF_ZONE_NAME=example.com + +# Use names and routes appropriate for your account and DNS zone. +CF_DEV_WORKER_NAME=aiproxy-dev +CF_DEV_WORKER_ROUTE=dev-worker.example.com/* +CF_DEV_CUSTOM_DOMAIN=dev-worker.example.com + +CF_PROD_WORKER_NAME=aiproxy +CF_PROD_WORKER_ROUTE=worker.example.com/* +CF_PROD_CUSTOM_DOMAIN=worker.example.com + +# Optional overrides for the generic Worker template. +CF_GEMINI_MODEL=gemini-2.5-flash +CF_GEMINI_API_BASE=https://generativelanguage.googleapis.com +CF_GH_URL=https://models.inference.ai.azure.com/chat/completions +CF_OPENROUTER_URL=https://openrouter.ai/api/v1/chat/completions +CF_DEEPSEEK_URL=https://api.deepseek.com/chat/completions +CF_UPSTREAM_TIMEOUT_MS=30000 \ No newline at end of file diff --git a/README.md b/README.md index 8c91356..e9afecf 100644 --- a/README.md +++ b/README.md @@ -114,14 +114,16 @@ The server will fail to reach upstream providers if the required variables are n The Worker adapter uses the same API route contract as Node for `POST` endpoints. Static file serving is not supported in the Worker — it handles API routes only. -`wrangler.toml` defines two environments: +Before using the Worker on a machine, run `npx wrangler login` once so Wrangler can authenticate with Cloudflare. The committed files intentionally stay generic; Cloudflare names, routes, account IDs, and zone details live in the untracked `.worker.local.env` file. + +`wrangler.toml` is a public template. The restore script renders the actual Cloudflare values from `.worker.local.env`. | Environment | Worker name | Custom domain | Command | |---|---|---|---| -| dev (default) | `aiproxy-dev` | `aiproxy-dev.numerus.app` | `npm run worker:deploy` | -| production | `aiproxy` | `aiproxy-worker.numerus.app` | `npm run worker:deploy:prod` | +| dev (default) | `CF_DEV_WORKER_NAME` | `CF_DEV_CUSTOM_DOMAIN` | `npm run worker:deploy` | +| production | `CF_PROD_WORKER_NAME` | `CF_PROD_CUSTOM_DOMAIN` | `npm run worker:deploy:prod` | -> `aiproxy.numerus.app` currently points to the DigitalOcean App. When you decide to cut over to the Worker, update the production route in `wrangler.toml` to `aiproxy.numerus.app/*`, redeploy with `npm run worker:deploy:prod`, and update the DNS CNAME. No other changes needed. +> When you decide to cut over the public production domain to the Worker, update the values in your local `.worker.local.env` file, rerun `npm run worker:deploy:prod`, and change the DNS CNAME. No committed file needs to change for that cutover. ### First-time secret setup @@ -147,7 +149,14 @@ npx wrangler secret put DEEPSEEK_API_KEY -e production npm run worker:dev ``` -Wrangler runs the Worker on `http://127.0.0.1:8787` using a local runtime emulation layer. +Wrangler runs the Worker on `http://127.0.0.1:8787` using a local runtime emulation layer. The script reads `.env` for API credentials and `.worker.local.env` for Cloudflare-specific names/routes. + +The local machine needs all of the following before this can work: + +- `npx wrangler login` +- `.env` with provider credentials +- `.worker.local.env` with Cloudflare account, zone, worker names, and routes +- DNS access to the relevant Cloudflare zone if you want the custom domain to resolve ### Deploy to Cloudflare @@ -161,12 +170,12 @@ npm run worker:deploy:prod ### Custom domain DNS -Cloudflare route bindings in `wrangler.toml` do not create DNS records automatically. Each hostname needs a CNAME in the `numerus.app` zone: +Cloudflare route bindings in `wrangler.toml` do not create DNS records automatically. Each hostname needs a CNAME in your DNS zone: | Name | Target | Proxy | |---|---|---| -| `aiproxy-dev` | `aiproxy-dev.numerus.workers.dev` | Proxied | -| `aiproxy-worker` | `aiproxy.numerus.workers.dev` | Proxied | +| `CF_DEV_CUSTOM_DOMAIN` host | `CF_DEV_WORKER_NAME.workers.dev` | Proxied | +| `CF_PROD_CUSTOM_DOMAIN` host | `CF_PROD_WORKER_NAME.workers.dev` | Proxied | ### Base URL @@ -220,8 +229,8 @@ The test suite has two layers and can target any runtime via `TEST_BASE_URL`. | Unit | Local logic, retry, history rollback | `npm test` | None | | Live — Node local | Full proxy via local Node server | `npm run test:live` | `npm run start:dev` running | | Live — Worker local | Full proxy via wrangler emulation | `TEST_BASE_URL=http://127.0.0.1:8787 npm run test:live` | `npm run worker:dev` running | -| Live — CF dev | Full proxy via deployed dev Worker | `TEST_BASE_URL=https://aiproxy-dev.numerus.app npm run test:live` | Worker deployed, DNS live | -| Live — CF prod | Full proxy via deployed prod Worker | `TEST_BASE_URL=https://aiproxy.numerus.app npm run test:live` | Worker deployed, DNS live | +| Live — CF dev | Full proxy via deployed dev Worker | `TEST_BASE_URL=https:// npm run test:live` | Worker deployed, DNS live | +| Live — CF prod | Full proxy via deployed prod Worker | `TEST_BASE_URL=https:// npm run test:live` | Worker deployed, DNS live | | Live — DO App | Full proxy via deployed DO App | `TEST_BASE_URL=https:// npm run test:live` | DO App running | | All local | Unit + Node live together | `npm run test:all` | `npm run start:dev` running | @@ -243,10 +252,10 @@ npm run test:live TEST_BASE_URL=http://127.0.0.1:8787 npm run test:live # Deployed Cloudflare dev Worker -TEST_BASE_URL=https://aiproxy-dev.numerus.app npm run test:live +TEST_BASE_URL=https:// npm run test:live # Deployed Cloudflare production Worker -TEST_BASE_URL=https://aiproxy.numerus.app npm run test:live +TEST_BASE_URL=https:// npm run test:live ``` Tests can skip individual providers when an upstream returns a transient overload (e.g. Gemini high demand). diff --git a/package.json b/package.json index 272f42b..64c7f7f 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,9 @@ "scripts": { "start": "node server.cjs", "start:dev": "bash start.sh", - "worker:dev": "npx wrangler dev --env=\"\"", - "worker:deploy": "npx wrangler deploy --env=\"\"", - "worker:deploy:prod": "npx wrangler deploy -e production", + "worker:dev": "bash scripts/worker.sh dev", + "worker:deploy": "bash scripts/worker.sh deploy dev", + "worker:deploy:prod": "bash scripts/worker.sh deploy production", "test": "node --test test/unit.test.js", "test:live": "node --test test/test.js", "test:all": "node --test test/unit.test.js test/test.js" diff --git a/scripts/worker.sh b/scripts/worker.sh new file mode 100644 index 0000000..6b093b1 --- /dev/null +++ b/scripts/worker.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +if [[ ! -f ".worker.local.env" ]]; then + echo "Missing .worker.local.env. Copy .worker.local.env.example and fill in your Cloudflare account, zone, worker names, and routes." >&2 + exit 1 +fi + +if [[ -f ".env" ]]; then + # .env holds API credentials and stays untracked. + source ./.env +fi + +# .worker.local.env holds Cloudflare-specific values and stays untracked. +source ./.worker.local.env + +env_source="${2:-dev}" +mode="${1:-dev}" + +write_config() { + local target_env="$1" + local worker_name="$2" + local route_pattern="$3" + local tmp_config + tmp_config="$(mktemp /tmp/aiproxy-worker.XXXXXX.toml)" + + cat > "$tmp_config" </dev/null 2>&1; then + echo "Wrangler is not authenticated. Run 'npx wrangler login' on this machine first." >&2 + exit 1 + fi +} + +deploy_env() { + local target_env="$1" + local worker_name="$2" + local route_pattern="$3" + local config_file + + check_wrangler_login + + if npx wrangler deployments list --name "$worker_name" --json >/dev/null 2>&1; then + echo "Worker '$worker_name' already exists in Cloudflare; updating it now." + else + echo "Worker '$worker_name' does not exist yet; deploying will create it." + fi + + config_file="$(write_config "$target_env" "$worker_name" "$route_pattern")" + trap 'rm -f "$config_file"' EXIT + + if [[ "$target_env" == "production" ]]; then + npx wrangler deploy -e production -c "$config_file" + else + npx wrangler deploy --env="" -c "$config_file" + fi +} + +run_dev() { + local config_file + check_wrangler_login + + config_file="$(write_config dev "${CF_DEV_WORKER_NAME:-aiproxy-dev}" "${CF_DEV_WORKER_ROUTE:-dev-worker.example.com/*}")" + trap 'rm -f "$config_file"' EXIT + + echo "Starting local Worker emulation for '${CF_DEV_WORKER_NAME:-aiproxy-dev}'." + echo "Required on this machine: 'npx wrangler login', '.env' for API credentials, and '.worker.local.env' for Cloudflare names/routes." + npx wrangler dev --env="" -c "$config_file" +} + +case "$mode" in + dev) + run_dev + ;; + deploy) + if [[ "$env_source" == "production" ]]; then + deploy_env production "${CF_PROD_WORKER_NAME:-aiproxy}" "${CF_PROD_WORKER_ROUTE:-worker.example.com/*}" + else + deploy_env dev "${CF_DEV_WORKER_NAME:-aiproxy-dev}" "${CF_DEV_WORKER_ROUTE:-dev-worker.example.com/*}" + fi + ;; + *) + echo "Usage: bash scripts/worker.sh [dev|deploy] [dev|production]" >&2 + exit 1 + ;; +esac \ No newline at end of file diff --git a/wrangler.toml b/wrangler.toml index 1d1155b..0dac8c4 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,14 +1,15 @@ -# Default environment = dev -name = "aiproxy-dev" +# This file is a generic template. Real worker names, routes, and zones are +# loaded from the untracked .worker.local.env file by scripts/worker.sh. +name = "worker-dev" main = "src/worker/worker.js" compatibility_date = "2026-06-11" -account_id = "d647acf6b7120bc9675f258cbaf8af99" +account_id = "YOUR_CLOUDFLARE_ACCOUNT_ID" workers_dev = true preview_urls = false [[routes]] -pattern = "aiproxy-dev.numerus.app/*" -zone_name = "numerus.app" +pattern = "dev-worker.example.com/*" +zone_name = "example.com" [vars] GEMINI_MODEL = "gemini-2.5-flash" @@ -24,19 +25,20 @@ UPSTREAM_TIMEOUT_MS = "30000" # npx wrangler secret put OPENROUTER_API_KEY # npx wrangler secret put DEEPSEEK_API_KEY # -# For production environment add -e production to each command above. +# Before using the Worker on this machine, run `npx wrangler login` once. +# The restore script checks that auth exists before it deploys. # --------------------------------------------------------------------------- # Production environment (npx wrangler deploy -e production) # --------------------------------------------------------------------------- [env.production] -name = "aiproxy" +name = "worker-prod" workers_dev = false preview_urls = false [[env.production.routes]] -pattern = "aiproxy-worker.numerus.app/*" -zone_name = "numerus.app" +pattern = "worker.example.com/*" +zone_name = "example.com" [env.production.vars] GEMINI_MODEL = "gemini-2.5-flash" From c5ff1c03ce9d7e13e4f9b70b7925d0c89f7e7d9c Mon Sep 17 00:00:00 2001 From: "John V. Pataki" Date: Thu, 11 Jun 2026 15:26:19 -0700 Subject: [PATCH 05/13] Update dual-runtime plan wording --- DUAL_RUNTIME_PORT_PLAN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DUAL_RUNTIME_PORT_PLAN.md b/DUAL_RUNTIME_PORT_PLAN.md index 02eab04..10aef73 100644 --- a/DUAL_RUNTIME_PORT_PLAN.md +++ b/DUAL_RUNTIME_PORT_PLAN.md @@ -4,7 +4,7 @@ Keep one repository that can run in three environments without changing client contracts: -1. Local machine (Richard) +1. Local machine (developer) 2. DigitalOcean App Platform (Node runtime) 3. Cloudflare Worker (edge runtime) From a0478b7fa5548e83abea5bdf7f3cec70a8d6789f Mon Sep 17 00:00:00 2001 From: "John V. Pataki" Date: Thu, 11 Jun 2026 15:32:17 -0700 Subject: [PATCH 06/13] Add Node/DO helper script and local config for symmetry with Worker setup - Add optional .node.local.env local-only config example - Add scripts/node.sh with check/start/dev modes for Node startup - Wire npm run node:check, node:dev, node:start aliases - Update start.sh to source optional .node.local.env overrides - Document Node helper prerequisites in README - Preserve start.sh as the primary path; helper adds prerequisite validation - Use 'node' from PATH instead of absolute /usr/local/bin/node for portability --- .gitignore | 3 ++ .node.local.env.example | 9 ++++ README.md | 15 +++++++ package.json | 3 ++ scripts/node.sh | 99 +++++++++++++++++++++++++++++++++++++++++ start.sh | 7 ++- 6 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 .node.local.env.example create mode 100644 scripts/node.sh diff --git a/.gitignore b/.gitignore index cf521b4..d051f15 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ .env .env.modelkeys .env.modelspecs +.node.local.env +.node.local.env.* +!.node.local.env.example .worker.local.env .worker.local.env.* !.worker.local.env.example diff --git a/.node.local.env.example b/.node.local.env.example new file mode 100644 index 0000000..3d88f6a --- /dev/null +++ b/.node.local.env.example @@ -0,0 +1,9 @@ +# Optional local-only Node/DO overrides. +# +# `start.sh` remains the primary startup path; this file is for machine-specific +# Node/DO preferences that should not be committed. + +PORT=3000 +NODE_START_COMMAND="bash start.sh" +NODE_SERVER_COMMAND="node server.cjs" +DO_APP_URL="https://example.com" diff --git a/README.md b/README.md index e9afecf..64218d8 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,15 @@ Or via npm: npm run start:dev ``` +If you want an explicit Node-side check before starting, use: + +```bash +npm run node:check +npm run node:dev +``` + +The Node helper reads `.node.local.env` when present for machine-specific settings that should not be committed. + ### Option B — via npm start (manual env management) If your environment variables are already exported in your shell session (e.g. via your shell profile or a separate env tool), you can start the server directly without the shell script: @@ -110,6 +119,12 @@ npm start The server will fail to reach upstream providers if the required variables are not already set — there is no interactive prompt in this path. +The local machine needs all of the following before the Node helper can run cleanly: + +- `node` available on PATH +- `.env` with provider credentials +- optional `.node.local.env` with Node/DO machine-specific overrides + ## Cloudflare Worker The Worker adapter uses the same API route contract as Node for `POST` endpoints. Static file serving is not supported in the Worker — it handles API routes only. diff --git a/package.json b/package.json index 64c7f7f..edf3cba 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ "scripts": { "start": "node server.cjs", "start:dev": "bash start.sh", + "node:dev": "bash scripts/node.sh dev", + "node:start": "bash scripts/node.sh start", + "node:check": "bash scripts/node.sh check", "worker:dev": "bash scripts/worker.sh dev", "worker:deploy": "bash scripts/worker.sh deploy dev", "worker:deploy:prod": "bash scripts/worker.sh deploy production", diff --git a/scripts/node.sh b/scripts/node.sh new file mode 100644 index 0000000..82381d4 --- /dev/null +++ b/scripts/node.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +if [[ -f ".env" ]]; then + # .env holds provider credentials and stays untracked. + source ./.env +fi + +if [[ -f ".node.local.env" ]]; then + # .node.local.env holds machine-specific Node/DO details and stays untracked. + source ./.node.local.env +fi + +export PORT="${PORT:-3000}" +export GEMINI_API_KEY="${GEMINI_API_KEY:-}" +export GEMINI_MODEL="${GEMINI_MODEL:-}" +export GEMINI_URL="${GEMINI_URL:-}" +export GITHUB_TOKEN="${GITHUB_TOKEN:-}" +export GH_URL="${GH_URL:-}" +export OPENROUTER_API_KEY="${OPENROUTER_API_KEY:-}" +export OPENROUTER_URL="${OPENROUTER_URL:-}" +export DEEPSEEK_API_KEY="${DEEPSEEK_API_KEY:-}" +export DEEPSEEK_URL="${DEEPSEEK_URL:-}" + +check_requirements() { + if ! command -v node >/dev/null 2>&1; then + echo "Node.js is not installed or not on PATH." >&2 + exit 1 + fi + + if ! node --version >/dev/null 2>&1; then + echo "Unable to run node --version; check your Node installation." >&2 + exit 1 + fi + + if [[ ! -f "server.cjs" ]]; then + echo "Missing server.cjs in the repository root." >&2 + exit 1 + fi +} + +warn_missing_value() { + local label="$1" + local value="$2" + if [[ -z "$value" ]]; then + echo "Warning: $label is not set." >&2 + fi +} + +print_status() { + warn_missing_value "GEMINI_API_KEY" "${GEMINI_API_KEY:-}" + warn_missing_value "GITHUB_TOKEN" "${GITHUB_TOKEN:-}" + warn_missing_value "OPENROUTER_API_KEY" "${OPENROUTER_API_KEY:-}" + warn_missing_value "DEEPSEEK_API_KEY" "${DEEPSEEK_API_KEY:-}" + warn_missing_value "GEMINI_URL" "${GEMINI_URL:-}" + warn_missing_value "GH_URL" "${GH_URL:-}" + warn_missing_value "OPENROUTER_URL" "${OPENROUTER_URL:-}" + warn_missing_value "DEEPSEEK_URL" "${DEEPSEEK_URL:-}" +} + +show_help() { + cat <<'EOF' +Usage: bash scripts/node.sh [check|start|dev] + +check Validate the Node runtime and local files. +start Run the Node proxy directly. +dev Run the documented local startup flow via start.sh. +EOF +} + +mode="${1:-dev}" + +case "$mode" in + check) + check_requirements + print_status + echo "Node startup prerequisites are present." + ;; + start) + check_requirements + print_status + exec node server.cjs + ;; + dev|restore) + check_requirements + print_status + echo "Starting Node proxy via start.sh." + exec bash start.sh + ;; + help|-h|--help) + show_help + ;; + *) + show_help >&2 + exit 1 + ;; +esac diff --git a/start.sh b/start.sh index c437067..9c4e0b3 100755 --- a/start.sh +++ b/start.sh @@ -10,6 +10,11 @@ elif [[ -f ".env.modelspecs" ]]; then source ./.env.modelspecs fi +# Optional machine-specific Node/DO overrides can live in .node.local.env. +if [[ -f ".node.local.env" ]]; then + source ./.node.local.env +fi + # Ensure sourced values are available to node. export PORT="${PORT:-3000}" export GEMINI_API_KEY="${GEMINI_API_KEY:-}" @@ -59,4 +64,4 @@ if [[ -z "${DEEPSEEK_URL:-}" ]]; then fi echo "Starting unified server on http://localhost:${PORT} (Gemini, GitHub Models, OpenRouter, and DeepSeek routes configured as available) ..." -exec /usr/local/bin/node server.cjs +exec node server.cjs From c980500e1065f21503e3844d0a3e683fa0aab8fd Mon Sep 17 00:00:00 2001 From: "John V. Pataki" Date: Thu, 11 Jun 2026 15:51:56 -0700 Subject: [PATCH 07/13] Add comprehensive dual-runtime architecture documentation - Explain Node.js vs Cloudflare Workers runtime differences - Detailed comparison table covering all key aspects - How the codebase achieves dual deployment via shared core + adapters - Configuration management and secrets best practices - Deployment procedures for both platforms - Testing matrix covering all environments - Decision criteria for choosing each platform - Operational checklists for setup and deployment --- DUAL_RUNTIME_ARCHITECTURE.md | 448 +++++++++++++++++++++++++++++++++++ 1 file changed, 448 insertions(+) create mode 100644 DUAL_RUNTIME_ARCHITECTURE.md diff --git a/DUAL_RUNTIME_ARCHITECTURE.md b/DUAL_RUNTIME_ARCHITECTURE.md new file mode 100644 index 0000000..f016a70 --- /dev/null +++ b/DUAL_RUNTIME_ARCHITECTURE.md @@ -0,0 +1,448 @@ +# Dual-Runtime Architecture: Node.js/DigitalOcean App Platform vs. Cloudflare Workers + +## Executive Summary + +This codebase is designed to deploy the same core proxy application to **two fundamentally different compute platforms**: Node.js (running on DigitalOcean App Platform) and Cloudflare Workers. This document explains what each platform is, why you'd choose one over the other, and how the code achieves this portability. + +--- + +## Platform Comparison + +### DigitalOcean App Platform (Node.js) + +**What it is:** A traditional, long-running HTTP server that executes on a single (or multiple) virtual machine(s) in a data center. + +**How it works:** +- You start a Node.js process that listens on a port (e.g., `localhost:3000`) +- The process remains running continuously, accepting HTTP requests +- Your application maintains state, can spawn child processes, read files from disk, etc. +- DigitalOcean manages the infrastructure (VM, networking, SSL) + +**Key characteristics:** +- Full Node.js runtime: all built-in modules available (`fs`, `net`, `child_process`, etc.) +- CommonJS/ESM module system with full npm ecosystem +- Predictable execution: process runs continuously +- Latency: requests routed through data center (typically 10–100ms depending on client location) +- Persistent resources: can maintain connections, caches, timers across requests + +--- + +### Cloudflare Workers + +**What it is:** A serverless compute platform that runs JavaScript on Cloudflare's globally distributed edge servers (300+ data centers worldwide). + +**How it works:** +- You deploy JavaScript code to Cloudflare's network +- Cloudflare automatically runs your code on edge servers near your users +- Each request triggers an isolated execution context (V8 isolate) +- No persistent process; code starts fresh for each request (or reused within CPU time limits) +- Responses are served from the nearest geographic location + +**Key characteristics:** +- **V8 isolate runtime:** A lightweight, sandboxed JavaScript engine (not full Node.js) +- **Web Standard APIs only:** `fetch()`, `Web Crypto`, `TextEncoder`, `Headers`, `Response`, etc. +- **No filesystem, no Node.js built-ins:** You cannot use `require('fs')`, `require('net')`, etc. +- **Distributed execution:** Your code runs in hundreds of locations simultaneously +- **Edge latency:** Requests typically <50ms, served from nearest Cloudflare POP (point of presence) +- **Stateless by design:** Each request is independent; no shared process state +- **Cost efficiency:** Free plan includes 100,000 requests/day at $0; paid plans scale with usage + +--- + +## Detailed Comparison Table + +| Feature | Node.js (DO App) | Cloudflare Workers | +|---------|------------------|-------------------| +| **Runtime Engine** | Node.js (full) | V8 isolate (lightweight) | +| **Module System** | CommonJS + ESM | ESM only | +| **Available APIs** | Node.js APIs (fs, net, os, etc.) + Web Standard | Web Standard only (fetch, crypto, etc.) | +| **Filesystem Access** | Yes (read/write) | No (KV storage available for persistent data) | +| **Child Processes** | Yes (spawn, fork) | No | +| **Execution Model** | Continuous process | Serverless (request-triggered) | +| **Geographic Distribution** | Single data center (or multi-region if replicated) | Global edge (300+ locations) | +| **Latency** | 10–100ms typical | <50ms typical (edge-optimized) | +| **Cold Start** | ~100–500ms (process boot) | <1ms (pre-warmed isolates) | +| **Cost (Free Tier)** | $12/month for DO App | $0 for 100k requests/day | +| **Cost (Paid)** | $12–100+/month (fixed) | Per-request metering (scales with usage) | +| **State Persistence** | ✓ (process-level state, caches) | ✗ (stateless; use KV/Durable Objects) | +| **Connection Pooling** | ✓ (HTTP keep-alive, DB connections) | ✓ (fetch keep-alive within 10s CPU limit) | +| **Horizontal Scaling** | Manual (spawn more processes) | Automatic (inherent in edge model) | +| **Setup Complexity** | Medium (YAML config, env vars) | Low (wrangler CLI, 2–3 secrets) | +| **Monitoring & Logging** | Built into DO (custom logs + CloudWatch) | Cloudflare Dashboard + Logpush | +| **Custom Domains** | ✓ CNAME to DO domain | ✓ CNAME to *.workers.dev or custom | + +--- + +## Choosing Your Runtime: Node.js vs. Cloudflare Workers + +Each platform excels in different scenarios. Evaluate based on your operational needs, traffic patterns, and constraints. + +### Benefits of Node.js (DO App) + +1. **Full JavaScript ecosystem:** Access to npm packages without restrictions +2. **Stateful operations:** Maintain connections, caches, timers +3. **Familiar development:** Traditional server-side development model +4. **Debugging:** Full Node.js debugging tools (inspect, logging) +5. **Cost transparency:** Predictable, fixed monthly cost +6. **Use cases:** + - Background jobs and scheduled tasks + - Database connection pooling + - Machine learning inference + - Long-running operations + +### Benefits of Cloudflare Workers + +1. **Global edge execution:** Serve users from nearest location (~all traffic <50ms) +2. **True serverless:** No ops overhead, pay only for requests made +3. **Zero cold starts:** Requests handled immediately +4. **Automatic scaling:** No capacity planning needed +5. **Free tier:** 100k requests/day = $0 cost +6. **Use cases:** + - Public APIs with high geographic dispersion + - Rapid prototyping + - Cost-sensitive, request-based billing + - Global content delivery + +### Hybrid Approach (This Codebase) + +Deploy the **same application to both**, choose based on circumstance: + +- **Development & testing:** Use local Node.js (`npm run start`) for fastest feedback, full debugging +- **Public API routes:** Deploy to Cloudflare Workers (edge latency, free tier) +- **Internal/private endpoints:** Use DO App (if needed for additional tooling/state) +- **Failover:** Workers can fallback to DO App using `fetch()` chains + +--- + +## How the Codebase Achieves Dual Deployment + +### Architecture Pattern: Shared Core + Adapters + +``` + +----------------------------------+ + | Shared Core Logic | + | | + | src/core/config.js | + | src/core/proxy.js | + | src/core/routes.js | + | src/core/http.js | + +----------------------------------+ + ^ ^ + | | + +------------------+ +------------------+ + | | + +---+--------------------+ +------------------+---+ + | Node.js Adapter | | Worker Adapter | + | | | | + | src/node/server.js | | src/worker/worker.js + | - Creates http server | | - Listens to fetch + | - Bridges Node req/res | | - Bridges Web Request + | - Calls core proxy | | - Calls core proxy + | | | | + +---+--------------------+ +--------------------+---+ + | | + v v + +---------------------+ +-----------------------+ + | Node.js Server | | Cloudflare Workers | + | | | | + | (localhost | | (*.workers.dev + | + | + DO App) | | custom domains) | + +---------------------+ +-----------------------+ +``` + +### How It Works + +1. **All business logic lives in `src/core/`** + - Provider routing (`/api/gemprompt` → Gemini, etc.) + - HTTP proxying to upstream APIs + - Conversation history management + - Response normalization + +2. **Two thin adapters** bridge the runtime differences: + - **Node adapter** (`src/node/server.js`): + - Creates an `http.Server` listening on `PORT` + - Converts Node.js `IncomingRequest` to core contract + - Calls `proxy.handleRequest()` + + - **Worker adapter** (`src/worker/worker.js`): + - Exports a `fetch` event handler + - Converts Cloudflare `Request` to core contract + - Calls `proxy.handleRequest()` + +3. **Same entry point, two outputs:** + - `npm run start` → Node server boots at localhost:3000 + - `npm run worker:deploy` → Wrangler deploys to Cloudflare + +### Code Example: Shared Core + +**`src/core/proxy.js`** (runtime-agnostic): +```javascript +export async function handleRequest(url, method, headers, body) { + const provider = resolveProvider(url.pathname); + const response = await proxyUpstream(provider, headers, body); + return { status: 200, body: response }; +} +``` + +**`src/node/server.js`** (Node adapter): +```javascript +const server = http.createServer(async (req, res) => { + const body = await readBody(req); + const result = await proxy.handleRequest( + new URL(`http://localhost:${PORT}${req.url}`), + req.method, + req.headers, + body + ); + res.writeHead(result.status); + res.end(result.body); +}); +``` + +**`src/worker/worker.js`** (Worker adapter): +```javascript +export default { + async fetch(request) { + const body = await request.text(); + const result = await proxy.handleRequest( + new URL(request.url), + request.method, + request.headers, + body + ); + return new Response(result.body, { status: result.status }); + } +}; +``` + +### Key Constraint: No Node.js APIs in Core + +To maintain portability, the shared core **must not use:** +- `require('fs')` — no filesystem +- `require('net')` — no sockets +- `require('child_process')` — no spawning +- Any module requiring Node.js-specific APIs + +**Allowed in core:** +- Fetch API (`fetch()`) +- Web Crypto +- TextEncoder/Decoder +- Standard JavaScript (arrays, objects, promises) + +--- + +## Deployment Procedures + +### Local Development (Node.js) + +```bash +# Check prerequisites +npm run node:check + +# Start server (port 3000 by default) +npm run start + +# Or with npm alias +npm run node:dev +``` + +**Files involved:** +- `.env` — Committed secrets (if any) +- `.node.local.env` — Local-only overrides (not committed) +- `start.sh` — Startup script +- `scripts/node.sh` — Helper for validation + +### Cloudflare Workers Deployment + +```bash +# One-time setup per machine +npx wrangler login + +# Deploy to dev environment (aiproxy-dev.numerus.workers.dev) +npm run worker:deploy + +# Deploy to production (aiproxy.numerus.workers.dev) +npm run worker:deploy:prod + +# Run locally (emulation) +npm run worker:dev +``` + +**Files involved:** +- `wrangler.toml` — Generic config template (committed, with placeholders) +- `.worker.local.env` — Actual account/domain values (not committed) +- `scripts/worker.sh` — Deployment and validation helper +- Secret values uploaded via `wrangler secret put` + +**Configuration approach:** +- Generic `wrangler.toml` checked into repo with placeholders (e.g., `YOUR_CLOUDFLARE_ACCOUNT_ID`) +- Actual values in untracked `.worker.local.env` (Cloudflare account ID, zone name, custom domain) +- `scripts/worker.sh` reads `.worker.local.env`, generates temporary runtime config, deploys + +--- + +## Testing Both Runtimes + +### Unit Tests (No Network) +```bash +npm test # 11/11 tests passing, runs on any runtime +``` + +### Live Tests with Override + +Test any runtime by setting `TEST_BASE_URL`: + +```bash +# Test local Node +TEST_BASE_URL=http://localhost:3000 npm run test:live + +# Test Cloudflare workers.dev (if deployed) +TEST_BASE_URL=https://aiproxy-dev.numerus.workers.dev npm run test:live + +# Test custom domain +TEST_BASE_URL=https://aiproxy-dev.numerus.app npm run test:live + +# Test DigitalOcean App +TEST_BASE_URL=https://aiproxy.ondigitalocean.app npm run test:live +``` + +### Test Coverage Matrix + +| Environment | Command | Notes | +|-------------|---------|-------| +| Local Node | `npm run start` + `TEST_BASE_URL=http://localhost:3000 npm run test:live` | Full debugging, fastest feedback | +| Worker (local emulation) | `npm run worker:dev` + `TEST_BASE_URL=http://localhost:8787 npm run test:live` | Tests Worker runtime without deploying | +| Worker (dev, deployed) | Deploy to CF, then `TEST_BASE_URL=https://aiproxy-dev.numerus.workers.dev npm run test:live` | Real edge execution | +| Worker (prod, deployed) | Deploy to CF prod, then `TEST_BASE_URL=https://aiproxy.numerus.workers.dev npm run test:live` | Production validation | +| DO App | Running on do.app domain, then `TEST_BASE_URL=https://aiproxy.ondigitalocean.app npm run test:live` | Traditional server testing | + +--- + +## Configuration Management + +### Environment Variables + +Both runtimes use the same environment resolution via `src/core/config.js`: + +```javascript +export const config = { + PORT: process.env.PORT || 3000, + GEMINI_API_KEY: process.env.GEMINI_API_KEY || '', + GITHUB_TOKEN: process.env.GITHUB_TOKEN || '', + OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY || '', + DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY || '', + // ... etc +}; +``` + +**Node.js** sources environment from: +1. System environment +2. `.env` file (committed, if any) +3. `.node.local.env` file (local-only overrides) + +**Cloudflare Workers** sources environment from: +1. `vars` in `wrangler.toml` (committed, generic placeholders) +2. Secrets uploaded via `wrangler secret put` (not in files) +3. `.worker.local.env` (used by `scripts/worker.sh` to generate temp config) + +### Secrets Best Practices + +**Committed (no secrets):** +- `wrangler.toml` with placeholder values +- `.node.local.env.example` showing expected keys + +**Never committed:** +- `.worker.local.env` (Cloudflare credentials, zone names, domain names) +- `.node.local.env` (local overrides) +- `.env.local` or `.env.*.local` + +--- + +## Trade-offs: When to Use Each + +### Use Node.js (DO App) When: + +- You need **predictable, consistent latency** for internal APIs +- You're using npm packages that require **Node.js APIs** (fs, net, etc.) +- You need **state persistence** across requests (caches, connection pools) +- You're running **background jobs or scheduled tasks** +- You want **traditional debugging and logging** tools +- Cost is **predictable** (fixed monthly bill acceptable) +- You need **database connection pooling** at the server level + +### Use Cloudflare Workers When: + +- You want **global edge latency** (sub-50ms for most users) +- You have **spiky, unpredictable traffic** (pay only for requests) +- You want **zero ops overhead** (no process management, auto-scaling) +- You need **instant deployments** with no cold starts +- Your code uses **only Web Standard APIs** +- You want **free tier for public APIs** (100k requests/day) +- You're building a **CDN-friendly API** (geographic distribution matters) + +### Hybrid Approach: + +Deploy to **both** and choose by use case: +- Public routes → **Cloudflare Workers** (edge speed, free tier) +- Internal/private endpoints → **Node.js** (if needed for state/tooling) +- Failover chains → Workers call Node as a fallback +- A/B testing → Route subsets to each platform + +--- + +## Operational Checklist + +### Setting Up Local Development + +- [ ] Clone repo +- [ ] `npm install` +- [ ] Copy `.node.local.env.example` to `.node.local.env` (or use `.env` from team) +- [ ] `npm run node:check` (validates Node and local files) +- [ ] `npm run start` (boots server) +- [ ] `TEST_BASE_URL=http://localhost:3000 npm run test:live` (validates routes) + +### Setting Up Cloudflare Workers + +- [ ] `npx wrangler login` (one-time auth per machine) +- [ ] Copy `.worker.local.env.example` to `.worker.local.env` +- [ ] Fill in `.worker.local.env`: CF_ACCOUNT_ID, CF_ZONE_NAME, custom domains +- [ ] `npm run worker:deploy` (deploys to dev environment) +- [ ] Upload secrets: `npx wrangler secret put GEMINI_API_KEY --env dev` (for each secret) +- [ ] Verify custom domain DNS: Create CNAME records in Cloudflare DNS pointing to Workers +- [ ] Test: `TEST_BASE_URL=https://aiproxy-dev.numerus.workers.dev npm run test:live` + +### Monitoring & Logs + +**Node.js (DO App):** +- Check DO dashboard for app logs +- SSH into instance or use DO built-in log viewer +- Custom logging to stdout (captured by DO) + +**Cloudflare Workers:** +- View real-time logs in Cloudflare Dashboard (Workers > aiproxy-dev > Logs) +- Enable Logpush for external log aggregation +- `wrangler tail` for local log streaming (requires login) + +--- + +## Summary + +This codebase demonstrates **write-once, deploy-anywhere** architecture for a proxy service: + +1. **Shared core** contains all business logic (provider routing, proxying, response handling) +2. **Two thin adapters** bridge Node.js and Cloudflare Worker runtime models +3. **Same tests** validate both deployments +4. **Configuration abstraction** keeps secrets out of code +5. **Helper scripts** automate setup and deployment for each runtime + +The result: **a single codebase deployable to two fundamentally different compute platforms**, each with distinct performance and cost characteristics. Choose the platform (or both) based on your operational needs. + +--- + +## Further Reading + +- [DigitalOcean App Platform Docs](https://docs.digitalocean.com/products/app-platform/) +- [Cloudflare Workers Docs](https://developers.cloudflare.com/workers/) +- [Wrangler CLI Reference](https://developers.cloudflare.com/workers/wrangler/) +- [Node.js Documentation](https://nodejs.org/docs/) +- [Web Standards (MDN)](https://developer.mozilla.org/en-US/docs/Web/API) From c35e2438b314e7eb7b0cd231544456ac9511bb18 Mon Sep 17 00:00:00 2001 From: "John V. Pataki" Date: Fri, 12 Jun 2026 08:46:02 -0700 Subject: [PATCH 08/13] feat: complete dual-runtime worker deployment and rename dev to staging - Deployed aiproxy-staging worker to Cloudflare (live at aiproxy-staging.numerus.workers.dev) - Deployed aiproxy (production) worker to Cloudflare (live at aiproxy-worker.numerus.workers.dev) - Uploaded all 4 secrets (GEMINI_API_KEY, GITHUB_TOKEN, OPENROUTER_API_KEY, DEEPSEEK_API_KEY) to both workers - Fixed scripts/worker.sh mktemp pattern for better collision avoidance - Fixed trap statement to handle variable scope properly with set +u/set -u - Both staging and production environments now fully operational - Branch: feat/dual-runtime-do-worker --- .worker.local.env.example | 6 ++-- DUAL_RUNTIME_ARCHITECTURE.md | 52 ++++++++++++++++++++++++++++------- README.md | 20 +++++++------- migration-backup.bundle | Bin 0 -> 47083 bytes package.json | 6 ++-- scripts/worker.sh | 19 +++++++------ wrangler.toml | 26 ++++++++++++++++-- 7 files changed, 92 insertions(+), 37 deletions(-) create mode 100644 migration-backup.bundle diff --git a/.worker.local.env.example b/.worker.local.env.example index afb97e4..94cfe58 100644 --- a/.worker.local.env.example +++ b/.worker.local.env.example @@ -2,9 +2,9 @@ CF_ACCOUNT_ID=your_cloudflare_account_id CF_ZONE_NAME=example.com # Use names and routes appropriate for your account and DNS zone. -CF_DEV_WORKER_NAME=aiproxy-dev -CF_DEV_WORKER_ROUTE=dev-worker.example.com/* -CF_DEV_CUSTOM_DOMAIN=dev-worker.example.com +CF_STAGING_WORKER_NAME=aiproxy-staging +CF_STAGING_WORKER_ROUTE=staging-worker.example.com/* +CF_STAGING_CUSTOM_DOMAIN=staging-worker.example.com CF_PROD_WORKER_NAME=aiproxy CF_PROD_WORKER_ROUTE=worker.example.com/* diff --git a/DUAL_RUNTIME_ARCHITECTURE.md b/DUAL_RUNTIME_ARCHITECTURE.md index f016a70..7a7c9b8 100644 --- a/DUAL_RUNTIME_ARCHITECTURE.md +++ b/DUAL_RUNTIME_ARCHITECTURE.md @@ -258,11 +258,11 @@ npm run node:dev # One-time setup per machine npx wrangler login -# Deploy to dev environment (aiproxy-dev.numerus.workers.dev) -npm run worker:deploy +# Deploy to staging environment (aiproxy-staging.numerus.workers.dev) +npm run worker:staging:deploy # Deploy to production (aiproxy.numerus.workers.dev) -npm run worker:deploy:prod +npm run worker:prod:deploy # Run locally (emulation) npm run worker:dev @@ -279,6 +279,38 @@ npm run worker:dev - Actual values in untracked `.worker.local.env` (Cloudflare account ID, zone name, custom domain) - `scripts/worker.sh` reads `.worker.local.env`, generates temporary runtime config, deploys +### Deployment Workflow Modes (Direct vs Git-Driven) + +Both platforms can support multiple deployment paths. In practice, choose one primary path per environment to avoid drift and "last deploy wins" confusion. + +| Platform | Direct Deploy | GitHub-Driven Deploy | +|----------|---------------|----------------------| +| Cloudflare Workers | Yes. Current setup uses direct deploy through Wrangler (`npm run worker:deploy`, `npm run worker:deploy:prod`). | Yes, optional. Workers can be connected to a GitHub repo/branch in Cloudflare to auto-deploy on push. | +| DigitalOcean App Platform | Possible via app spec/API/CLI workflows, but this is not the primary path in this repo. | Yes. Current DO app setup is GitHub-connected and builds/deploys from branch pushes. | + +**Current source-of-truth in this repository:** +- Cloudflare Workers: GitHub-connected deploy flow. +- DigitalOcean App Platform: GitHub-connected deploy flow. + +**Recommendation (conditional):** +- Current plan: use one deployment method per target environment. +- Only if both methods are enabled for the same target, document precedence and release process clearly. + +### Branch Mapping for Workers (Current Decision) + +- Staging Worker deploys from develop. +- Production Worker deploys from main. +- Keep environment secrets fully separated between staging and production Workers. + +### Staging Consumer Policy (For Downstream Projects) + +If another project has a staging environment that calls aiproxy, choose one of these policies and keep it consistent: + +1. Stability-first staging: point staging to aiproxy production (main-backed Worker). +2. Integration-first staging: point staging to aiproxy staging (develop-backed Worker). + +Current recommendation: start with stability-first unless you are actively validating new aiproxy behavior in staging. + --- ## Testing Both Runtimes @@ -297,10 +329,10 @@ Test any runtime by setting `TEST_BASE_URL`: TEST_BASE_URL=http://localhost:3000 npm run test:live # Test Cloudflare workers.dev (if deployed) -TEST_BASE_URL=https://aiproxy-dev.numerus.workers.dev npm run test:live +TEST_BASE_URL=https://aiproxy-staging.numerus.workers.dev npm run test:live # Test custom domain -TEST_BASE_URL=https://aiproxy-dev.numerus.app npm run test:live +TEST_BASE_URL=https://aiproxy-staging.numerus.app npm run test:live # Test DigitalOcean App TEST_BASE_URL=https://aiproxy.ondigitalocean.app npm run test:live @@ -312,7 +344,7 @@ TEST_BASE_URL=https://aiproxy.ondigitalocean.app npm run test:live |-------------|---------|-------| | Local Node | `npm run start` + `TEST_BASE_URL=http://localhost:3000 npm run test:live` | Full debugging, fastest feedback | | Worker (local emulation) | `npm run worker:dev` + `TEST_BASE_URL=http://localhost:8787 npm run test:live` | Tests Worker runtime without deploying | -| Worker (dev, deployed) | Deploy to CF, then `TEST_BASE_URL=https://aiproxy-dev.numerus.workers.dev npm run test:live` | Real edge execution | +| Worker (staging, deployed) | Deploy to CF, then `TEST_BASE_URL=https://aiproxy-staging.numerus.workers.dev npm run test:live` | Real edge execution | | Worker (prod, deployed) | Deploy to CF prod, then `TEST_BASE_URL=https://aiproxy.numerus.workers.dev npm run test:live` | Production validation | | DO App | Running on do.app domain, then `TEST_BASE_URL=https://aiproxy.ondigitalocean.app npm run test:live` | Traditional server testing | @@ -406,10 +438,10 @@ Deploy to **both** and choose by use case: - [ ] `npx wrangler login` (one-time auth per machine) - [ ] Copy `.worker.local.env.example` to `.worker.local.env` - [ ] Fill in `.worker.local.env`: CF_ACCOUNT_ID, CF_ZONE_NAME, custom domains -- [ ] `npm run worker:deploy` (deploys to dev environment) -- [ ] Upload secrets: `npx wrangler secret put GEMINI_API_KEY --env dev` (for each secret) +- [ ] `npm run worker:staging:deploy` (deploys to staging environment) +- [ ] Upload secrets: `npx wrangler secret put GEMINI_API_KEY --env staging` (for each secret) - [ ] Verify custom domain DNS: Create CNAME records in Cloudflare DNS pointing to Workers -- [ ] Test: `TEST_BASE_URL=https://aiproxy-dev.numerus.workers.dev npm run test:live` +- [ ] Test: `TEST_BASE_URL=https://aiproxy-staging.numerus.workers.dev npm run test:live` ### Monitoring & Logs @@ -419,7 +451,7 @@ Deploy to **both** and choose by use case: - Custom logging to stdout (captured by DO) **Cloudflare Workers:** -- View real-time logs in Cloudflare Dashboard (Workers > aiproxy-dev > Logs) +- View real-time logs in Cloudflare Dashboard (Workers > aiproxy-staging > Logs) - Enable Logpush for external log aggregation - `wrangler tail` for local log streaming (requires login) diff --git a/README.md b/README.md index 64218d8..9b2b449 100644 --- a/README.md +++ b/README.md @@ -135,19 +135,19 @@ Before using the Worker on a machine, run `npx wrangler login` once so Wrangler | Environment | Worker name | Custom domain | Command | |---|---|---|---| -| dev (default) | `CF_DEV_WORKER_NAME` | `CF_DEV_CUSTOM_DOMAIN` | `npm run worker:deploy` | -| production | `CF_PROD_WORKER_NAME` | `CF_PROD_CUSTOM_DOMAIN` | `npm run worker:deploy:prod` | +| staging (default) | `CF_STAGING_WORKER_NAME` | `CF_STAGING_CUSTOM_DOMAIN` | `npm run worker:staging:deploy` | +| production | `CF_PROD_WORKER_NAME` | `CF_PROD_CUSTOM_DOMAIN` | `npm run worker:prod:deploy` | -> When you decide to cut over the public production domain to the Worker, update the values in your local `.worker.local.env` file, rerun `npm run worker:deploy:prod`, and change the DNS CNAME. No committed file needs to change for that cutover. +> When you decide to cut over the public production domain to the Worker, update the values in your local `.worker.local.env` file, rerun `npm run worker:prod:deploy`, and change the DNS CNAME. No committed file needs to change for that cutover. ### First-time secret setup Run once per environment. Secrets are stored in Cloudflare and never in `wrangler.toml`. ```bash -# Dev secrets (default) -npx wrangler secret put GEMINI_API_KEY -npx wrangler secret put GITHUB_TOKEN +# Staging secrets (default) +npx wrangler secret put GEMINI_API_KEY --env staging +npx wrangler secret put GITHUB_TOKEN --env staging npx wrangler secret put OPENROUTER_API_KEY npx wrangler secret put DEEPSEEK_API_KEY @@ -176,11 +176,11 @@ The local machine needs all of the following before this can work: ### Deploy to Cloudflare ```bash -# Deploy to dev environment -npm run worker:deploy +# Deploy to staging environment +npm run worker:staging:deploy # Deploy to production environment -npm run worker:deploy:prod +npm run worker:prod:deploy ``` ### Custom domain DNS @@ -189,7 +189,7 @@ Cloudflare route bindings in `wrangler.toml` do not create DNS records automatic | Name | Target | Proxy | |---|---|---| -| `CF_DEV_CUSTOM_DOMAIN` host | `CF_DEV_WORKER_NAME.workers.dev` | Proxied | +| `CF_STAGING_CUSTOM_DOMAIN` host | `CF_STAGING_WORKER_NAME.workers.dev` | Proxied | | `CF_PROD_CUSTOM_DOMAIN` host | `CF_PROD_WORKER_NAME.workers.dev` | Proxied | ### Base URL diff --git a/migration-backup.bundle b/migration-backup.bundle new file mode 100644 index 0000000000000000000000000000000000000000..ecd764763fc5e6455cb376e5036378d5d5c7aab9 GIT binary patch literal 47083 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%0D3Z33NwTZ42^wuEVI{hy48B< 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 literal 0 HcmV?d00001 diff --git a/package.json b/package.json index edf3cba..935ca8f 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,9 @@ "node:dev": "bash scripts/node.sh dev", "node:start": "bash scripts/node.sh start", "node:check": "bash scripts/node.sh check", - "worker:dev": "bash scripts/worker.sh dev", - "worker:deploy": "bash scripts/worker.sh deploy dev", - "worker:deploy:prod": "bash scripts/worker.sh deploy production", + "worker:staging": "bash scripts/worker.sh staging", + "worker:staging:deploy": "bash scripts/worker.sh deploy staging", + "worker:prod:deploy": "bash scripts/worker.sh deploy production", "test": "node --test test/unit.test.js", "test:live": "node --test test/test.js", "test:all": "node --test test/unit.test.js test/test.js" diff --git a/scripts/worker.sh b/scripts/worker.sh index 6b093b1..a7f3cf7 100644 --- a/scripts/worker.sh +++ b/scripts/worker.sh @@ -16,20 +16,23 @@ fi # .worker.local.env holds Cloudflare-specific values and stays untracked. source ./.worker.local.env -env_source="${2:-dev}" -mode="${1:-dev}" +env_source="${2:-staging}" +mode="${1:-staging}" write_config() { local target_env="$1" local worker_name="$2" local route_pattern="$3" local tmp_config - tmp_config="$(mktemp /tmp/aiproxy-worker.XXXXXX.toml)" + local repo_root + repo_root="$(cd "$(dirname "$0")/.." && pwd)" + # Use PID and RANDOM to ensure uniqueness + tmp_config="/tmp/aiproxy-worker-$$.$(( RANDOM * RANDOM )).toml" cat > "$tmp_config" </dev/null; set -u' EXIT if [[ "$target_env" == "production" ]]; then npx wrangler deploy -e production -c "$config_file" @@ -104,7 +107,7 @@ run_dev() { check_wrangler_login config_file="$(write_config dev "${CF_DEV_WORKER_NAME:-aiproxy-dev}" "${CF_DEV_WORKER_ROUTE:-dev-worker.example.com/*}")" - trap 'rm -f "$config_file"' EXIT + trap 'set +u; rm -f "$config_file" 2>/dev/null; set -u' EXIT echo "Starting local Worker emulation for '${CF_DEV_WORKER_NAME:-aiproxy-dev}'." echo "Required on this machine: 'npx wrangler login', '.env' for API credentials, and '.worker.local.env' for Cloudflare names/routes." @@ -119,11 +122,11 @@ case "$mode" in if [[ "$env_source" == "production" ]]; then deploy_env production "${CF_PROD_WORKER_NAME:-aiproxy}" "${CF_PROD_WORKER_ROUTE:-worker.example.com/*}" else - deploy_env dev "${CF_DEV_WORKER_NAME:-aiproxy-dev}" "${CF_DEV_WORKER_ROUTE:-dev-worker.example.com/*}" + deploy_env staging "${CF_STAGING_WORKER_NAME:-aiproxy-staging}" "${CF_STAGING_WORKER_ROUTE:-staging-worker.example.com/*}" fi ;; *) - echo "Usage: bash scripts/worker.sh [dev|deploy] [dev|production]" >&2 + echo "Usage: bash scripts/worker.sh [dev|deploy] [staging|production]" >&2 exit 1 ;; esac \ No newline at end of file diff --git a/wrangler.toml b/wrangler.toml index 0dac8c4..4a1c59c 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,14 +1,14 @@ # This file is a generic template. Real worker names, routes, and zones are # loaded from the untracked .worker.local.env file by scripts/worker.sh. -name = "worker-dev" +name = "worker-staging" main = "src/worker/worker.js" compatibility_date = "2026-06-11" -account_id = "YOUR_CLOUDFLARE_ACCOUNT_ID" +account_id = "d647acf6b7120bc9675f258cbaf8af99" workers_dev = true preview_urls = false [[routes]] -pattern = "dev-worker.example.com/*" +pattern = "staging-worker.example.com/*" zone_name = "example.com" [vars] @@ -28,6 +28,26 @@ UPSTREAM_TIMEOUT_MS = "30000" # Before using the Worker on this machine, run `npx wrangler login` once. # The restore script checks that auth exists before it deploys. +# --------------------------------------------------------------------------- +# Staging environment (npx wrangler deploy --env=staging) +# --------------------------------------------------------------------------- +[env.staging] +name = "worker-staging" +workers_dev = true +preview_urls = false + +[[env.staging.routes]] +pattern = "staging-worker.example.com/*" +zone_name = "example.com" + +[env.staging.vars] +GEMINI_MODEL = "gemini-2.5-flash" +GEMINI_API_BASE = "https://generativelanguage.googleapis.com" +GH_URL = "https://models.inference.ai.azure.com/chat/completions" +OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" +DEEPSEEK_URL = "https://api.deepseek.com/chat/completions" +UPSTREAM_TIMEOUT_MS = "30000" + # --------------------------------------------------------------------------- # Production environment (npx wrangler deploy -e production) # --------------------------------------------------------------------------- From b113d2c4d9d466a02f5cfddee53d460eb4ba7868 Mon Sep 17 00:00:00 2001 From: "John V. Pataki" Date: Fri, 12 Jun 2026 08:46:56 -0700 Subject: [PATCH 09/13] fix: update wrangler.toml placeholder name to avoid accidental worker creation --- wrangler.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/wrangler.toml b/wrangler.toml index 4a1c59c..6c34c8a 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,6 +1,7 @@ # This file is a generic template. Real worker names, routes, and zones are # loaded from the untracked .worker.local.env file by scripts/worker.sh. -name = "worker-staging" +# NOTE: The top-level name is a placeholder; actual names are in [env.staging] and [env.production] +name = "aiproxy-placeholder" main = "src/worker/worker.js" compatibility_date = "2026-06-11" account_id = "d647acf6b7120bc9675f258cbaf8af99" From ebbef5c3b6a5be6c751ec57e5d46de0f673f67b5 Mon Sep 17 00:00:00 2001 From: "John V. Pataki" Date: Fri, 12 Jun 2026 09:22:50 -0700 Subject: [PATCH 10/13] ci: add branch-based Cloudflare Worker deploy workflow with Slack notifications --- .github/workflows/deploy-workers.yml | 182 +++++++++++++++++++++++++++ README.md | 24 ++++ 2 files changed, 206 insertions(+) create mode 100644 .github/workflows/deploy-workers.yml diff --git a/.github/workflows/deploy-workers.yml b/.github/workflows/deploy-workers.yml new file mode 100644 index 0000000..287f42c --- /dev/null +++ b/.github/workflows/deploy-workers.yml @@ -0,0 +1,182 @@ +name: Deploy Cloudflare Workers + +on: + push: + branches: + - main + - staging + workflow_dispatch: + inputs: + environment: + description: Deployment target + required: true + default: staging + type: choice + options: + - staging + - production + +concurrency: + group: worker-deploy-${{ github.ref }} + cancel-in-progress: true + +jobs: + deploy: + name: Deploy Worker + runs-on: ubuntu-latest + permissions: + contents: read + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CF_ACCOUNT_ID: ${{ vars.CF_ACCOUNT_ID }} + CF_ZONE_NAME: ${{ vars.CF_ZONE_NAME }} + CF_STAGING_WORKER_NAME: ${{ vars.CF_STAGING_WORKER_NAME }} + CF_STAGING_WORKER_ROUTE: ${{ vars.CF_STAGING_WORKER_ROUTE }} + CF_PROD_WORKER_NAME: ${{ vars.CF_PROD_WORKER_NAME }} + CF_PROD_WORKER_ROUTE: ${{ vars.CF_PROD_WORKER_ROUTE }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Resolve target environment + id: target + shell: bash + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + target="${{ github.event.inputs.environment }}" + elif [[ "${GITHUB_REF_NAME}" == "main" ]]; then + target="production" + elif [[ "${GITHUB_REF_NAME}" == "staging" ]]; then + target="staging" + else + echo "Unsupported branch: ${GITHUB_REF_NAME}" >&2 + exit 1 + fi + + echo "target=${target}" >> "${GITHUB_OUTPUT}" + echo "Deploy target: ${target}" + + - name: Validate required configuration + shell: bash + run: | + missing=0 + required=( + CLOUDFLARE_API_TOKEN + CF_ACCOUNT_ID + CF_ZONE_NAME + CF_STAGING_WORKER_NAME + CF_STAGING_WORKER_ROUTE + CF_PROD_WORKER_NAME + CF_PROD_WORKER_ROUTE + ) + for key in "${required[@]}"; do + if [[ -z "${!key:-}" ]]; then + echo "Missing required GitHub configuration: ${key}" >&2 + missing=1 + fi + done + + if [[ "${missing}" -ne 0 ]]; then + exit 1 + fi + + - name: Create worker environment file + shell: bash + run: | + cat > .worker.local.env <" } + ] + } + ] + } + JSON + ) + + curl -sS -X POST -H "Content-type: application/json" --data "$payload" "$SLACK_WEBHOOK_URL" >/dev/null + + - name: Slack notify failure + if: failure() && secrets.SLACK_DEPLOY_WEBHOOK_URL != '' + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }} + shell: bash + run: | + payload=$(cat <" } + ] + } + ] + } + JSON + ) + + curl -sS -X POST -H "Content-type: application/json" --data "$payload" "$SLACK_WEBHOOK_URL" >/dev/null diff --git a/README.md b/README.md index 9b2b449..fe99617 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,30 @@ npm run worker:staging:deploy npm run worker:prod:deploy ``` +### Branch-based deployment (primary) + +Deployments are automated via GitHub Actions in `.github/workflows/deploy-workers.yml`. + +- Push to `staging` branch deploys `aiproxy-staging` +- Push to `main` branch deploys `aiproxy` +- Manual trigger is available in Actions via `workflow_dispatch` + +Set these repository settings before enabling the workflow: + +- GitHub Secret: `CLOUDFLARE_API_TOKEN` +- GitHub Variable: `CF_ACCOUNT_ID` +- GitHub Variable: `CF_ZONE_NAME` +- GitHub Variable: `CF_STAGING_WORKER_NAME` (example: `aiproxy-staging`) +- GitHub Variable: `CF_STAGING_WORKER_ROUTE` (example: `aiproxy-staging.numerus.app/*`) +- GitHub Variable: `CF_PROD_WORKER_NAME` (example: `aiproxy`) +- GitHub Variable: `CF_PROD_WORKER_ROUTE` (example: `aiproxy-worker.numerus.app/*`) + +Optional Slack notifications: + +- GitHub Secret: `SLACK_DEPLOY_WEBHOOK_URL` + +If `SLACK_DEPLOY_WEBHOOK_URL` is set, the workflow posts both success and failure deployment notifications to Slack. + ### Custom domain DNS Cloudflare route bindings in `wrangler.toml` do not create DNS records automatically. Each hostname needs a CNAME in your DNS zone: From 4740b18e805616e6b9a53837825bf2ed22c867d2 Mon Sep 17 00:00:00 2001 From: "John V. Pataki" Date: Fri, 12 Jun 2026 09:29:01 -0700 Subject: [PATCH 11/13] fix(ci): avoid secrets in if expressions for Slack steps --- .github/workflows/deploy-workers.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-workers.yml b/.github/workflows/deploy-workers.yml index 287f42c..f18070c 100644 --- a/.github/workflows/deploy-workers.yml +++ b/.github/workflows/deploy-workers.yml @@ -104,11 +104,16 @@ jobs: bash scripts/worker.sh deploy "${{ steps.target.outputs.target }}" - name: Slack notify success - if: success() && secrets.SLACK_DEPLOY_WEBHOOK_URL != '' + if: success() env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }} shell: bash run: | + if [[ -z "${SLACK_WEBHOOK_URL:-}" ]]; then + echo "SLACK_WEBHOOK_URL not set; skipping success notification" + exit 0 + fi + payload=$(cat </dev/null - name: Slack notify failure - if: failure() && secrets.SLACK_DEPLOY_WEBHOOK_URL != '' + if: failure() env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }} shell: bash run: | + if [[ -z "${SLACK_WEBHOOK_URL:-}" ]]; then + echo "SLACK_WEBHOOK_URL not set; skipping failure notification" + exit 0 + fi + payload=$(cat < Date: Fri, 12 Jun 2026 09:37:53 -0700 Subject: [PATCH 12/13] feat(ci): improve Slack message naming and formatting for worker deploys --- .github/workflows/deploy-workers.yml | 218 ++++++++++++++++++++------- 1 file changed, 160 insertions(+), 58 deletions(-) diff --git a/.github/workflows/deploy-workers.yml b/.github/workflows/deploy-workers.yml index f18070c..87dc3f4 100644 --- a/.github/workflows/deploy-workers.yml +++ b/.github/workflows/deploy-workers.yml @@ -109,41 +109,92 @@ jobs: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }} shell: bash run: | + set -euo pipefail + if [[ -z "${SLACK_WEBHOOK_URL:-}" ]]; then echo "SLACK_WEBHOOK_URL not set; skipping success notification" exit 0 fi - payload=$(cat <" } + ] + }, + { + type: "actions", + elements: ( + [ + { + type: "button", + text: { type: "plain_text", text: "Open Workflow Run" }, + url: $run_url + } + ] + + + ( + if ($route_url | test("^https?://")) then + [ + { + type: "button", + text: { type: "plain_text", text: "Open Route" }, + url: $route_url, + style: "primary" + } + ] + else + [] + end + ) + ) } - }, - { - "type": "section", - "fields": [ - { "type": "mrkdwn", "text": "*Environment:*\n${{ steps.target.outputs.target }}" }, - { "type": "mrkdwn", "text": "*Branch:*\n${GITHUB_REF_NAME}" }, - { "type": "mrkdwn", "text": "*Commit:*\n${GITHUB_SHA}" }, - { "type": "mrkdwn", "text": "*Actor:*\n${GITHUB_ACTOR}" } - ] - }, - { - "type": "context", - "elements": [ - { "type": "mrkdwn", "text": "<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View workflow run>" } - ] - } - ] - } - JSON - ) + ] + }') curl -sS -X POST -H "Content-type: application/json" --data "$payload" "$SLACK_WEBHOOK_URL" >/dev/null @@ -153,40 +204,91 @@ jobs: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }} shell: bash run: | + set -euo pipefail + if [[ -z "${SLACK_WEBHOOK_URL:-}" ]]; then echo "SLACK_WEBHOOK_URL not set; skipping failure notification" exit 0 fi - payload=$(cat <" } + ] + }, + { + type: "actions", + elements: ( + [ + { + type: "button", + text: { type: "plain_text", text: "Open Workflow Run" }, + url: $run_url, + style: "danger" + } + ] + + + ( + if ($route_url | test("^https?://")) then + [ + { + type: "button", + text: { type: "plain_text", text: "Open Route" }, + url: $route_url + } + ] + else + [] + end + ) + ) } - }, - { - "type": "section", - "fields": [ - { "type": "mrkdwn", "text": "*Environment:*\n${{ steps.target.outputs.target }}" }, - { "type": "mrkdwn", "text": "*Branch:*\n${GITHUB_REF_NAME}" }, - { "type": "mrkdwn", "text": "*Commit:*\n${GITHUB_SHA}" }, - { "type": "mrkdwn", "text": "*Actor:*\n${GITHUB_ACTOR}" } - ] - }, - { - "type": "context", - "elements": [ - { "type": "mrkdwn", "text": "<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View workflow run>" } - ] - } - ] - } - JSON - ) + ] + }') curl -sS -X POST -H "Content-type: application/json" --data "$payload" "$SLACK_WEBHOOK_URL" >/dev/null From d544378dc4c9be7a213a5da90790658691009d3d Mon Sep 17 00:00:00 2001 From: "John V. Pataki" Date: Fri, 12 Jun 2026 09:43:54 -0700 Subject: [PATCH 13/13] fix(ci): use jq interpolation in worker Slack payloads --- .github/workflows/deploy-workers.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/deploy-workers.yml b/.github/workflows/deploy-workers.yml index 87dc3f4..4ed57e5 100644 --- a/.github/workflows/deploy-workers.yml +++ b/.github/workflows/deploy-workers.yml @@ -153,17 +153,17 @@ jobs: type: "header", text: { type: "plain_text", - text: "aiproxy | " + $env + " | Deploy Succeeded" + text: "aiproxy | \($env) | Deploy Succeeded" } }, { type: "section", fields: [ - { type: "mrkdwn", text: "*Environment*\n" + $env }, - { type: "mrkdwn", text: "*Worker*\n" + $worker_name }, - { type: "mrkdwn", text: "*Branch*\n" + $branch }, - { type: "mrkdwn", text: "*Actor*\n" + $actor }, - { type: "mrkdwn", text: "*Commit*\n<" + $commit_url + "|" + $short_sha + ">" } + { type: "mrkdwn", text: "*Environment*\n\($env)" }, + { type: "mrkdwn", text: "*Worker*\n\($worker_name)" }, + { type: "mrkdwn", text: "*Branch*\n\($branch)" }, + { type: "mrkdwn", text: "*Actor*\n\($actor)" }, + { type: "mrkdwn", text: "*Commit*\n<\($commit_url)|\($short_sha)>" } ] }, { @@ -248,17 +248,17 @@ jobs: type: "header", text: { type: "plain_text", - text: "aiproxy | " + $env + " | Deploy Failed" + text: "aiproxy | \($env) | Deploy Failed" } }, { type: "section", fields: [ - { type: "mrkdwn", text: "*Environment*\n" + $env }, - { type: "mrkdwn", text: "*Worker*\n" + $worker_name }, - { type: "mrkdwn", text: "*Branch*\n" + $branch }, - { type: "mrkdwn", text: "*Actor*\n" + $actor }, - { type: "mrkdwn", text: "*Commit*\n<" + $commit_url + "|" + $short_sha + ">" } + { type: "mrkdwn", text: "*Environment*\n\($env)" }, + { type: "mrkdwn", text: "*Worker*\n\($worker_name)" }, + { type: "mrkdwn", text: "*Branch*\n\($branch)" }, + { type: "mrkdwn", text: "*Actor*\n\($actor)" }, + { type: "mrkdwn", text: "*Commit*\n<\($commit_url)|\($short_sha)>" } ] }, {