@spicyapi/proxy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SpicyAPI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # @spicyapi/proxy
2
+
3
+ Let a browser, mobile or desktop app call SpicyAPI **without ever holding an API key**.
4
+
5
+ A key compiled into a client is a public key. Anyone can decompile the app or watch one request, and
6
+ then spend your balance until it runs out — while your dashboard just shows a busy day. The fix is
7
+ not obfuscation. It is to never put the key there: the app calls **your** server, and your server
8
+ adds the credential.
9
+
10
+ This package is that server-side hop.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install @spicyapi/proxy
16
+ ```
17
+
18
+ Set `SPICY_API_KEY` in the server environment. It is never sent to the client.
19
+
20
+ ## Next.js (App Router)
21
+
22
+ ```ts
23
+ // app/api/spicy/proxy/route.ts
24
+ export { GET, POST, PUT, PATCH, DELETE } from "@spicyapi/proxy/nextjs";
25
+ export const runtime = "nodejs";
26
+ ```
27
+
28
+ `runtime = "nodejs"` is not optional. Edge runtimes do not see server-only environment variables, so
29
+ the proxy would answer 500 in production while working perfectly on your machine.
30
+
31
+ ## Express
32
+
33
+ ```ts
34
+ import { createExpressHandler } from "@spicyapi/proxy/express";
35
+
36
+ app.all("/api/spicy/proxy", createExpressHandler());
37
+ ```
38
+
39
+ Mount it **before** any body parser. `express.json()` consumes the request body, and the proxy would
40
+ then forward an empty one — which surfaces upstream as a missing-parameter error that points nowhere
41
+ near your middleware order.
42
+
43
+ ## Any fetch runtime
44
+
45
+ ```ts
46
+ import { createProxyHandler } from "@spicyapi/proxy";
47
+
48
+ const handle = createProxyHandler();
49
+ // handle(request: Request) => Promise<Response>
50
+ ```
51
+
52
+ ## How a client uses it
53
+
54
+ Point the client at your own route and put the real target in `x-spicy-target-url`:
55
+
56
+ ```ts
57
+ await fetch("/api/spicy/proxy", {
58
+ method: "POST",
59
+ headers: {
60
+ "content-type": "application/json",
61
+ "x-spicy-target-url": "https://api.spicyapi.ai/api/v1/jobs/createTask",
62
+ "idempotency-key": crypto.randomUUID(),
63
+ },
64
+ body: JSON.stringify({ model, input }),
65
+ });
66
+ ```
67
+
68
+ ## What it refuses
69
+
70
+ | Situation | Answer |
71
+ | ------------------------------- | ------ |
72
+ | No `x-spicy-target-url` | `400` |
73
+ | Target origin not allow-listed | `403` |
74
+ | No key configured on the server | `500` |
75
+ | Upstream did not answer in time | `504` |
76
+
77
+ **The allow-list is the whole point.** Forwarding to whatever the header says would mean a stranger
78
+ can make your server hand your key to a host they control — one request, and the traffic looks
79
+ entirely normal because it came from you. The comparison is on the exact origin, never a prefix:
80
+ `https://api.spicyapi.ai.attacker.example` starts with our domain too.
81
+
82
+ Client-supplied `authorization`, `x-api-key` and `cookie` headers are dropped before forwarding, so
83
+ the proxy cannot be turned into an open relay for someone else's credentials.
84
+
85
+ ## Limits
86
+
87
+ This hop authenticates to SpicyAPI. It does **not** authenticate your users — anyone who can reach
88
+ the route can spend your balance. Put your own session check, rate limit and per-user quota in front
89
+ of it, exactly as you would for any endpoint that costs money.
package/SECURITY.md ADDED
@@ -0,0 +1,4 @@
1
+ # Security
2
+
3
+ Report suspected vulnerabilities privately at <https://spicyapi.ai/security>. Never include API
4
+ keys, private media, signed URLs, recovery codes, or customer data in a public issue.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * 服务端代理:让客户端应用在**不持有 API 密钥**的前提下调用 SpicyAPI。
3
+ *
4
+ * 浏览器里的 JS、iOS / Android 应用、桌面应用——这些地方都放不住密钥。打进包里的
5
+ * 密钥等于公开的密钥:攻击者反编译或抓一次包就能拿到,然后一直花到余额见底,而
6
+ * 我们这边看到的只是「这个账号今天用得有点多」。
7
+ *
8
+ * 正确的形状是:客户端把请求打到**调用方自己的服务器**,由那一层补上密钥再转发。
9
+ * 这个模块就是那一层,装在调用方的 Next.js / Express / 任意 fetch 运行时里。
10
+ *
11
+ * ── 协议 ───────────────────────────────────────────────────────────
12
+ *
13
+ * 客户端把目标地址放进 `x-spicy-target-url` 头,打到自己后端约定的那条路由
14
+ * (惯例是 `/api/spicy/proxy`)。代理校验目标、补上 `Authorization`、转发,
15
+ * 再把响应原样回给客户端。
16
+ *
17
+ * ── 为什么目标地址必须白名单,而不是「转发到头里写的任何地址」 ──────
18
+ *
19
+ * 这是整个设计里唯一会造成灾难的地方。不校验的话,任何人往那个头里填
20
+ * `https://attacker.example` 就能让**你的服务器把你的密钥发给他**——一个
21
+ * 请求就泄露,而且流量看起来完全正常:是你自己的服务器发出去的。
22
+ *
23
+ * 所以 `allowedOrigins` 默认只有 `https://api.spicyapi.ai`,而且是**精确的
24
+ * origin 比较**,不是前缀匹配:`https://api.spicyapi.ai.attacker.example`
25
+ * 的前缀也是我们的域名。
26
+ */
27
+ /** 承载目标地址的请求头。全小写,HTTP/2 只接受小写头名。 */
28
+ export declare const TARGET_URL_HEADER = "x-spicy-target-url";
29
+ /** 惯例路由。不是强制的,但保持一致能让前端的配置可以照抄。 */
30
+ export declare const DEFAULT_PROXY_ROUTE = "/api/spicy/proxy";
31
+ export interface ProxyOptions {
32
+ /**
33
+ * 密钥。缺省从 `SPICY_API_KEY` 读。
34
+ *
35
+ * 传函数可以支持轮换:每次请求现取,不必重启进程。
36
+ */
37
+ apiKey?: string | (() => string | undefined);
38
+ /** 允许转发到的 origin。默认只有生产入口;本地联调时才需要放宽。 */
39
+ allowedOrigins?: string[];
40
+ /** 转发超时。默认 120 秒——媒体任务的提交是快的,慢的是之后的轮询。 */
41
+ timeoutMs?: number;
42
+ /** 注入 fetch,用于测试。 */
43
+ fetch?: typeof fetch;
44
+ }
45
+ export declare class ProxyConfigurationError extends Error {
46
+ constructor(message: string);
47
+ }
48
+ /** 代理判定的结果:要么放行并给出该发的请求,要么拒绝并给出该回的状态。 */
49
+ export type ProxyDecision = {
50
+ ok: true;
51
+ request: Request;
52
+ } | {
53
+ ok: false;
54
+ status: number;
55
+ message: string;
56
+ };
57
+ /**
58
+ * 把一条进来的请求判定成「转发什么」或「拒绝」。
59
+ *
60
+ * 这个函数不碰网络,所以各框架适配器共用它,测试也能直接断言判定结果,
61
+ * 不必起一个真的 HTTP 服务。
62
+ */
63
+ export declare function decide(incoming: Request, options?: ProxyOptions): ProxyDecision;
64
+ /**
65
+ * 通用处理器:吃一条 `Request`,回一条 `Response`。
66
+ *
67
+ * 各框架适配器只负责把自己的请求对象转成 `Request`,判定与转发都在这里。
68
+ */
69
+ export declare function createProxyHandler(options?: ProxyOptions): (incoming: Request) => Promise<Response>;
70
+ //# sourceMappingURL=core.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"core.d.ts","sourceRoot":"","sources":["../../src/core.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,qCAAqC;AACrC,eAAO,MAAM,iBAAiB,uBAAuB,CAAC;AAEtD,mCAAmC;AACnC,eAAO,MAAM,mBAAmB,qBAAqB,CAAC;AA8BtD,MAAM,WAAW,YAAY;IAC3B;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,CAAC,MAAM,MAAM,GAAG,SAAS,CAAC,CAAC;IAC7C,yCAAyC;IACzC,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,0CAA0C;IAC1C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,qBAAqB;IACrB,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;CACtB;AAED,qBAAa,uBAAwB,SAAQ,KAAK;gBACpC,OAAO,EAAE,MAAM;CAI5B;AAED,yCAAyC;AACzC,MAAM,MAAM,aAAa,GACvB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAkBlF;;;;;GAKG;AACH,wBAAgB,MAAM,CAAC,QAAQ,EAAE,OAAO,EAAE,OAAO,GAAE,YAAiB,GAAG,aAAa,CAgDnF;AAUD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,GAAE,YAAiB,IAI9B,UAAU,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC,CAmBnE"}
@@ -0,0 +1,164 @@
1
+ /**
2
+ * 服务端代理:让客户端应用在**不持有 API 密钥**的前提下调用 SpicyAPI。
3
+ *
4
+ * 浏览器里的 JS、iOS / Android 应用、桌面应用——这些地方都放不住密钥。打进包里的
5
+ * 密钥等于公开的密钥:攻击者反编译或抓一次包就能拿到,然后一直花到余额见底,而
6
+ * 我们这边看到的只是「这个账号今天用得有点多」。
7
+ *
8
+ * 正确的形状是:客户端把请求打到**调用方自己的服务器**,由那一层补上密钥再转发。
9
+ * 这个模块就是那一层,装在调用方的 Next.js / Express / 任意 fetch 运行时里。
10
+ *
11
+ * ── 协议 ───────────────────────────────────────────────────────────
12
+ *
13
+ * 客户端把目标地址放进 `x-spicy-target-url` 头,打到自己后端约定的那条路由
14
+ * (惯例是 `/api/spicy/proxy`)。代理校验目标、补上 `Authorization`、转发,
15
+ * 再把响应原样回给客户端。
16
+ *
17
+ * ── 为什么目标地址必须白名单,而不是「转发到头里写的任何地址」 ──────
18
+ *
19
+ * 这是整个设计里唯一会造成灾难的地方。不校验的话,任何人往那个头里填
20
+ * `https://attacker.example` 就能让**你的服务器把你的密钥发给他**——一个
21
+ * 请求就泄露,而且流量看起来完全正常:是你自己的服务器发出去的。
22
+ *
23
+ * 所以 `allowedOrigins` 默认只有 `https://api.spicyapi.ai`,而且是**精确的
24
+ * origin 比较**,不是前缀匹配:`https://api.spicyapi.ai.attacker.example`
25
+ * 的前缀也是我们的域名。
26
+ */
27
+ /** 承载目标地址的请求头。全小写,HTTP/2 只接受小写头名。 */
28
+ export const TARGET_URL_HEADER = "x-spicy-target-url";
29
+ /** 惯例路由。不是强制的,但保持一致能让前端的配置可以照抄。 */
30
+ export const DEFAULT_PROXY_ROUTE = "/api/spicy/proxy";
31
+ /** 平台唯一的生产入口。 */
32
+ const DEFAULT_ALLOWED_ORIGIN = "https://api.spicyapi.ai";
33
+ /**
34
+ * 逐跳头:它们描述的是**这一段连接**,不是这条消息,转发出去没有意义且可能有害。
35
+ * 见 RFC 9110 §7.6.1。`host` 另算——它必须按目标重新计算,照抄会把源站打糊涂。
36
+ */
37
+ const HOP_BY_HOP = new Set([
38
+ "connection",
39
+ "keep-alive",
40
+ "proxy-authenticate",
41
+ "proxy-authorization",
42
+ "te",
43
+ "trailer",
44
+ "transfer-encoding",
45
+ "upgrade",
46
+ "host",
47
+ "content-length",
48
+ ]);
49
+ /**
50
+ * 客户端送来的这些头一律**丢弃**,不允许它影响我们替它做的鉴权决定。
51
+ *
52
+ * `authorization` 尤其要紧:不丢的话,客户端可以自带一个头覆盖掉我们补的那个,
53
+ * 把代理变成一个替任意密钥转发的开放中继。
54
+ */
55
+ const CLIENT_CONTROLLED = new Set(["authorization", "cookie", "x-api-key"]);
56
+ export class ProxyConfigurationError extends Error {
57
+ constructor(message) {
58
+ super(message);
59
+ this.name = "ProxyConfigurationError";
60
+ }
61
+ }
62
+ function resolveKey(options) {
63
+ const raw = typeof options.apiKey === "function" ? options.apiKey() : options.apiKey;
64
+ const key = (raw ?? process.env.SPICY_API_KEY)?.trim();
65
+ return key || undefined;
66
+ }
67
+ function allowed(options) {
68
+ const list = options.allowedOrigins ?? [DEFAULT_ALLOWED_ORIGIN];
69
+ if (list.length === 0) {
70
+ throw new ProxyConfigurationError("allowedOrigins must not be empty; an empty list would forward your API key anywhere");
71
+ }
72
+ return list;
73
+ }
74
+ /**
75
+ * 把一条进来的请求判定成「转发什么」或「拒绝」。
76
+ *
77
+ * 这个函数不碰网络,所以各框架适配器共用它,测试也能直接断言判定结果,
78
+ * 不必起一个真的 HTTP 服务。
79
+ */
80
+ export function decide(incoming, options = {}) {
81
+ const target = incoming.headers.get(TARGET_URL_HEADER);
82
+ if (!target) {
83
+ return { ok: false, status: 400, message: `missing ${TARGET_URL_HEADER} header` };
84
+ }
85
+ let url;
86
+ try {
87
+ url = new URL(target);
88
+ }
89
+ catch {
90
+ return { ok: false, status: 400, message: `${TARGET_URL_HEADER} is not a valid absolute URL` };
91
+ }
92
+ // 精确 origin 比较。前缀匹配会把 https://api.spicyapi.ai.attacker.example 放进来。
93
+ if (!allowed(options).includes(url.origin)) {
94
+ return { ok: false, status: 403, message: `target origin is not allowed: ${url.origin}` };
95
+ }
96
+ const key = resolveKey(options);
97
+ if (!key) {
98
+ // 这是部署问题,不是调用方的错,所以不要把它说成 4xx。
99
+ return { ok: false, status: 500, message: "the proxy has no SpicyAPI key configured" };
100
+ }
101
+ const headers = new Headers();
102
+ for (const [name, value] of incoming.headers) {
103
+ const lower = name.toLowerCase();
104
+ if (lower === TARGET_URL_HEADER)
105
+ continue;
106
+ if (HOP_BY_HOP.has(lower))
107
+ continue;
108
+ if (CLIENT_CONTROLLED.has(lower))
109
+ continue;
110
+ headers.set(name, value);
111
+ }
112
+ headers.set("authorization", `Bearer ${key}`);
113
+ return {
114
+ ok: true,
115
+ request: new Request(url, {
116
+ method: incoming.method,
117
+ headers,
118
+ body: incoming.body,
119
+ // 转发的是一条新请求,不该继承调用方的重定向策略。
120
+ redirect: "manual",
121
+ // Node 的 undici 要求带流式 body 时显式声明 duplex。它不在标准的
122
+ // RequestInit 里,所以单独断言这一小块,而不是把整个字面量断言成
123
+ // RequestInit——那样会把上面每个字段的类型检查一起关掉。
124
+ ...(incoming.body ? { duplex: "half" } : {}),
125
+ }),
126
+ };
127
+ }
128
+ /** 回一条拒绝响应。形状与平台的错误信封一致,客户端不必为代理另写一套解析。 */
129
+ function refuse(status, message) {
130
+ return new Response(JSON.stringify({ code: status, msg: message, request_id: null }), {
131
+ status,
132
+ headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
133
+ });
134
+ }
135
+ /**
136
+ * 通用处理器:吃一条 `Request`,回一条 `Response`。
137
+ *
138
+ * 各框架适配器只负责把自己的请求对象转成 `Request`,判定与转发都在这里。
139
+ */
140
+ export function createProxyHandler(options = {}) {
141
+ const doFetch = options.fetch ?? globalThis.fetch;
142
+ const timeoutMs = options.timeoutMs ?? 120_000;
143
+ return async function handle(incoming) {
144
+ const decision = decide(incoming, options);
145
+ if (!decision.ok)
146
+ return refuse(decision.status, decision.message);
147
+ let upstream;
148
+ try {
149
+ upstream = await doFetch(decision.request, { signal: AbortSignal.timeout(timeoutMs) });
150
+ }
151
+ catch {
152
+ // 不把上游的原始错误回给客户端:它可能带着内部主机名。
153
+ return refuse(504, "the upstream request did not complete in time");
154
+ }
155
+ const headers = new Headers();
156
+ for (const [name, value] of upstream.headers) {
157
+ if (HOP_BY_HOP.has(name.toLowerCase()))
158
+ continue;
159
+ headers.set(name, value);
160
+ }
161
+ return new Response(upstream.body, { status: upstream.status, headers });
162
+ };
163
+ }
164
+ //# sourceMappingURL=core.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"core.js","sourceRoot":"","sources":["../../src/core.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Express 适配器。
3
+ *
4
+ * ```ts
5
+ * app.all("/api/spicy/proxy", createExpressHandler());
6
+ * ```
7
+ *
8
+ * Express 的 req/res 是 Node 的流,不是 fetch 的 `Request`/`Response`,
9
+ * 所以这一层做的全部事情就是两边互转,判定与转发仍在 core 里。
10
+ *
11
+ * **不要在这条路由前面挂 body 解析中间件**(`express.json()` 之类):那会把
12
+ * 请求体读干净,转发出去的就是一条空 body 的请求,而错误发生在上游——表现是
13
+ * 「参数缺失」,指不到中间件顺序上。要么把代理挂在解析器之前,要么对这条路径跳过。
14
+ */
15
+ import type { IncomingMessage, ServerResponse } from "node:http";
16
+ import { type ProxyOptions } from "./core.js";
17
+ type ExpressLike = IncomingMessage & {
18
+ originalUrl?: string;
19
+ body?: unknown;
20
+ };
21
+ export declare function createExpressHandler(options?: ProxyOptions): (req: ExpressLike, res: ServerResponse, next?: (error?: unknown) => void) => Promise<void>;
22
+ export {};
23
+ //# sourceMappingURL=express.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"express.d.ts","sourceRoot":"","sources":["../../src/express.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAGjE,OAAO,EAAsB,KAAK,YAAY,EAAE,MAAM,WAAW,CAAC;AAElE,KAAK,WAAW,GAAG,eAAe,GAAG;IAAE,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAE9E,wBAAgB,oBAAoB,CAAC,OAAO,GAAE,YAAiB,IAI3D,KAAK,WAAW,EAChB,KAAK,cAAc,EACnB,OAAO,CAAC,KAAK,CAAC,EAAE,OAAO,KAAK,IAAI,KAC/B,OAAO,CAAC,IAAI,CAAC,CAmCjB"}
@@ -0,0 +1,39 @@
1
+ import { Readable } from "node:stream";
2
+ import { createProxyHandler } from "./core.js";
3
+ export function createExpressHandler(options = {}) {
4
+ const handle = createProxyHandler(options);
5
+ return async function middleware(req, res, next) {
6
+ try {
7
+ const headers = new Headers();
8
+ for (const [name, value] of Object.entries(req.headers)) {
9
+ if (value === undefined)
10
+ continue;
11
+ headers.set(name, Array.isArray(value) ? value.join(", ") : value);
12
+ }
13
+ const hasBody = req.method !== "GET" && req.method !== "HEAD";
14
+ const response = await handle(new Request(`http://proxy.invalid${req.originalUrl ?? req.url ?? "/"}`, {
15
+ method: req.method,
16
+ headers,
17
+ ...(hasBody ? { body: Readable.toWeb(req), duplex: "half" } : {}),
18
+ }));
19
+ res.statusCode = response.status;
20
+ response.headers.forEach((value, name) => res.setHeader(name, value));
21
+ if (response.body) {
22
+ const nodeStream = Readable.fromWeb(response.body);
23
+ nodeStream.pipe(res);
24
+ }
25
+ else {
26
+ res.end();
27
+ }
28
+ }
29
+ catch (error) {
30
+ if (next)
31
+ next(error);
32
+ else {
33
+ res.statusCode = 500;
34
+ res.end();
35
+ }
36
+ }
37
+ };
38
+ }
39
+ //# sourceMappingURL=express.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"express.js","sourceRoot":"","sources":["../../src/express.ts"],"names":[],"mappings":"AAeA,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAEvC,OAAO,EAAE,kBAAkB,EAAqB,MAAM,WAAW,CAAC;AAIlE,MAAM,UAAU,oBAAoB,CAAC,UAAwB,EAAE;IAC7D,MAAM,MAAM,GAAG,kBAAkB,CAAC,OAAO,CAAC,CAAC;IAE3C,OAAO,KAAK,UAAU,UAAU,CAC9B,GAAgB,EAChB,GAAmB,EACnB,IAAgC;QAEhC,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,IAAI,OAAO,EAAE,CAAC;YAC9B,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC;gBACxD,IAAI,KAAK,KAAK,SAAS;oBAAE,SAAS;gBAClC,OAAO,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;YACrE,CAAC;YAED,MAAM,OAAO,GAAG,GAAG,CAAC,MAAM,KAAK,KAAK,IAAI,GAAG,CAAC,MAAM,KAAK,MAAM,CAAC;YAC9D,MAAM,QAAQ,GAAG,MAAM,MAAM,CAC3B,IAAI,OAAO,CAAC,uBAAuB,GAAG,CAAC,WAAW,IAAI,GAAG,CAAC,GAAG,IAAI,GAAG,EAAE,EAAE;gBACtE,MAAM,EAAE,GAAG,CAAC,MAAM;gBAClB,OAAO;gBACP,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAmB,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;aACrE,CAAC,CAClB,CAAC;YAEF,GAAG,CAAC,UAAU,GAAG,QAAQ,CAAC,MAAM,CAAC;YACjC,QAAQ,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC,CAAC;YACtE,IAAI,QAAQ,CAAC,IAAI,EAAE,CAAC;gBAClB,MAAM,UAAU,GAAG,QAAQ,CAAC,OAAO,CACjC,QAAQ,CAAC,IAA8C,CACxD,CAAC;gBACF,UAAU,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YACvB,CAAC;iBAAM,CAAC;gBACN,GAAG,CAAC,GAAG,EAAE,CAAC;YACZ,CAAC;QACH,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,IAAI;gBAAE,IAAI,CAAC,KAAK,CAAC,CAAC;iBACjB,CAAC;gBACJ,GAAG,CAAC,UAAU,GAAG,GAAG,CAAC;gBACrB,GAAG,CAAC,GAAG,EAAE,CAAC;YACZ,CAAC;QACH,CAAC;IACH,CAAC,CAAC;AACJ,CAAC"}
@@ -0,0 +1,2 @@
1
+ export { createProxyHandler, decide, ProxyConfigurationError, DEFAULT_PROXY_ROUTE, TARGET_URL_HEADER, type ProxyDecision, type ProxyOptions, } from "./core.js";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kBAAkB,EAClB,MAAM,EACN,uBAAuB,EACvB,mBAAmB,EACnB,iBAAiB,EACjB,KAAK,aAAa,EAClB,KAAK,YAAY,GAClB,MAAM,WAAW,CAAC"}
@@ -0,0 +1,2 @@
1
+ export { createProxyHandler, decide, ProxyConfigurationError, DEFAULT_PROXY_ROUTE, TARGET_URL_HEADER, } from "./core.js";
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kBAAkB,EAClB,MAAM,EACN,uBAAuB,EACvB,mBAAmB,EACnB,iBAAiB,GAGlB,MAAM,WAAW,CAAC"}
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Next.js 适配器。
3
+ *
4
+ * App Router:把 `route` 原样导出成路由的方法处理器即可。
5
+ *
6
+ * ```ts
7
+ * // app/api/spicy/proxy/route.ts
8
+ * export const { GET, POST, PUT, DELETE } = route;
9
+ * export const runtime = "nodejs"; // 见下
10
+ * ```
11
+ *
12
+ * **`runtime` 要显式写 `nodejs`**:边缘运行时读不到 `process.env` 里那些只在
13
+ * 服务端配置的值,而密钥恰恰住在那里。默认跑在边缘的项目会在部署之后才发现
14
+ * 这一点——那时表现是代理回 500「没有配置密钥」,而本地一切正常。
15
+ */
16
+ import { type ProxyOptions } from "./core.js";
17
+ /** App Router:`export const { POST } = route` 就能用。 */
18
+ export declare function createRoute(options?: ProxyOptions): {
19
+ GET: (incoming: Request) => Promise<Response>;
20
+ POST: (incoming: Request) => Promise<Response>;
21
+ PUT: (incoming: Request) => Promise<Response>;
22
+ PATCH: (incoming: Request) => Promise<Response>;
23
+ DELETE: (incoming: Request) => Promise<Response>;
24
+ };
25
+ /** 默认配置的 App Router 路由,密钥取自 `SPICY_API_KEY`。 */
26
+ export declare const route: {
27
+ GET: (incoming: Request) => Promise<Response>;
28
+ POST: (incoming: Request) => Promise<Response>;
29
+ PUT: (incoming: Request) => Promise<Response>;
30
+ PATCH: (incoming: Request) => Promise<Response>;
31
+ DELETE: (incoming: Request) => Promise<Response>;
32
+ };
33
+ //# sourceMappingURL=nextjs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"nextjs.d.ts","sourceRoot":"","sources":["../../src/nextjs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAsB,KAAK,YAAY,EAAE,MAAM,WAAW,CAAC;AAElE,sDAAsD;AACtD,wBAAgB,WAAW,CAAC,OAAO,GAAE,YAAiB;;;;;;EAGrD;AAED,gDAAgD;AAChD,eAAO,MAAM,KAAK;;;;;;CAAgB,CAAC"}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Next.js 适配器。
3
+ *
4
+ * App Router:把 `route` 原样导出成路由的方法处理器即可。
5
+ *
6
+ * ```ts
7
+ * // app/api/spicy/proxy/route.ts
8
+ * export const { GET, POST, PUT, DELETE } = route;
9
+ * export const runtime = "nodejs"; // 见下
10
+ * ```
11
+ *
12
+ * **`runtime` 要显式写 `nodejs`**:边缘运行时读不到 `process.env` 里那些只在
13
+ * 服务端配置的值,而密钥恰恰住在那里。默认跑在边缘的项目会在部署之后才发现
14
+ * 这一点——那时表现是代理回 500「没有配置密钥」,而本地一切正常。
15
+ */
16
+ import { createProxyHandler } from "./core.js";
17
+ /** App Router:`export const { POST } = route` 就能用。 */
18
+ export function createRoute(options = {}) {
19
+ const handle = createProxyHandler(options);
20
+ return { GET: handle, POST: handle, PUT: handle, PATCH: handle, DELETE: handle };
21
+ }
22
+ /** 默认配置的 App Router 路由,密钥取自 `SPICY_API_KEY`。 */
23
+ export const route = createRoute();
24
+ //# sourceMappingURL=nextjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"nextjs.js","sourceRoot":"","sources":["../../src/nextjs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAE,kBAAkB,EAAqB,MAAM,WAAW,CAAC;AAElE,sDAAsD;AACtD,MAAM,UAAU,WAAW,CAAC,UAAwB,EAAE;IACpD,MAAM,MAAM,GAAG,kBAAkB,CAAC,OAAO,CAAC,CAAC;IAC3C,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC;AACnF,CAAC;AAED,gDAAgD;AAChD,MAAM,CAAC,MAAM,KAAK,GAAG,WAAW,EAAE,CAAC"}
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@spicyapi/proxy",
3
+ "version": "0.1.0",
4
+ "description": "Server-side proxy for SpicyAPI: let browser, mobile and desktop apps call the API without ever holding an API key.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/SpicyAPI/spicy-devkit.git",
10
+ "directory": "packages/proxy"
11
+ },
12
+ "homepage": "https://spicyapi.ai",
13
+ "bugs": {
14
+ "url": "https://spicyapi.ai/contact"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "engines": {
20
+ "node": ">=22.13.0"
21
+ },
22
+ "main": "./dist/src/index.js",
23
+ "types": "./dist/src/index.d.ts",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/src/index.d.ts",
27
+ "import": "./dist/src/index.js"
28
+ },
29
+ "./nextjs": {
30
+ "types": "./dist/src/nextjs.d.ts",
31
+ "import": "./dist/src/nextjs.js"
32
+ },
33
+ "./express": {
34
+ "types": "./dist/src/express.d.ts",
35
+ "import": "./dist/src/express.js"
36
+ }
37
+ },
38
+ "files": [
39
+ "dist/src",
40
+ "README.md",
41
+ "SECURITY.md",
42
+ "LICENSE"
43
+ ],
44
+ "scripts": {
45
+ "build": "tsc -p tsconfig.json",
46
+ "typecheck": "tsc -p tsconfig.json --noEmit"
47
+ }
48
+ }