☰
OpenSpace 实战:用 Serverless API 代理端点保护密钥,构建前端统一后端(api-proxy-endpoint 模式)
2026/10/9 7:40:00 网站建设 项目流程
  • 人工智能
  • AI 技能
  • MCP 服务
  • AI 评测

【免费下载链接】OpenSpace

"OpenSpace: The Skill Management Layer for AI Agents" -- https://open-space.cloud/

项目地址:https://gitcode.com/gh_mirrors/opens/OpenSpace
点击查看免费下载

导读:本文以 OpenSpace 仓库示例项目examples/my-daily-monitor中的api-proxy-endpoint技能文档为核心,讲解如何在服务端隐藏 API Key、为前端提供统一/api/*命名空间的 Serverless 代理端点模式。你将掌握 CORS 助手封装、Stock/News 两个完整可运行的代理端点、7 条关键实现模式,以及本地开发(Vite 代理 / 嵌入式插件 / 独立 Node 服务)三种联调方案,并看到该模式在仓库源码中的真实落地与测试验证。

外部 API(如股票行情 Finnhub、新闻聚合 GNews)通常要求携带 API Key 才能访问,而把密钥直接写进前端代码意味着任何打开浏览器的人都能通过 DevTools 提取并滥用它。更普遍的问题是:前端业务需要的数据形态往往与上游 API 的原始响应不一致,分散在多个域名下的调用也难以统一管理。

api-proxy-endpoint技能提供的解决方案是在服务端增加一层薄薄的 Serverless 代理端点(proxy endpoint),让前端只与自己的/api/*命名空间对话。该技能定义于 examples/my-daily-monitor/skills/api-proxy-endpoint/SKILL.md,是 OpenSpace 的 my-daily-monitor(个人每日监控面板)示例应用的核心后端模式之一,其核心目标有三条:

  • 在服务端隐藏 API Key,密钥永远不进入前端代码与浏览器;
  • 为前端提供统一的/api/*命名空间,屏蔽上游域名差异;
  • 统一处理 CORS、限流(缓存降频)与错误包装(error wrapping),让前端拿到稳定的 JSON 结构。

端点结构:一个 API 领域一个文件

每个代理端点就是一个位于api/目录下的文件,按业务领域拆分,而不是按“实现细节”拆分。技能文档给出如下目录骨架:

api/ ├── stocks.ts # Stock market data proxy ├── news.ts # News API proxy ├── calendar.ts # Calendar events proxy └── _cors.ts # Shared CORS helper

_前缀用于标记“共享辅助模块”(underscore-prefixed helper),即不直接对外暴露路由、仅供同目录端点复用的文件。这一“一个领域一个文件 + 共享助手”的组织方式,在示例项目中得到了忠实落地——只是目录改叫server/routes/:

技能文档的端点仓库实际实现(server/routes/index.ts 中的注册表)
stocks.ts股票行情代理handleStockRequest→/api/stocks(stock.ts)
news.ts新闻代理handleNewsRequest→/api/news(news.ts)
calendar.ts日历事件代理handleCalendarRequest→/api/calendar
其余同款模式/api/github、/api/emails、/api/feishu、/api/social、/api/system、/api/office、/api/health

所有处理器被收敛进一个RouteHandler类型签名——(query, body, headers) => Promise<unknown>(server/routes/index.ts),这意味着无论端点内部是调用 Finnhub、GNews 还是 RSS 聚合,对外都表现为“输入解析后的请求参数、输出可 JSON 序列化的统一结构”。配套测试 server/routes/index.test.ts 逐一断言 10 条路由均已注册且顺序固定,防止路由表被意外破坏。

CORS 助手:开发期跨域的第一道防线

前端在开发期运行在localhost:5173,而代理服务跑在另一个端口/域名上,属于典型的“不同 origin”。技能文档建议把 CORS 逻辑抽成一个共享助手_cors.ts,避免在每个端点里重复堆 Header:

// api/_cors.ts export function corsHeaders() { return { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', }; } export function handleCors(req: Request): Response | null { if (req.method === 'OPTIONS') { return new Response(null, { status: 204, headers: corsHeaders() }); } return null; }

几个值得注意的细节:

  • 预检(preflight)响应必须是 204 + 空 body,浏览器才认为预检通过;实际业务请求的 CORS 头由各端点在res.setHeader中设置。
  • Access-Control-Allow-Headers要覆盖真实请求可能携带的自定义头。示例项目的 Node 代理服务把Content-Type之外的一整套密钥传递头都声明进去(server/index.ts):X-API-Key, X-Finnhub-Key, X-Github-Token, X-Feishu-App-Id, X-Feishu-App-Secret, X-Twitter-Token, X-Gmail-Token, X-OpenRouter-Key——这说明“密钥必须留在服务端”并非绝对教条:某些场景允许前端把用户自备的密钥放在请求头中传给代理,再由代理转发给上游,密钥依然不会进入前端代码的可执行逻辑。

示例端点一:股票行情代理(api/stocks.ts)

技能文档给出一个基于 Vercel Node 函数的股票代理:

// api/stocks.ts import type { VercelRequest, VercelResponse } from '@vercel/node'; export default async function handler(req: VercelRequest, res: VercelResponse) { // CORS res.setHeader('Access-Control-Allow-Origin', '*'); if (req.method === 'OPTIONS') return res.status(204).end(); const symbols = (req.query.symbols as string || '').split(',').filter(Boolean); if (symbols.length === 0) { return res.status(400).json({ error: 'Missing symbols parameter' }); } const apiKey = process.env.FINNHUB_API_KEY; if (!apiKey) { return res.status(500).json({ error: 'API key not configured' }); } try { const quotes = await Promise.all( symbols.map(async (sym) => { const resp = await fetch( `https://finnhub.io/api/v1/quote?symbol=${sym}&token=${apiKey}` ); if (!resp.ok) throw new Error(`Finnhub ${resp.status}`); const data = await resp.json(); return { symbol: sym, price: data.c, // current price change: data.dp, // percent change high: data.h, low: data.l, open: data.o, prevClose: data.pc, }; }) ); res.setHeader('Cache-Control', 's-maxage=30, stale-while-revalidate=60'); return res.json({ quotes }); } catch (err) { console.error('Stock API error:', err); return res.status(502).json({ error: 'Upstream API failed' }); } }

这个端点已经示范了全部核心防护措施:

  1. 密钥来自环境变量process.env.FINNHUB_API_KEY,缺失时返回 500 而不是悄悄降级;
  2. 输入验证先行——symbols为空直接 400,绝不带着空参去打扰上游;
  3. 批量并发——用Promise.all并行拉取多个 symbol,把上游请求数收敛到 1 个入站请求;
  4. 字段裁剪——只把c/dp/h/l/o/pc六个字段透传给前端,前端拿到的是稳定的自有结构;
  5. CDN 边缘缓存——Cache-Control: s-maxage=30, stale-while-revalidate=60让 Vercel CDN 缓存 30 秒、过期后允许返回旧数据的同时在后台刷新(stale-while-revalidate);
  6. 错误包装——上游失败统一包装成502 { error: 'Upstream API failed' },绝不把 Finnhub 的原始错误体透传给浏览器。

仓库落地:Finnhub + Yahoo Finance 双通道与内存缓存

示例项目把该模式进一步工程化。handleStockRequest(server/routes/stock.ts)做了三点增强:

  • 双上游回退:普通个股走 Finnhub;指数、期货、加密货币(^GSPC、^DJI、^VIX、GC=F、BTC-USD等,见YAHOO_ONLY集合)走 Yahoo Finance 免密钥图表接口;若完全没配置 Finnhub Key,则全部 symbol 回退到 Yahoo——即“无密钥也能跑通大盘指数”,这正是finnhubAvailable字段的用途;
  • 8 分钟内存缓存:Map<string, { data, ts }>+CACHE_TTL = 8 * 60_000,按排序后的 symbol 列表做缓存键,命中直接返回,大幅降低对上游的限流压力(这是技能文档“限流”目标的落地方式);
  • 容错并发:Promise.allSettled保证个别 symbol 失败不影响其他结果,失败的条目被静默跳过而不是让整个请求 502。

前端消费侧(src/services/stock-market.ts)还叠加了 30 秒的客户端熔断器(circuit breaker)与密钥传递:X-Finnhub-Key请求头把用户在设置面板里填的密钥透传给代理,代理优先取headers['x-finnhub-key']、其次才是process.env.FINNHUB_API_KEY(stock.ts)。

示例端点二:新闻聚合代理(api/news.ts)

技能文档给出的新闻端点支持两种模式——关键词搜索与分类头条:

// api/news.ts import type { VercelRequest, VercelResponse } from '@vercel/node'; export default async function handler(req: VercelRequest, res: VercelResponse) { res.setHeader('Access-Control-Allow-Origin', '*'); if (req.method === 'OPTIONS') return res.status(204).end(); const apiKey = process.env.NEWS_API_KEY; if (!apiKey) return res.status(500).json({ error: 'API key not configured' }); const query = req.query.q as string || ''; const category = req.query.category as string || 'general'; const lang = req.query.lang as string || 'en'; try { const url = query ? `https://gnews.io/api/v4/search?q=${encodeURIComponent(query)}&lang=${lang}&max=20&token=${apiKey}` : `https://gnews.io/api/v4/top-headlines?category=${category}&lang=${lang}&max=20&token=${apiKey}`; const resp = await fetch(url); if (!resp.ok) throw new Error(`GNews ${resp.status}`); const data = await resp.json(); res.setHeader('Cache-Control', 's-maxage=120, stale-while-revalidate=300'); return res.json({ articles: (data.articles || []).map((a: any) => ({ title: a.title, description: a.description, url: a.url, source: a.source?.name || '', publishedAt: a.publishedAt, image: a.image, })), }); } catch (err) { console.error('News API error:', err); return res.status(502).json({ error: 'Upstream API failed' }); } }

要点:同样的错误包装与缓存策略(120 秒边缘缓存 + 300 秒后台刷新);encodeURIComponent对查询词做编码,防止注入与乱码;响应裁剪把上游的完整 article 对象压成前端需要的 6 个字段。

仓库落地:免密钥的 RSS 聚合 + 威胁分级

示例项目的新闻端点走了一条更“去依赖”的路线:不依赖 GNews 付费密钥,而是聚合 27 个免费 RSS 源(Hacker News、BBC、Reuters、CNBC、Nature、The Verge 等,见 news.ts 的ALL_FEEDS配置表),每个源带name / url / category三元组,分类覆盖tech / finance / world / ai / china / science / us / europe。它同时演示了代理端点承担“业务逻辑”的边界:

  • RSS + Atom 双格式解析:正则提取<item>与<entry>,并逐级尝试 5 种图片提取策略(media:content→media:thumbnail→enclosure→<image><url>→<img src>,见extractImage);
  • 威胁分级:基于标题关键词对每条新闻打critical / high / medium / low / info标签(THREAT_KEYWORDS表),这是把上游“原始数据”加工成“前端可直接渲染的语义数据”的典型例子;
  • 去重与排序:sha256 标题哈希去重、按publishedAt倒序、截断到 50 条;
  • 5 分钟内存缓存 + 8 秒 AbortController 超时:缓存键为categories:sources,避免同名新闻源重复请求,超时源静默跳过;
  • 元数据接口:?action=sources返回可用分类与源清单,供前端动态渲染筛选器——同一端点承担“数据代理”与“元数据查询”两种职责。

前端侧(src/services/news.ts)用 5 分钟客户端熔断器包裹/api/news调用,并刻意一次请求全部分类以复用服务端缓存。

七条关键模式:从技能文档到可复用的 checklist

技能文档把上述两个示例抽象为七条可迁移的通用模式,这也是 Agent 在编写新端点时应逐条对照的验收清单:

  1. 一个 API 领域一个文件——api/目录内按领域(stocks、news、calendar…)组织,路由表即目录结构的映射;
  2. 永远设置 CORS 头——开发期前端与代理不同 origin,漏掉 CORS 头前端必然报跨域错误;
  3. 密钥放环境变量——process.env.FINNHUB_API_KEY这类引用,绝不硬编码、绝不进前端 bundle;
  4. Cache-Control 边缘缓存——s-maxage+stale-while-revalidate组合在 Serverless/CDN 场景下兼顾时效与命中率;
  5. 错误包装——返回结构化 JSON 错误({ error: '...' }配合 400/500/502 状态码),绝不把上游原始错误透传;
  6. 输入验证——调用上游前先校验 query 参数(空参、非法格式直接拒绝);
  7. 类型化响应——保持响应 shape 稳定,前端只消费自有字段结构,上游字段变化被隔离在端点内部。

本地开发:三种把/api接到本机的方式

代理端点写好后,开发期需要让localhost:5173的前端能访问到它。技能文档给出两种方式,仓库又提供了第三种更“零配置”的玩法。

方式一:Vite dev server 代理

在vite.config.ts中把/api前缀转发到本地代理服务端口:

export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true, }, }, }, });

changeOrigin: true会把请求的Host头改写成目标地址,避免目标服务因 Host 不匹配而拒绝。

方式二:vercel dev本地跑 Serverless

技能文档同时指出可以使用vercel dev在本地以 Serverless 方式运行这些函数端点,模拟生产环境的行为(含环境变量注入、边缘缓存语义),适合上线前的验证。

方式三:嵌入式 API 插件(仓库特有)

示例项目用vite-api-plugin.ts实现了一种“连代理进程都不需要”的开发体验:一个自定义 Vite 插件在configureServer钩子中把server/routes/index.ts的路由表直接挂进 Vite 的中间件栈(vite-api-plugin.ts)。这样npm run dev一个进程同时服务前端与全部/api/*,且天然同源、无跨域问题。其vite.config.ts只做了三件事:注册apiPlugin()、配置@别名、固定 5173 端口(vite.config.ts)。

生产环境则按 server/index.ts 的注释说明走两条路:部署为 Serverless 函数(对应技能文档的 Vercel 场景),或作为独立 Node 服务运行(npm run dev:api即tsx watch server/index.ts,见 package.json)。该服务是一个零依赖的node:http服务器:统一设置 CORS 头、处理 OPTIONS 预检、按apiRoutes[pathname]查表分发、POST 读取 body、统一 try/catch 包装错误(server/index.ts),与技能文档描述的 Serverless 端点在行为上完全等价。

总结

api-proxy-endpoint模式回答了一个反复出现的问题:外部 API 的密钥、限流、错误格式与字段结构都不该由前端直接面对。通过“一个领域一个文件”的端点组织、共享 CORS 助手、环境变量保管密钥、CDN 边缘缓存、结构化错误包装与输入验证,前端只认识自己的/api/*命名空间和稳定的 JSON 契约。该模式已完整落地于 examples/my-daily-monitor/server/routes 的 10 条路由中,并有 index.test.ts 的注册表测试与 stock.ts、news.ts 的双上游回退、缓存、威胁分级等工程化增强作为佐证。无论是部署到 Vercel 的 Serverless 函数,还是作为独立 Node 服务、嵌入式 Vite 插件,这一模式都能让“隐藏密钥 + 统一后端”的目标在几十分钟内落地。

  • 人工智能
  • AI 技能
  • MCP 服务
  • AI 评测

【免费下载链接】OpenSpace

"OpenSpace: The Skill Management Layer for AI Agents" -- https://open-space.cloud/

项目地址:https://gitcode.com/gh_mirrors/opens/OpenSpace
点击查看免费下载
上一篇:日本视角下的 DeepSeek:技术实力、安全争议与政府态度的全景分析
下一篇:Axure 中文语言包安装教程:4 步把界面从纯英文换成纯中文(axure-cn,支持 9/10/11)

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询