@xiao-ying/miniapp-proxy 1.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,35 @@
1
+ Shanghai Plum Technology Ltd. Software License Agreement
2
+
3
+ This License Agreement ("Agreement") is a legal agreement between you (either an individual or a single entity, hereafter "Licensee") and Shanghai Plum Technology Ltd. ("Plum") for the software product accompanying this Agreement, which includes computer software and may include associated media, printed materials, and "online" or electronic documentation ("Software").
4
+
5
+ BY INSTALLING, COPYING, OR OTHERWISE USING THE SOFTWARE, YOU AGREE TO BE BOUND BY THE TERMS OF THIS AGREEMENT. IF YOU DO NOT AGREE TO THE TERMS OF THIS AGREEMENT, DO NOT INSTALL OR USE THE SOFTWARE.
6
+
7
+ 1. Grant of License. Plum grants Licensee a non-exclusive, non-transferable, limited license to use the Software solely for Licensee's internal business purposes, subject to the terms and conditions of this Agreement. THIS LICENSE IS GRANTED ONLY TO ENTITIES OR INDIVIDUALS WHO HAVE OBTAINED AN OFFICIAL SERVICE AGREEMENT OR WRITTEN AUTHORIZATION FROM PLUM.
8
+
9
+ 2. Ownership. The Software is owned and copyrighted by Plum or its suppliers. Your license confers no title or ownership in the Software and is not a sale of any rights in the Software. All rights not expressly granted to you are reserved by Plum.
10
+
11
+ 3. Restrictions. You may not:
12
+ * Reverse engineer, decompile, or disassemble the Software, except and only to the extent that such activity is expressly permitted by applicable law notwithstanding this limitation.
13
+ * Modify, translate, or create derivative works based on the Software.
14
+ * Rent, lease, lend, or provide commercial hosting services with the Software.
15
+ * Transfer the Software or this license to any third party.
16
+ * Remove or alter any copyright notices or other proprietary markings on or in the Software.
17
+ * Use the software in any way that violates applicable laws or regulations.
18
+
19
+ 4. Termination. Without prejudice to any other rights, Plum may terminate this Agreement if you fail to comply with any of the terms and conditions of this Agreement. In such event, you must destroy all copies of the Software and all of its component parts.
20
+
21
+ 5. Limited Warranty. Plum warrants that the media on which the Software is distributed (if any) will be free from defects in materials and workmanship for a period of thirty (30) days from the date of delivery. Plum's entire liability and your exclusive remedy for any breach of this limited warranty shall be, at Plum's option, either (a) return of the price paid, or (b) replacement of the defective media.
22
+
23
+ 6. Disclaimer of Warranties. EXCEPT AS PROVIDED ABOVE, THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. PLUM DISCLAIMS ALL OTHER WARRANTIES, EXPRESS OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
24
+
25
+ 7. Limitation of Liability. IN NO EVENT SHALL PLUM BE LIABLE FOR ANY DAMAGES WHATSOEVER (INCLUDING, WITHOUT LIMITATION, DAMAGES FOR LOSS OF BUSINESS PROFIT, BUSINESS INTERRUPTION, LOSS OF BUSINESS INFORMATION, OR ANY OTHER PECUNIARY LOSS) ARISING OUT OF THE USE OF OR INABILITY TO USE THE SOFTWARE, EVEN IF PLUM HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
26
+
27
+ 8. Governing Law. This Agreement shall be governed by and construed in accordance with the laws of the People's Republic of China, without regard to its conflict of law principles.
28
+
29
+ 9. Entire Agreement. This Agreement constitutes the entire agreement between the parties with respect to the subject matter hereof and supersedes all prior or contemporaneous communications and proposals, whether oral or written.
30
+
31
+ 10. Severability. If any provision of this Agreement is held to be invalid or unenforceable, the remaining provisions of this Agreement will remain in full force and effect.
32
+
33
+ Shanghai Plum Technology Ltd.
34
+ Address: Shanghai, China.
35
+ Email: liplum@liplum.net
package/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # @xiao-ying/miniapp-proxy
2
+
3
+ 独立的动态代理服务与 Vite 插件,可在开发时把浏览器请求转发到任意 http/https 或 ws/wss 目标。
4
+
5
+ ## 开发态跨域与 dynamicProxyPlugin(快速使用)
6
+
7
+ 1) 安装(仅 dev):
8
+
9
+ ```bash
10
+ pnpm add -D @xiao-ying/miniapp-proxy
11
+ ```
12
+
13
+ 2) 在 `vite.config.ts` 启用(建议用 `loadEnv` 传入 token 等):
14
+
15
+ ```ts
16
+ import { defineConfig, loadEnv } from 'vite'
17
+ import react from '@vitejs/plugin-react'
18
+ import { dynamicProxyPlugin } from '@xiao-ying/miniapp-proxy'
19
+
20
+ export default defineConfig(({ mode }) => {
21
+ const env = loadEnv(mode, process.cwd(), 'VITE_')
22
+ const proxyPrefix = env.VITE_XY_PROXY_PREFIX ?? '/dev-proxy/'
23
+ const allowTarget = (url: URL) => url.hostname.endsWith('.example.com')
24
+
25
+ return {
26
+ plugins: [
27
+ react(),
28
+ dynamicProxyPlugin({
29
+ prefix: proxyPrefix, // 默认 /dev-proxy
30
+ token: env.VITE_XIAOYING_TOKEN,
31
+ tokenTargets: env.VITE_XIAOYING_TOKEN_TARGETS,
32
+ allowTarget, // 可选:拒绝不允许的目标
33
+ secure: false // 允许自签名证书
34
+ })
35
+ ]
36
+ }
37
+ })
38
+ ```
39
+
40
+ 3) 运行时开启代理(浏览器分支会自动重写请求 URL):
41
+
42
+ ```ts
43
+ const prefix = import.meta.env.VITE_XY_PROXY_PREFIX ?? '/dev-proxy/'
44
+ window.__XY_CONFIG__ = {
45
+ runtime: 'browser',
46
+ devProxy: { enable: true, prefix }
47
+ }
48
+ ```
49
+
50
+ 4) 发起请求:
51
+
52
+ ```ts
53
+ const target = 'https://api.example.com'
54
+ fetch(`${prefix}${encodeURIComponent(target)}/v1/foo`, { method: 'GET' })
55
+ ```
56
+
57
+ ## WebSocket 代理
58
+
59
+ 同样使用 `/dev-proxy/<encodeURIComponent(target)>` 形式即可:
60
+
61
+ ```ts
62
+ const prefix = import.meta.env.VITE_XY_PROXY_PREFIX ?? '/dev-proxy/'
63
+ const wsTarget = 'wss://ws.example.com/socket'
64
+ const ws = new WebSocket(`${prefix}${encodeURIComponent(wsTarget)}`)
65
+ ```
66
+
67
+ 说明:
68
+
69
+ - WS 仅允许 `ws://` / `wss://` 目标,HTTP 仍仅允许 `http://` / `https://`。
70
+ - 代理会移除 `Origin/Referer/Host` 头,避免跨域检查干扰。
71
+
72
+ ## 独立进程用法
73
+
74
+ ```ts
75
+ import { startProxy } from '@xiao-ying/miniapp-proxy'
76
+
77
+ startProxy({
78
+ port: 9000,
79
+ prefix: '/dev-proxy'
80
+ })
81
+ ```
82
+
83
+ ## 作为中间件使用
84
+
85
+ 需要自行接入到任意 Node HTTP 框架时,可直接使用 `createProxyMiddleware`:
86
+
87
+ ```ts
88
+ import express from 'express'
89
+ import { createProxyMiddleware } from '@xiao-ying/miniapp-proxy'
90
+
91
+ const app = express()
92
+ app.use(createProxyMiddleware({ prefix: '/dev-proxy' }))
93
+ ```
94
+
95
+ ## 请求体预览(bodyPreview)
96
+
97
+ 已默认开启请求体预览,会在日志打印请求体前 N 字节(默认 2048),便于快速核对参数:
98
+
99
+ ```ts
100
+ dynamicProxyPlugin({
101
+ bodyPreview: { maxBytes: 4096 }, // 关闭可传 false
102
+ })
103
+ ```
104
+
105
+ 预览仅在有请求体的场景(非 GET/HEAD/OPTIONS)生效,实际转发仍使用完整请求体。
106
+
107
+ ## 开发者凭证注入
108
+
109
+ 如需在调试时自动为指定目标追加 `XIAOYING-TOKEN` 请求头,推荐在 `vite.config.ts` 里用 `loadEnv` 读取并通过插件参数传入(见上例)。同时仍兼容直接使用环境变量:
110
+
111
+ - `VITE_XIAOYING_TOKEN`(优先)/ `XIAOYING_TOKEN`(回退):要注入的 token(为空则不注入)。
112
+ - `VITE_XIAOYING_TOKEN_TARGETS`(优先)/ `XIAOYING_TOKEN_TARGETS`(回退):逗号分隔的 host/port 通配符,默认值为 `xiaoying.life,*.xiaoying.life,*.xiaoying.xyz:44`。示例:
113
+ `VITE_XIAOYING_TOKEN_TARGETS="*.internal.example.com:8080,api.example.com"`
114
+
115
+ 仅当目标 URL 匹配这些规则时才会附加 `XIAOYING-TOKEN`,其余目标保持原样转发。
116
+
117
+ ## 约定与可选项
118
+
119
+ - `prefix`: 代理前缀,带/不带尾斜杠皆可,默认 `/dev-proxy`。
120
+ - `allowTarget(url: URL)`: 可选白名单钩子,返回 `false` 则拒绝转发。
121
+ - `secure`: 是否验证上游 HTTPS 证书,默认 `false`(允许自签名)。
122
+ - `bodyPreview`: `boolean | { enabled?: boolean; maxBytes?: number }`,日志打印请求体前若干字节,默认开启(2048 字节);仅在有请求体时生效,可传 `false` 关闭。
123
+ - HTTP 仅转发 http/https;WS 仅转发 ws/wss,其他协议会被拒绝。
124
+ - 默认 `changeOrigin: true`,`secure: false`(允许自签名证书)。
125
+ - 可以通过 `allowTarget` 添加额外白名单校验;通过 `log` 接管日志输出。
126
+ - `proxyOptions` 可透传给 `http-proxy-middleware`,满足高级定制需求。
127
+
128
+ ## 其他形态
129
+
130
+ - 需要独立进程时,可用同包的 `startProxy` 启动本地服务,再由 Vite `server.proxy` 转发。
131
+ - 不希望在 Vite 中内置开放代理时,可改用 Nginx/公司网关等方案,但需显式验证白名单以避免滥用。
@@ -0,0 +1,3 @@
1
+ export { createProxyMiddleware, startProxy, type ProxyOptions, type StartProxyOptions, type ProxyRequestHandler, type BodyPreviewOptions } from './proxy.js';
2
+ export { dynamicProxyPlugin, type DynamicProxyPluginOptions } from './vite.js';
3
+ //# 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,qBAAqB,EACrB,UAAU,EACV,KAAK,YAAY,EACjB,KAAK,iBAAiB,EACtB,KAAK,mBAAmB,EACxB,KAAK,kBAAkB,EACxB,MAAM,YAAY,CAAA;AAEnB,OAAO,EAAE,kBAAkB,EAAE,KAAK,yBAAyB,EAAE,MAAM,WAAW,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { createProxyMiddleware, startProxy } from './proxy.js';
2
+ export { dynamicProxyPlugin } from './vite.js';
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,qBAAqB,EACrB,UAAU,EAKX,MAAM,YAAY,CAAA;AAEnB,OAAO,EAAE,kBAAkB,EAAkC,MAAM,WAAW,CAAA"}
@@ -0,0 +1,55 @@
1
+ import http from 'http';
2
+ import type { Duplex } from 'stream';
3
+ import type { Options as HttpProxyMiddlewareOptions } from 'http-proxy-middleware';
4
+ export type ProxyUpgradeHandler = (req: http.IncomingMessage, socket: Duplex, head: Buffer) => void;
5
+ export type ProxyRequestHandler = ((req: http.IncomingMessage, res: http.ServerResponse, next?: (err?: any) => void) => void) & {
6
+ upgrade?: ProxyUpgradeHandler;
7
+ };
8
+ /** 代理配置。 */
9
+ export interface ProxyOptions {
10
+ /** 路由前缀,形如 /dev-proxy */
11
+ prefix?: string;
12
+ /** 明确传入要注入的 token(优先级最高) */
13
+ token?: string;
14
+ /** 明确传入 token 目标匹配串,逗号分隔(优先级最高) */
15
+ tokenTargets?: string;
16
+ /** http-proxy changeOrigin 开关,默认 true */
17
+ changeOrigin?: boolean;
18
+ /** 是否允许自签名证书,默认 false */
19
+ secure?: boolean;
20
+ /** 可选的白名单钩子,返回 false 则拒绝转发 */
21
+ allowTarget?: (target: URL) => boolean;
22
+ /** 自定义日志输出 */
23
+ log?: (message: string) => void;
24
+ /** 透传给 http-proxy-middleware 的额外配置 */
25
+ proxyOptions?: HttpProxyMiddlewareOptions;
26
+ /** 是否在日志中输出请求体预览,默认开启(max 2048 bytes) */
27
+ bodyPreview?: boolean | BodyPreviewOptions;
28
+ }
29
+ /** startProxy 的配置项。 */
30
+ export interface StartProxyOptions extends ProxyOptions {
31
+ /** 监听端口,默认 9000 */
32
+ port?: number;
33
+ }
34
+ /** 请求体预览配置。 */
35
+ export interface BodyPreviewOptions {
36
+ /** 开启后打印请求体的前若干字节 */
37
+ enabled?: boolean;
38
+ /** 预览的最大字节数,默认 2048 */
39
+ maxBytes?: number;
40
+ }
41
+ /**
42
+ * 创建可挂载到任意 HTTP 框架的代理中间件。
43
+ *
44
+ * @example
45
+ * app.use(createProxyMiddleware({ prefix: '/dev-proxy' }))
46
+ */
47
+ export declare const createProxyMiddleware: (options?: ProxyOptions) => ProxyRequestHandler;
48
+ /**
49
+ * 启动独立代理服务。
50
+ *
51
+ * @example
52
+ * startProxy({ port: 9000, prefix: '/dev-proxy' })
53
+ */
54
+ export declare const startProxy: (options?: StartProxyOptions | number) => http.Server;
55
+ //# sourceMappingURL=proxy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../src/proxy.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,MAAM,CAAA;AACvB,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AACpC,OAAO,KAAK,EAAE,OAAO,IAAI,0BAA0B,EAAkB,MAAM,uBAAuB,CAAA;AAIlG,MAAM,MAAM,mBAAmB,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;AAEnG,MAAM,MAAM,mBAAmB,GAAG,CAAC,CACjC,GAAG,EAAE,IAAI,CAAC,eAAe,EACzB,GAAG,EAAE,IAAI,CAAC,cAAc,EACxB,IAAI,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,GAAG,KAAK,IAAI,KACvB,IAAI,CAAC,GAAG;IAAE,OAAO,CAAC,EAAE,mBAAmB,CAAA;CAAE,CAAA;AAE9C,YAAY;AACZ,MAAM,WAAW,YAAY;IAC3B,yBAAyB;IACzB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,4BAA4B;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,mCAAmC;IACnC,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,yCAAyC;IACzC,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,yBAAyB;IACzB,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,8BAA8B;IAC9B,WAAW,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,OAAO,CAAA;IACtC,cAAc;IACd,GAAG,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;IAC/B,sCAAsC;IACtC,YAAY,CAAC,EAAE,0BAA0B,CAAA;IACzC,yCAAyC;IACzC,WAAW,CAAC,EAAE,OAAO,GAAG,kBAAkB,CAAA;CAC3C;AAED,uBAAuB;AACvB,MAAM,WAAW,iBAAkB,SAAQ,YAAY;IACrD,mBAAmB;IACnB,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED,eAAe;AACf,MAAM,WAAW,kBAAkB;IACjC,qBAAqB;IACrB,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,uBAAuB;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAmPD;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,GAAI,UAAS,YAAiB,KAAG,mBA4PlE,CAAA;AAED;;;;;GAKG;AACH,eAAO,MAAM,UAAU,GAAI,UAAS,iBAAiB,GAAG,MAAW,KAAG,IAAI,CAAC,MAa1E,CAAA"}
package/dist/proxy.js ADDED
@@ -0,0 +1,401 @@
1
+ import http from 'http';
2
+ import { createProxyMiddleware as createHttpProxyMiddleware } from 'http-proxy-middleware';
3
+ const defaultLogger = (message) => console.log(message);
4
+ const isHttpProtocol = (protocol) => protocol === 'http:' || protocol === 'https:';
5
+ const isWsProtocol = (protocol) => protocol === 'ws:' || protocol === 'wss:';
6
+ const toProxyOrigin = (url) => {
7
+ if (url.protocol === 'ws:') {
8
+ return `http://${url.host}`;
9
+ }
10
+ if (url.protocol === 'wss:') {
11
+ return `https://${url.host}`;
12
+ }
13
+ return url.origin;
14
+ };
15
+ const normalizePrefix = (prefix) => {
16
+ const withLeading = prefix.startsWith('/') ? prefix : `/${prefix}`;
17
+ const normalized = withLeading.replace(/\/+$/, '') || '/';
18
+ const matchPrefix = normalized === '/' ? '/' : `${normalized}/`;
19
+ return { normalized, matchPrefix };
20
+ };
21
+ const writeTextResponse = (res, status, message) => {
22
+ if (res.writableEnded)
23
+ return;
24
+ res.statusCode = status;
25
+ res.setHeader('Content-Type', 'text/plain');
26
+ res.end(message);
27
+ };
28
+ const stripBrowserHeaders = (req) => {
29
+ delete req.headers.host;
30
+ delete req.headers.referer;
31
+ delete req.headers.origin;
32
+ delete req.headers['xy-proxy-target'];
33
+ };
34
+ const writeSocketResponse = (socket, status, message) => {
35
+ if (socket.destroyed)
36
+ return;
37
+ const statusText = http.STATUS_CODES[status] ?? 'Error';
38
+ const response = `HTTP/1.1 ${status} ${statusText}\r\nConnection: close\r\nContent-Type: text/plain\r\n\r\n${message}`;
39
+ try {
40
+ socket.write(response);
41
+ }
42
+ catch {
43
+ // ignore write failure
44
+ }
45
+ socket.destroy();
46
+ };
47
+ const headerIncludesToken = (value, token) => {
48
+ if (!value)
49
+ return false;
50
+ if (Array.isArray(value)) {
51
+ return value.some((item) => item.toLowerCase().split(',').some((part) => part.trim() === token));
52
+ }
53
+ return value.toLowerCase().split(',').some((part) => part.trim() === token);
54
+ };
55
+ const isWebSocketUpgrade = (req) => {
56
+ const connectionOk = headerIncludesToken(req.headers.connection, 'upgrade');
57
+ const upgradeOk = headerIncludesToken(req.headers.upgrade, 'websocket');
58
+ return connectionOk && upgradeOk;
59
+ };
60
+ const isRedirectStatus = (status) => typeof status === 'number' && status >= 300 && status < 400;
61
+ const defaultTargetPatterns = ['xiaoying.life', '*.xiaoying.life', '*.xiaoying.xyz:44'];
62
+ const toHostRegex = (pattern) => {
63
+ // Escape regex metacharacters except the wildcard `*`, then expand `*` to `.*`
64
+ const escaped = pattern.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&');
65
+ const regex = '^' + escaped.replace(/\*/g, '.*') + '$';
66
+ return new RegExp(regex, 'i');
67
+ };
68
+ const parsePattern = (pattern) => {
69
+ const [hostPart, port] = pattern.split(':', 2);
70
+ return { hostRegex: toHostRegex(hostPart), port };
71
+ };
72
+ const loadTargetPatterns = (rawFromEnv) => {
73
+ const raw = (rawFromEnv && rawFromEnv.trim().length > 0 ? rawFromEnv : undefined) ??
74
+ process.env.VITE_XIAOYING_TOKEN_TARGETS?.trim() ??
75
+ process.env.XIAOYING_TOKEN_TARGETS?.trim() ??
76
+ '';
77
+ const list = raw.length > 0 ? raw.split(',') : defaultTargetPatterns;
78
+ return list
79
+ .map((item) => item.trim())
80
+ .filter(Boolean)
81
+ .map(parsePattern);
82
+ };
83
+ const normalizePort = (url) => {
84
+ if (url.port)
85
+ return url.port;
86
+ if (url.protocol === 'http:')
87
+ return '80';
88
+ if (url.protocol === 'https:')
89
+ return '443';
90
+ if (url.protocol === 'ws:')
91
+ return '80';
92
+ if (url.protocol === 'wss:')
93
+ return '443';
94
+ return '';
95
+ };
96
+ const shouldInjectToken = (url, patterns) => {
97
+ const port = normalizePort(url);
98
+ return patterns.some((pattern) => {
99
+ const hostOk = pattern.hostRegex.test(url.hostname);
100
+ const portOk = !pattern.port || pattern.port === port;
101
+ return hostOk && portOk;
102
+ });
103
+ };
104
+ const resolveBodyPreview = (bodyPreview) => {
105
+ if (typeof bodyPreview === 'boolean') {
106
+ return { enabled: bodyPreview, maxBytes: 2048 };
107
+ }
108
+ const enabled = bodyPreview?.enabled ?? true;
109
+ const maxBytes = bodyPreview?.maxBytes ?? 2048;
110
+ return { enabled, maxBytes };
111
+ };
112
+ const shouldCaptureBody = (req) => {
113
+ const method = (req.method ?? 'GET').toUpperCase();
114
+ if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS')
115
+ return false;
116
+ return true;
117
+ };
118
+ const collectRequestBody = (req, maxPreviewBytes) => {
119
+ if (!shouldCaptureBody(req))
120
+ return Promise.resolve(null);
121
+ return new Promise((resolve, reject) => {
122
+ const bodyChunks = [];
123
+ const previewChunks = [];
124
+ let previewCollected = 0;
125
+ req.on('data', (chunk) => {
126
+ bodyChunks.push(chunk);
127
+ if (previewCollected < maxPreviewBytes) {
128
+ const remaining = maxPreviewBytes - previewCollected;
129
+ const slice = remaining >= chunk.length ? chunk : chunk.slice(0, remaining);
130
+ previewChunks.push(slice);
131
+ previewCollected += slice.length;
132
+ }
133
+ });
134
+ req.on('end', () => {
135
+ const buffer = Buffer.concat(bodyChunks);
136
+ const preview = Buffer.concat(previewChunks);
137
+ const truncated = buffer.length > preview.length;
138
+ resolve({ buffer, preview, truncated, totalBytes: buffer.length });
139
+ });
140
+ req.on('error', (err) => reject(err));
141
+ });
142
+ };
143
+ const looksText = (buffer) => {
144
+ // simple heuristic: allow common whitespace and printable ASCII
145
+ return buffer.every((byte) => (byte >= 0x20 && byte <= 0x7e) || byte === 0x0a || byte === 0x0d || byte === 0x09);
146
+ };
147
+ const formatBodyPreview = (capture, headers) => {
148
+ const { preview, truncated, totalBytes } = capture;
149
+ if (preview.length === 0)
150
+ return '(empty body)';
151
+ const contentType = headers?.['content-type']?.toString().toLowerCase() ?? '';
152
+ const forceText = contentType.includes('application/json') || contentType.startsWith('text/');
153
+ const printable = forceText || looksText(preview);
154
+ const content = printable ? preview.toString('utf8') : preview.toString('base64');
155
+ const encodingLabel = printable ? 'utf8' : 'base64';
156
+ const truncatedLabel = truncated ? ', truncated' : '';
157
+ return `(${preview.length}/${totalBytes} bytes${truncatedLabel}, ${encodingLabel}): ${content}`;
158
+ };
159
+ const resolveProxyTarget = (req, matchPrefix, guard, allowProtocol) => {
160
+ const url = req.url ?? '';
161
+ const matchesPrefix = url === matchPrefix.slice(0, -1) || url.startsWith(matchPrefix);
162
+ if (!matchesPrefix) {
163
+ return { status: 'skip' };
164
+ }
165
+ const encodedPart = url.startsWith(matchPrefix) ? url.slice(matchPrefix.length) : '';
166
+ if (!encodedPart) {
167
+ return { status: 'error', statusCode: 400, message: 'Proxy Error: Missing target' };
168
+ }
169
+ let decoded = '';
170
+ try {
171
+ decoded = decodeURIComponent(encodedPart);
172
+ }
173
+ catch {
174
+ return { status: 'error', statusCode: 400, message: 'Proxy Error: Invalid encoding' };
175
+ }
176
+ let fullUrl;
177
+ try {
178
+ fullUrl = new URL(decoded);
179
+ }
180
+ catch {
181
+ return { status: 'error', statusCode: 400, message: 'Proxy Error: Invalid URL' };
182
+ }
183
+ if (!allowProtocol(fullUrl.protocol)) {
184
+ return { status: 'error', statusCode: 400, message: 'Proxy Error: Unsupported protocol' };
185
+ }
186
+ if (!guard(fullUrl)) {
187
+ return { status: 'error', statusCode: 403, message: 'Proxy Error: Target blocked' };
188
+ }
189
+ return { status: 'ok', targetUrl: fullUrl, pathAndSearch: fullUrl.pathname + fullUrl.search };
190
+ };
191
+ /**
192
+ * 创建可挂载到任意 HTTP 框架的代理中间件。
193
+ *
194
+ * @example
195
+ * app.use(createProxyMiddleware({ prefix: '/dev-proxy' }))
196
+ */
197
+ export const createProxyMiddleware = (options = {}) => {
198
+ const { prefix = '/dev-proxy', token: tokenFromOptions, tokenTargets: tokenTargetsFromOptions, changeOrigin = true, secure = false, allowTarget, log = defaultLogger, proxyOptions = {}, bodyPreview } = options;
199
+ const bodyPreviewConfig = resolveBodyPreview(bodyPreview);
200
+ const { matchPrefix } = normalizePrefix(prefix);
201
+ const token = tokenFromOptions?.trim() ??
202
+ process.env.VITE_XIAOYING_TOKEN?.trim() ??
203
+ process.env.XIAOYING_TOKEN?.trim();
204
+ const tokenTargetRaw = tokenTargetsFromOptions?.trim() ??
205
+ process.env.VITE_XIAOYING_TOKEN_TARGETS?.trim() ??
206
+ process.env.XIAOYING_TOKEN_TARGETS?.trim() ??
207
+ '';
208
+ const tokenTargetPatterns = loadTargetPatterns(tokenTargetRaw);
209
+ const tokenSource = tokenFromOptions
210
+ ? 'options.token'
211
+ : process.env.VITE_XIAOYING_TOKEN
212
+ ? 'VITE_XIAOYING_TOKEN'
213
+ : process.env.XIAOYING_TOKEN
214
+ ? 'XIAOYING_TOKEN (fallback)'
215
+ : 'none';
216
+ const maskedToken = token
217
+ ? token.length <= 8
218
+ ? '***'
219
+ : `${token.slice(0, 4)}...${token.slice(-4)}`
220
+ : 'missing';
221
+ log(`[dev-proxy] env token (${tokenSource}): ${maskedToken}`);
222
+ const targetsSource = tokenTargetsFromOptions
223
+ ? 'options.tokenTargets'
224
+ : process.env.VITE_XIAOYING_TOKEN_TARGETS
225
+ ? 'VITE_XIAOYING_TOKEN_TARGETS'
226
+ : process.env.XIAOYING_TOKEN_TARGETS
227
+ ? 'XIAOYING_TOKEN_TARGETS (fallback)'
228
+ : 'defaults';
229
+ log(`[dev-proxy] env token targets (${targetsSource}): ${tokenTargetRaw || '(using defaults)'}`);
230
+ log(`[dev-proxy] token target patterns loaded: ${tokenTargetPatterns.length}`);
231
+ log(`[dev-proxy] body preview: ${bodyPreviewConfig.enabled
232
+ ? `enabled (max ${bodyPreviewConfig.maxBytes} bytes)`
233
+ : 'disabled'}`);
234
+ const guard = allowTarget ?? (() => true);
235
+ const userEvents = (proxyOptions.on ?? {});
236
+ const resolvedProxyOptions = {
237
+ ...proxyOptions,
238
+ ws: typeof proxyOptions.ws === 'boolean' ? proxyOptions.ws : true
239
+ };
240
+ const injectTokenHeader = (targetOrigin, setHeader) => {
241
+ if (token) {
242
+ if (targetOrigin) {
243
+ try {
244
+ const url = new URL(targetOrigin);
245
+ if (shouldInjectToken(url, tokenTargetPatterns)) {
246
+ setHeader('XIAOYING-TOKEN', token);
247
+ log(`[dev-proxy] injected XIAOYING-TOKEN for ${url.hostname}:${normalizePort(url)}`);
248
+ }
249
+ else {
250
+ log(`[dev-proxy] skip token injection: target ${url.hostname}:${normalizePort(url)} not in patterns`);
251
+ }
252
+ }
253
+ catch {
254
+ // ignore parse errors; no injection
255
+ }
256
+ }
257
+ else {
258
+ log('[dev-proxy] skip token injection: missing target origin (options.token/VITE_XIAOYING_TOKEN/XIAOYING_TOKEN)');
259
+ }
260
+ }
261
+ else {
262
+ log('[dev-proxy] skip token injection: token missing (options.token/VITE_XIAOYING_TOKEN/XIAOYING_TOKEN)');
263
+ }
264
+ };
265
+ const proxy = createHttpProxyMiddleware({
266
+ ...resolvedProxyOptions,
267
+ target: 'http://dev-proxy.placeholder', // will be overridden by router
268
+ changeOrigin,
269
+ secure,
270
+ router: (req) => req.__xyTargetOrigin ?? 'http://dev-proxy.placeholder',
271
+ on: {
272
+ ...userEvents,
273
+ error: (error, req, res, target) => {
274
+ log(`[dev-proxy] proxy error: ${error.message}`);
275
+ if (res instanceof http.ServerResponse) {
276
+ writeTextResponse(res, 502, 'Proxy Error');
277
+ }
278
+ userEvents.error?.(error, req, res, target);
279
+ },
280
+ proxyRes: (proxyRes, req, res) => {
281
+ userEvents.proxyRes?.(proxyRes, req, res);
282
+ if (!isRedirectStatus(proxyRes.statusCode))
283
+ return;
284
+ const location = proxyRes.headers?.location;
285
+ if (!location)
286
+ return;
287
+ const targetOrigin = req.__xyTargetOrigin;
288
+ const proxyPrefix = req.__xyMatchPrefix;
289
+ if (!proxyPrefix)
290
+ return;
291
+ try {
292
+ const absoluteLocation = new URL(location, targetOrigin).toString();
293
+ // 将重定向跳转改写为经过代理的地址,避免浏览器直接访问目标源
294
+ proxyRes.headers.location = `${proxyPrefix}${encodeURIComponent(absoluteLocation)}`;
295
+ }
296
+ catch {
297
+ // 如果地址解析失败,保持原样
298
+ }
299
+ },
300
+ proxyReq: (proxyReq, req, res, options) => {
301
+ // 透传开发 token
302
+ injectTokenHeader(req.__xyTargetOrigin, (key, value) => proxyReq.setHeader(key, value));
303
+ // 写入已捕获的请求体
304
+ const bodyCapture = req.__xyBodyCapture;
305
+ if (bodyCapture && bodyCapture.buffer.length > 0) {
306
+ proxyReq.setHeader('content-length', String(bodyCapture.buffer.length));
307
+ proxyReq.removeHeader?.('transfer-encoding');
308
+ proxyReq.write(bodyCapture.buffer);
309
+ }
310
+ userEvents.proxyReq?.(proxyReq, req, res, options);
311
+ },
312
+ proxyReqWs: (proxyReq, req, socket, options, head) => {
313
+ injectTokenHeader(req.__xyTargetOrigin, (key, value) => proxyReq.setHeader(key, value));
314
+ userEvents.proxyReqWs?.(proxyReq, req, socket, options, head);
315
+ }
316
+ }
317
+ });
318
+ const handler = ((req, res, next) => {
319
+ const resolution = resolveProxyTarget(req, matchPrefix, guard, isHttpProtocol);
320
+ if (resolution.status === 'skip') {
321
+ if (next)
322
+ return next();
323
+ return writeTextResponse(res, 404, 'Not Found');
324
+ }
325
+ if (resolution.status === 'error') {
326
+ return writeTextResponse(res, resolution.statusCode, resolution.message);
327
+ }
328
+ const { targetUrl, pathAndSearch } = resolution;
329
+ const method = req.method ?? 'UNKNOWN';
330
+ log(`[dev-proxy] incoming request -> method: ${method}, url: ${req.url ?? ''}`);
331
+ log(`[dev-proxy] target: ${method} ${targetUrl.toString()}`);
332
+ log(`[dev-proxy] headers: ${JSON.stringify(req.headers, null, 2)}`);
333
+ req.url = pathAndSearch;
334
+ log(`[dev-proxy] ${targetUrl.origin}${req.url}`);
335
+ req.__xyTargetOrigin = targetUrl.origin;
336
+ req.__xyMatchPrefix = matchPrefix;
337
+ stripBrowserHeaders(req);
338
+ const forward = () => proxy(req, res, next ?? (() => undefined));
339
+ if (!bodyPreviewConfig.enabled || !shouldCaptureBody(req)) {
340
+ return forward();
341
+ }
342
+ collectRequestBody(req, bodyPreviewConfig.maxBytes)
343
+ .then((capture) => {
344
+ if (capture) {
345
+ ;
346
+ req.__xyBodyCapture = capture;
347
+ log(`[dev-proxy] body preview ${formatBodyPreview(capture, req.headers)}`);
348
+ }
349
+ forward();
350
+ })
351
+ .catch((error) => {
352
+ log(`[dev-proxy] body preview failed: ${error instanceof Error ? error.message : error}`);
353
+ forward();
354
+ });
355
+ });
356
+ handler.upgrade = (req, socket, head) => {
357
+ if (!isWebSocketUpgrade(req))
358
+ return;
359
+ const resolution = resolveProxyTarget(req, matchPrefix, guard, isWsProtocol);
360
+ if (resolution.status === 'skip')
361
+ return;
362
+ if (resolution.status === 'error') {
363
+ return writeSocketResponse(socket, resolution.statusCode, resolution.message);
364
+ }
365
+ const { targetUrl, pathAndSearch } = resolution;
366
+ log(`[dev-proxy] incoming upgrade -> url: ${req.url ?? ''}`);
367
+ log(`[dev-proxy] target: WS ${targetUrl.toString()}`);
368
+ log(`[dev-proxy] headers: ${JSON.stringify(req.headers, null, 2)}`);
369
+ req.url = pathAndSearch;
370
+ log(`[dev-proxy] ${targetUrl.origin}${req.url}`);
371
+ req.__xyTargetOrigin = toProxyOrigin(targetUrl);
372
+ req.__xyMatchPrefix = matchPrefix;
373
+ stripBrowserHeaders(req);
374
+ const upgrader = proxy.upgrade;
375
+ if (typeof upgrader !== 'function') {
376
+ return writeSocketResponse(socket, 502, 'Proxy Error');
377
+ }
378
+ return upgrader(req, socket, head);
379
+ };
380
+ return handler;
381
+ };
382
+ /**
383
+ * 启动独立代理服务。
384
+ *
385
+ * @example
386
+ * startProxy({ port: 9000, prefix: '/dev-proxy' })
387
+ */
388
+ export const startProxy = (options = {}) => {
389
+ const normalizedOptions = typeof options === 'number' ? { port: options } : options ?? {};
390
+ const { port = 9000, log = defaultLogger } = normalizedOptions;
391
+ const handler = createProxyMiddleware(normalizedOptions);
392
+ const server = http.createServer((req, res) => handler(req, res));
393
+ if (handler.upgrade) {
394
+ server.on('upgrade', (req, socket, head) => handler.upgrade?.(req, socket, head));
395
+ }
396
+ server.listen(port, () => {
397
+ log(`> Dev proxy running at http://localhost:${port}`);
398
+ });
399
+ return server;
400
+ };
401
+ //# sourceMappingURL=proxy.js.map