vite-plugin-ai-mock 0.1.5 → 1.0.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/README.md +81 -10
- package/README.zh-CN.md +77 -6
- package/dist/index.cjs +165 -125
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +30 -2
- package/dist/index.d.ts +30 -2
- package/dist/index.js +165 -125
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
[](https://github.com/quanzhiyuan/vite-plugin-ai-mock/actions)
|
|
9
9
|
[](./LICENSE)
|
|
10
10
|
|
|
11
|
-
A standalone Vite plugin for AI scene mocking.
|
|
11
|
+
A standalone Vite plugin for AI scene mocking. Supports both JSON and TypeScript mock files, returning SSE streaming or JSON responses to simulate various AI scenarios.
|
|
12
12
|
|
|
13
|
-
- Reads mock files from `mock/*.json`
|
|
13
|
+
- Reads mock files from `mock/*.json` or `mock/*.ts`
|
|
14
14
|
- Returns SSE streaming response by default
|
|
15
15
|
- Use `?transport=json` to get JSON format response
|
|
16
16
|
- Supports 11 streaming scenarios with request parameters
|
|
17
|
+
- TypeScript mock files support dynamic data and full middleware control — **hot-reloaded without server restart**
|
|
17
18
|
|
|
18
19
|
## Install
|
|
19
20
|
|
|
@@ -33,7 +34,8 @@ pnpm add vite-plugin-ai-mock -D
|
|
|
33
34
|
project/
|
|
34
35
|
├── mock/
|
|
35
36
|
│ └── ai/
|
|
36
|
-
│ ├── chat.json
|
|
37
|
+
│ ├── chat.json ← JSON mock
|
|
38
|
+
│ ├── sessions.ts ← TypeScript mock
|
|
37
39
|
│ └── default.json
|
|
38
40
|
├── src/
|
|
39
41
|
└── vite.config.ts
|
|
@@ -86,6 +88,75 @@ export default defineConfig({
|
|
|
86
88
|
|
|
87
89
|
> 💡 See full examples: [examples](https://github.com/quanzhiyuan/vite-plugin-ai-mock/tree/main/examples) (includes Ant Design X, Assistant UI, Lobe Chat integrations)
|
|
88
90
|
|
|
91
|
+
## TypeScript Mock Files
|
|
92
|
+
|
|
93
|
+
In addition to `.json` files, mock files can be written in TypeScript. `.ts` takes priority over `.json` when both exist.
|
|
94
|
+
|
|
95
|
+
Three export patterns are supported:
|
|
96
|
+
|
|
97
|
+
### Pattern 1 — Static default export (same as JSON)
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
// mock/ai/chat.ts
|
|
101
|
+
export default {
|
|
102
|
+
chunks: [
|
|
103
|
+
{ id: "1", data: { delta: "Hello" } },
|
|
104
|
+
{ id: "2", data: { delta: " World" } },
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Pattern 2 — Factory function (called per request)
|
|
110
|
+
|
|
111
|
+
The function receives the incoming request and can return dynamic data. Supports `async`.
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
// mock/ai/chat.ts
|
|
115
|
+
import type { Connect } from "vite";
|
|
116
|
+
|
|
117
|
+
export default (req: Connect.IncomingMessage) => {
|
|
118
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
119
|
+
const lang = url.searchParams.get("lang") ?? "en";
|
|
120
|
+
return {
|
|
121
|
+
chunks: [
|
|
122
|
+
{ id: "1", data: { delta: lang === "zh" ? "你好" : "Hello" } },
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Pattern 3 — Handler (full middleware control)
|
|
129
|
+
|
|
130
|
+
Replaces the need to write a separate `configureServer` plugin. Receives `(req, res, next)` and handles the response directly — ideal for dynamic routes like session create/delete.
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
// mock/ai/session.ts
|
|
134
|
+
import type { MockRequestHandler } from "vite-plugin-ai-mock";
|
|
135
|
+
|
|
136
|
+
const sessions = new Set<string>();
|
|
137
|
+
|
|
138
|
+
export const handler: MockRequestHandler = (req, res) => {
|
|
139
|
+
if (req.method === "POST") {
|
|
140
|
+
const id = `sess-${Date.now()}`;
|
|
141
|
+
sessions.add(id);
|
|
142
|
+
res.setHeader("Content-Type", "application/json");
|
|
143
|
+
res.end(JSON.stringify({ status: "success", data: { session_id: id } }));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (req.method === "DELETE") {
|
|
147
|
+
const id = (req.url ?? "").split("/").pop() ?? "";
|
|
148
|
+
sessions.delete(id);
|
|
149
|
+
res.setHeader("Content-Type", "application/json");
|
|
150
|
+
res.end(JSON.stringify({ status: "success" }));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
res.statusCode = 405;
|
|
154
|
+
res.end();
|
|
155
|
+
};
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**Hot reload**: TypeScript mock files are loaded via Vite's `ssrLoadModule`. Any edit is picked up on the next request — **no server restart required**.
|
|
159
|
+
|
|
89
160
|
## Scenarios (11)
|
|
90
161
|
|
|
91
162
|
1. Normal completion (default)
|
|
@@ -169,7 +240,7 @@ Precedence: `?transport=json` / `?transport=sse` > `jsonApis` config > default S
|
|
|
169
240
|
|
|
170
241
|
## Mock file format
|
|
171
242
|
|
|
172
|
-
Each file is a JSON object with a `chunks` array. Every chunk maps to one SSE event:
|
|
243
|
+
Each mock file is a JSON object (or TypeScript module) with a `chunks` array. Every chunk maps to one SSE event:
|
|
173
244
|
|
|
174
245
|
| Field | Type | Description |
|
|
175
246
|
| --------- | ------ | --------------------------------------------------------------- |
|
|
@@ -356,12 +427,12 @@ pnpm release:npm
|
|
|
356
427
|
|
|
357
428
|
### Plugin Options `AiMockPluginOptions`
|
|
358
429
|
|
|
359
|
-
| Option | Type | Default | Description
|
|
360
|
-
| ----------------- | ------------------------------------------ | ---------------- |
|
|
361
|
-
| `dataDir` | `string` | `"mock"` | Directory for mock files, relative to project root
|
|
362
|
-
| `endpoint` | `string \| RegExp \| (string \| RegExp)[]` | `"/api"` | API path to intercept, supports string, RegExp, or array
|
|
363
|
-
| `defaultScenario` | `DefaultScenarioConfig` | `undefined` | Global default scenario config, can be overridden by URL params
|
|
364
|
-
| `jsonApis` | `(string \| RegExp)[]` | `undefined` | List of API paths that should return JSON format
|
|
430
|
+
| Option | Type | Default | Description |
|
|
431
|
+
| ----------------- | ------------------------------------------ | ---------------- | ----------------------------------------------------------------------------------------- |
|
|
432
|
+
| `dataDir` | `string` | `"mock"` | Directory for mock files (`.json` or `.ts`), relative to project root |
|
|
433
|
+
| `endpoint` | `string \| RegExp \| (string \| RegExp)[]` | `"/api"` | API path to intercept, supports string, RegExp, or array |
|
|
434
|
+
| `defaultScenario` | `DefaultScenarioConfig` | `undefined` | Global default scenario config, can be overridden by URL params |
|
|
435
|
+
| `jsonApis` | `(string \| RegExp)[]` | `undefined` | List of API paths that should return JSON format (applies to both `.json` and `.ts` files)|
|
|
365
436
|
|
|
366
437
|
### Scenario Config `DefaultScenarioConfig`
|
|
367
438
|
|
package/README.zh-CN.md
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# vite-plugin-ai-mock
|
|
2
2
|
|
|
3
|
-
一个用于 AI 场景模拟的独立 Vite
|
|
3
|
+
一个用于 AI 场景模拟的独立 Vite 插件。支持 JSON 和 TypeScript 两种 mock 文件格式,返回 SSE 流式或 JSON 响应,模拟不同的 AI 场景。
|
|
4
4
|
|
|
5
5
|
> [English](./README.md) | 中文
|
|
6
6
|
|
|
7
|
-
- 从 `mock/*.json` 读取 mock 文件
|
|
7
|
+
- 从 `mock/*.json` 或 `mock/*.ts` 读取 mock 文件
|
|
8
8
|
- 默认返回 SSE 流式响应
|
|
9
9
|
- 使用 `?transport=json` 获取 JSON 格式响应
|
|
10
10
|
- 支持 11 种流式场景,通过请求参数控制
|
|
11
|
+
- TypeScript mock 文件支持动态数据和完整中间件控制——**修改后无需重启服务**
|
|
11
12
|
|
|
12
13
|
## 安装
|
|
13
14
|
|
|
@@ -27,7 +28,8 @@ pnpm add vite-plugin-ai-mock -D
|
|
|
27
28
|
project/
|
|
28
29
|
├── mock/
|
|
29
30
|
│ └── ai/
|
|
30
|
-
│ ├── chat.json
|
|
31
|
+
│ ├── chat.json ← JSON mock
|
|
32
|
+
│ ├── sessions.ts ← TypeScript mock
|
|
31
33
|
│ └── default.json
|
|
32
34
|
├── src/
|
|
33
35
|
└── vite.config.ts
|
|
@@ -80,6 +82,75 @@ export default defineConfig({
|
|
|
80
82
|
|
|
81
83
|
> 💡 查看完整示例:[examples](https://github.com/quanzhiyuan/vite-plugin-ai-mock/tree/main/examples)(包含 Ant Design X、Assistant UI、Lobe Chat 等集成示例)
|
|
82
84
|
|
|
85
|
+
## TypeScript Mock 文件
|
|
86
|
+
|
|
87
|
+
除 `.json` 外,mock 文件可以用 TypeScript 编写。当同名 `.ts` 和 `.json` 文件同时存在时,`.ts` 优先。
|
|
88
|
+
|
|
89
|
+
支持三种 export 模式:
|
|
90
|
+
|
|
91
|
+
### 模式一 — 静态 default export(等同于 JSON)
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
// mock/ai/chat.ts
|
|
95
|
+
export default {
|
|
96
|
+
chunks: [
|
|
97
|
+
{ id: "1", data: { delta: "你好" } },
|
|
98
|
+
{ id: "2", data: { delta: "世界" } },
|
|
99
|
+
],
|
|
100
|
+
};
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### 模式二 — 工厂函数(每次请求都会调用)
|
|
104
|
+
|
|
105
|
+
函数接收请求对象,可返回动态数据。支持 `async`。
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
// mock/ai/chat.ts
|
|
109
|
+
import type { Connect } from "vite";
|
|
110
|
+
|
|
111
|
+
export default (req: Connect.IncomingMessage) => {
|
|
112
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
113
|
+
const lang = url.searchParams.get("lang") ?? "en";
|
|
114
|
+
return {
|
|
115
|
+
chunks: [
|
|
116
|
+
{ id: "1", data: { delta: lang === "zh" ? "你好" : "Hello" } },
|
|
117
|
+
],
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### 模式三 — Handler(完全控制 req/res)
|
|
123
|
+
|
|
124
|
+
替代在 `vite.config.ts` 中单独编写 `configureServer` 插件。接收 `(req, res, next)`,直接控制响应——适用于会话创建/删除等动态路由。
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
// mock/ai/session.ts
|
|
128
|
+
import type { MockRequestHandler } from "vite-plugin-ai-mock";
|
|
129
|
+
|
|
130
|
+
const sessions = new Set<string>();
|
|
131
|
+
|
|
132
|
+
export const handler: MockRequestHandler = (req, res) => {
|
|
133
|
+
if (req.method === "POST") {
|
|
134
|
+
const id = `sess-${Date.now()}`;
|
|
135
|
+
sessions.add(id);
|
|
136
|
+
res.setHeader("Content-Type", "application/json");
|
|
137
|
+
res.end(JSON.stringify({ status: "success", data: { session_id: id } }));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (req.method === "DELETE") {
|
|
141
|
+
const id = (req.url ?? "").split("/").pop() ?? "";
|
|
142
|
+
sessions.delete(id);
|
|
143
|
+
res.setHeader("Content-Type", "application/json");
|
|
144
|
+
res.end(JSON.stringify({ status: "success" }));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
res.statusCode = 405;
|
|
148
|
+
res.end();
|
|
149
|
+
};
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**热更新**:TypeScript mock 文件通过 Vite 的 `ssrLoadModule` 加载。每次修改后,下一个请求即可生效,**无需重启开发服务器**。
|
|
153
|
+
|
|
83
154
|
## 场景(11 种)
|
|
84
155
|
|
|
85
156
|
1. 正常完成(默认)
|
|
@@ -163,7 +234,7 @@ aiMockPlugin({
|
|
|
163
234
|
|
|
164
235
|
## Mock 文件格式
|
|
165
236
|
|
|
166
|
-
|
|
237
|
+
每个 mock 文件(JSON 或 TypeScript)包含一个 `chunks` 数组,每个 chunk 对应一条 SSE 事件:
|
|
167
238
|
|
|
168
239
|
| 字段 | 类型 | 说明 |
|
|
169
240
|
| --------- | ------ | --------------------------------------------------- |
|
|
@@ -352,10 +423,10 @@ pnpm release:npm
|
|
|
352
423
|
|
|
353
424
|
| 选项 | 类型 | 默认值 | 说明 |
|
|
354
425
|
| --- | --- | --- | --- |
|
|
355
|
-
| `dataDir` | `string` | `"mock"` | mock
|
|
426
|
+
| `dataDir` | `string` | `"mock"` | mock 文件目录(支持 `.json` 和 `.ts`),相对于项目根目录 |
|
|
356
427
|
| `endpoint` | `string \| RegExp \| (string \| RegExp)[]` | `"/api"` | 拦截的 API 路径,支持字符串、正则或数组 |
|
|
357
428
|
| `defaultScenario` | `DefaultScenarioConfig` | `undefined` | 全局默认场景配置,可被 URL 参数覆盖 |
|
|
358
|
-
| `jsonApis` | `(string \| RegExp)[]` | `undefined` | 指定返回 JSON 格式的 API
|
|
429
|
+
| `jsonApis` | `(string \| RegExp)[]` | `undefined` | 指定返回 JSON 格式的 API 路径(对 `.json` 和 `.ts` 均生效)|
|
|
359
430
|
|
|
360
431
|
### 场景配置 `DefaultScenarioConfig`
|
|
361
432
|
|
package/dist/index.cjs
CHANGED
|
@@ -64,14 +64,38 @@ function safeFileName(name) {
|
|
|
64
64
|
function resolveDataFile(dataDir, fileName) {
|
|
65
65
|
const safeName = safeFileName(fileName) || "default";
|
|
66
66
|
const absoluteDataDir = import_node_path.default.resolve(process.cwd(), dataDir);
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
const baseName = safeName.replace(/\.(json|ts)$/, "");
|
|
68
|
+
const candidates = [
|
|
69
|
+
// .ts takes priority so dynamic mock can shadow a JSON file
|
|
70
|
+
{ filePath: import_node_path.default.join(absoluteDataDir, `${baseName}.ts`), type: "ts" },
|
|
71
|
+
{ filePath: import_node_path.default.join(absoluteDataDir, `${baseName}.json`), type: "json" }
|
|
72
|
+
];
|
|
73
|
+
if (safeName.endsWith(".ts")) {
|
|
74
|
+
candidates.unshift({ filePath: import_node_path.default.join(absoluteDataDir, safeName), type: "ts" });
|
|
75
|
+
} else if (safeName.endsWith(".json")) {
|
|
76
|
+
candidates.unshift({ filePath: import_node_path.default.join(absoluteDataDir, safeName), type: "json" });
|
|
70
77
|
}
|
|
71
|
-
|
|
72
|
-
|
|
78
|
+
for (const candidate of candidates) {
|
|
79
|
+
if (!candidate.filePath.startsWith(absoluteDataDir)) continue;
|
|
80
|
+
if (import_node_fs.default.existsSync(candidate.filePath)) return candidate;
|
|
73
81
|
}
|
|
74
|
-
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Mock data file not found: tried ${baseName}.ts and ${baseName}.json in "${dataDir}"`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
async function loadTsMockModule(server, absoluteFilePath) {
|
|
87
|
+
const existingMod = server.moduleGraph.getModuleById(absoluteFilePath);
|
|
88
|
+
if (existingMod) {
|
|
89
|
+
server.moduleGraph.invalidateModule(existingMod);
|
|
90
|
+
}
|
|
91
|
+
const viteRoot = server.config.root;
|
|
92
|
+
let moduleUrl;
|
|
93
|
+
if (absoluteFilePath.startsWith(viteRoot)) {
|
|
94
|
+
moduleUrl = "/" + import_node_path.default.relative(viteRoot, absoluteFilePath).replace(/\\/g, "/");
|
|
95
|
+
} else {
|
|
96
|
+
moduleUrl = "/@fs/" + absoluteFilePath;
|
|
97
|
+
}
|
|
98
|
+
return await server.ssrLoadModule(moduleUrl);
|
|
75
99
|
}
|
|
76
100
|
function normalizeChunks(raw) {
|
|
77
101
|
const source = Array.isArray(raw) ? raw : typeof raw === "object" && raw !== null && "chunks" in raw ? raw.chunks : [raw];
|
|
@@ -112,8 +136,8 @@ function parseScenarioOptions(reqUrl, lastEventIdHeader, defaultScenario) {
|
|
|
112
136
|
return fallback;
|
|
113
137
|
};
|
|
114
138
|
const firstChunkDelayMs = getParam("firstChunkDelayMs", 0);
|
|
115
|
-
|
|
116
|
-
|
|
139
|
+
const minIntervalMs = getParam("minIntervalMs", 200);
|
|
140
|
+
const maxIntervalMs = getParam("maxIntervalMs", 700);
|
|
117
141
|
return {
|
|
118
142
|
file: params.get("file") ?? "default",
|
|
119
143
|
firstChunkDelayMs,
|
|
@@ -207,6 +231,108 @@ function matchEndpoint(pathname, endpoint) {
|
|
|
207
231
|
}
|
|
208
232
|
return endpoint.test(pathname) ? { fileFromPath: "" } : null;
|
|
209
233
|
}
|
|
234
|
+
function streamChunks(req, res, chunks, options) {
|
|
235
|
+
res.statusCode = 200;
|
|
236
|
+
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
237
|
+
res.setHeader("Cache-Control", "no-cache, no-transform");
|
|
238
|
+
res.setHeader("Connection", "keep-alive");
|
|
239
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
240
|
+
if ("flushHeaders" in res && typeof res.flushHeaders === "function") {
|
|
241
|
+
res.flushHeaders();
|
|
242
|
+
}
|
|
243
|
+
let closed = false;
|
|
244
|
+
let heartbeatTimer = null;
|
|
245
|
+
const pendingTimers = /* @__PURE__ */ new Set();
|
|
246
|
+
const cleanup = () => {
|
|
247
|
+
if (closed) return;
|
|
248
|
+
closed = true;
|
|
249
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
250
|
+
for (const timer of pendingTimers) clearTimeout(timer);
|
|
251
|
+
pendingTimers.clear();
|
|
252
|
+
};
|
|
253
|
+
req.on("close", cleanup);
|
|
254
|
+
if (options.heartbeatMs > 0) {
|
|
255
|
+
heartbeatTimer = setInterval(() => {
|
|
256
|
+
if (closed) return;
|
|
257
|
+
res.write(`: ping ${Date.now()}
|
|
258
|
+
|
|
259
|
+
`);
|
|
260
|
+
}, options.heartbeatMs);
|
|
261
|
+
}
|
|
262
|
+
const schedule = (task, delay) => {
|
|
263
|
+
const timer = setTimeout(() => {
|
|
264
|
+
pendingTimers.delete(timer);
|
|
265
|
+
task();
|
|
266
|
+
}, delay);
|
|
267
|
+
pendingTimers.add(timer);
|
|
268
|
+
};
|
|
269
|
+
const writeChunk = (chunk, index) => {
|
|
270
|
+
if (closed) return;
|
|
271
|
+
const chunkNo = index + 1;
|
|
272
|
+
if (options.disconnectAt === chunkNo) {
|
|
273
|
+
cleanup();
|
|
274
|
+
if ("destroy" in res && typeof res.destroy === "function") {
|
|
275
|
+
res.destroy();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
res.end();
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (options.errorAt === chunkNo) {
|
|
282
|
+
writeSseEvent(res, {
|
|
283
|
+
id: chunk.id,
|
|
284
|
+
event: "error",
|
|
285
|
+
data: { message: options.errorMessage, at: chunkNo }
|
|
286
|
+
});
|
|
287
|
+
cleanup();
|
|
288
|
+
res.end();
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (options.malformedAt === chunkNo) {
|
|
292
|
+
res.write(`id: ${chunk.id}
|
|
293
|
+
`);
|
|
294
|
+
res.write("event: message\n");
|
|
295
|
+
res.write('data: {"malformed": true\n\n');
|
|
296
|
+
} else {
|
|
297
|
+
writeSseEvent(res, {
|
|
298
|
+
id: chunk.id,
|
|
299
|
+
event: chunk.event,
|
|
300
|
+
data: chunk.data
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
if (options.stallAfter === chunkNo) {
|
|
304
|
+
schedule(() => {
|
|
305
|
+
if (!closed) {
|
|
306
|
+
cleanup();
|
|
307
|
+
res.end();
|
|
308
|
+
}
|
|
309
|
+
}, options.stallMs);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const nextChunk = chunks[index + 1];
|
|
313
|
+
if (!nextChunk) {
|
|
314
|
+
if (options.includeDone) {
|
|
315
|
+
writeSseEvent(res, { event: "done", data: { done: true } });
|
|
316
|
+
}
|
|
317
|
+
cleanup();
|
|
318
|
+
res.end();
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const interval = typeof nextChunk.delayMs === "number" ? nextChunk.delayMs : options.minIntervalMs + Math.floor(
|
|
322
|
+
Math.random() * (options.maxIntervalMs - options.minIntervalMs + 1)
|
|
323
|
+
);
|
|
324
|
+
schedule(() => writeChunk(nextChunk, index + 1), interval);
|
|
325
|
+
};
|
|
326
|
+
if (chunks.length === 0) {
|
|
327
|
+
if (options.includeDone) {
|
|
328
|
+
writeSseEvent(res, { event: "done", data: { done: true } });
|
|
329
|
+
}
|
|
330
|
+
cleanup();
|
|
331
|
+
res.end();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
schedule(() => writeChunk(chunks[0], 0), options.firstChunkDelayMs);
|
|
335
|
+
}
|
|
210
336
|
function aiMockPlugin(config) {
|
|
211
337
|
const dataDir = config?.dataDir ?? "mock/ai";
|
|
212
338
|
const endpoint = config?.endpoint ?? AI_MOCK_BASE;
|
|
@@ -215,15 +341,10 @@ function aiMockPlugin(config) {
|
|
|
215
341
|
return {
|
|
216
342
|
name: "vite-plugin-ai-mock",
|
|
217
343
|
configureServer(server) {
|
|
218
|
-
server.middlewares.use((req, res, next) => {
|
|
344
|
+
server.middlewares.use(async (req, res, next) => {
|
|
219
345
|
if (!req.url) return next();
|
|
220
346
|
const reqUrl = new URL(req.url, "http://localhost");
|
|
221
347
|
const matched = matchEndpoint(reqUrl.pathname, endpoint);
|
|
222
|
-
if (req.url.startsWith("/api")) {
|
|
223
|
-
console.log("[aiMockPlugin] Request:", req.method, req.url);
|
|
224
|
-
console.log("[aiMockPlugin] Configured endpoint:", endpoint);
|
|
225
|
-
console.log("[aiMockPlugin] Matched:", matched);
|
|
226
|
-
}
|
|
227
348
|
if (matched === null) return next();
|
|
228
349
|
const fileFromPath = matched.fileFromPath;
|
|
229
350
|
const lastEventIdHeader = typeof req.headers["last-event-id"] === "string" ? req.headers["last-event-id"] : void 0;
|
|
@@ -235,10 +356,6 @@ function aiMockPlugin(config) {
|
|
|
235
356
|
if (fileFromPath) options.file = fileFromPath;
|
|
236
357
|
try {
|
|
237
358
|
if (options.httpErrorStatus >= 400) {
|
|
238
|
-
console.log(
|
|
239
|
-
"[aiMockPlugin] Returning HTTP error:",
|
|
240
|
-
options.httpErrorStatus
|
|
241
|
-
);
|
|
242
359
|
res.statusCode = options.httpErrorStatus;
|
|
243
360
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
244
361
|
res.end(
|
|
@@ -249,121 +366,44 @@ function aiMockPlugin(config) {
|
|
|
249
366
|
);
|
|
250
367
|
return;
|
|
251
368
|
}
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const raw = readJsonFile(filePath);
|
|
258
|
-
const chunks = applyChunkMutations(normalizeChunks(raw), options);
|
|
259
|
-
if (!isSseRequest(reqUrl, jsonApis)) {
|
|
260
|
-
console.log("[aiMockPlugin] Handling as JSON response");
|
|
261
|
-
res.statusCode = 200;
|
|
262
|
-
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
263
|
-
res.end(JSON.stringify(raw));
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
console.log("[aiMockPlugin] Handling as SSE stream");
|
|
267
|
-
res.statusCode = 200;
|
|
268
|
-
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
269
|
-
res.setHeader("Cache-Control", "no-cache, no-transform");
|
|
270
|
-
res.setHeader("Connection", "keep-alive");
|
|
271
|
-
res.setHeader("X-Accel-Buffering", "no");
|
|
272
|
-
if ("flushHeaders" in res && typeof res.flushHeaders === "function") {
|
|
273
|
-
res.flushHeaders();
|
|
274
|
-
}
|
|
275
|
-
let closed = false;
|
|
276
|
-
let heartbeatTimer = null;
|
|
277
|
-
const pendingTimers = /* @__PURE__ */ new Set();
|
|
278
|
-
const cleanup = () => {
|
|
279
|
-
if (closed) return;
|
|
280
|
-
closed = true;
|
|
281
|
-
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
282
|
-
for (const timer of pendingTimers) clearTimeout(timer);
|
|
283
|
-
pendingTimers.clear();
|
|
284
|
-
};
|
|
285
|
-
req.on("close", cleanup);
|
|
286
|
-
if (options.heartbeatMs > 0) {
|
|
287
|
-
heartbeatTimer = setInterval(() => {
|
|
288
|
-
if (closed) return;
|
|
289
|
-
res.write(`: ping ${Date.now()}
|
|
290
|
-
|
|
291
|
-
`);
|
|
292
|
-
}, options.heartbeatMs);
|
|
293
|
-
}
|
|
294
|
-
const schedule = (task, delay) => {
|
|
295
|
-
const timer = setTimeout(() => {
|
|
296
|
-
pendingTimers.delete(timer);
|
|
297
|
-
task();
|
|
298
|
-
}, delay);
|
|
299
|
-
pendingTimers.add(timer);
|
|
300
|
-
};
|
|
301
|
-
const writeChunk = (chunk, index) => {
|
|
302
|
-
if (closed) return;
|
|
303
|
-
const chunkNo = index + 1;
|
|
304
|
-
if (options.disconnectAt === chunkNo) {
|
|
305
|
-
cleanup();
|
|
306
|
-
if ("destroy" in res && typeof res.destroy === "function") {
|
|
307
|
-
res.destroy();
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
res.end();
|
|
311
|
-
return;
|
|
312
|
-
}
|
|
313
|
-
if (options.errorAt === chunkNo) {
|
|
314
|
-
writeSseEvent(res, {
|
|
315
|
-
id: chunk.id,
|
|
316
|
-
event: "error",
|
|
317
|
-
data: { message: options.errorMessage, at: chunkNo }
|
|
318
|
-
});
|
|
319
|
-
cleanup();
|
|
320
|
-
res.end();
|
|
321
|
-
return;
|
|
322
|
-
}
|
|
323
|
-
if (options.malformedAt === chunkNo) {
|
|
324
|
-
res.write(`id: ${chunk.id}
|
|
325
|
-
`);
|
|
326
|
-
res.write("event: message\n");
|
|
327
|
-
res.write('data: {"malformed": true\n\n');
|
|
328
|
-
} else {
|
|
329
|
-
writeSseEvent(res, {
|
|
330
|
-
id: chunk.id,
|
|
331
|
-
event: chunk.event,
|
|
332
|
-
data: chunk.data
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
if (options.stallAfter === chunkNo) {
|
|
336
|
-
schedule(() => {
|
|
337
|
-
if (!closed) {
|
|
338
|
-
cleanup();
|
|
339
|
-
res.end();
|
|
340
|
-
}
|
|
341
|
-
}, options.stallMs);
|
|
369
|
+
const resolved = resolveDataFile(dataDir, options.file);
|
|
370
|
+
if (resolved.type === "ts") {
|
|
371
|
+
const mod = await loadTsMockModule(server, resolved.filePath);
|
|
372
|
+
if (typeof mod.handler === "function") {
|
|
373
|
+
await mod.handler(req, res, next);
|
|
342
374
|
return;
|
|
343
375
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
if (
|
|
347
|
-
|
|
376
|
+
if ("default" in mod) {
|
|
377
|
+
const raw2 = typeof mod.default === "function" ? await mod.default(req) : mod.default;
|
|
378
|
+
if (!isSseRequest(reqUrl, jsonApis)) {
|
|
379
|
+
res.statusCode = 200;
|
|
380
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
381
|
+
res.end(JSON.stringify(raw2));
|
|
382
|
+
return;
|
|
348
383
|
}
|
|
349
|
-
|
|
350
|
-
res
|
|
384
|
+
const chunks2 = applyChunkMutations(normalizeChunks(raw2), options);
|
|
385
|
+
streamChunks(req, res, chunks2, options);
|
|
351
386
|
return;
|
|
352
387
|
}
|
|
353
|
-
|
|
354
|
-
|
|
388
|
+
res.statusCode = 500;
|
|
389
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
390
|
+
res.end(
|
|
391
|
+
JSON.stringify({
|
|
392
|
+
error: "mock_empty_module",
|
|
393
|
+
message: `Mock file "${resolved.filePath}" must have a default export or a named "handler" export.`
|
|
394
|
+
})
|
|
355
395
|
);
|
|
356
|
-
schedule(() => writeChunk(nextChunk, index + 1), interval);
|
|
357
|
-
};
|
|
358
|
-
if (chunks.length === 0) {
|
|
359
|
-
if (options.includeDone) {
|
|
360
|
-
writeSseEvent(res, { event: "done", data: { done: true } });
|
|
361
|
-
}
|
|
362
|
-
cleanup();
|
|
363
|
-
res.end();
|
|
364
396
|
return;
|
|
365
397
|
}
|
|
366
|
-
|
|
398
|
+
const raw = readJsonFile(resolved.filePath);
|
|
399
|
+
if (!isSseRequest(reqUrl, jsonApis)) {
|
|
400
|
+
res.statusCode = 200;
|
|
401
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
402
|
+
res.end(JSON.stringify(raw));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const chunks = applyChunkMutations(normalizeChunks(raw), options);
|
|
406
|
+
streamChunks(req, res, chunks, options);
|
|
367
407
|
} catch (error) {
|
|
368
408
|
res.statusCode = 500;
|
|
369
409
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|