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 CHANGED
@@ -8,12 +8,13 @@
8
8
  [![CI](https://img.shields.io/github/actions/workflow/status/quanzhiyuan/vite-plugin-ai-mock/ci.yml?label=CI)](https://github.com/quanzhiyuan/vite-plugin-ai-mock/actions)
9
9
  [![license](https://img.shields.io/npm/l/vite-plugin-ai-mock)](./LICENSE)
10
10
 
11
- A standalone Vite plugin for AI scene mocking. Returns streaming data in JSON format, simulating various AI scenarios.
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 插件。可以返回 JSON 格式的流式数据,模拟不同的 AI 场景。
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
- 每个文件是一个包含 `chunks` 数组的 JSON 对象,每个 chunk 对应一条 SSE 事件:
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 candidate = safeName.endsWith(".json") ? import_node_path.default.join(absoluteDataDir, safeName) : import_node_path.default.join(absoluteDataDir, `${safeName}.json`);
68
- if (!candidate.startsWith(absoluteDataDir)) {
69
- throw new Error("Invalid mock file path.");
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
- if (!import_node_fs.default.existsSync(candidate)) {
72
- throw new Error(`Mock data file not found: ${import_node_path.default.basename(candidate)}`);
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
- return candidate;
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
- let minIntervalMs = getParam("minIntervalMs", 200);
116
- let maxIntervalMs = getParam("maxIntervalMs", 700);
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 filePath = resolveDataFile(dataDir, options.file);
253
- console.log("[aiMockPlugin] Resolving mock file:", filePath);
254
- if (!import_node_fs.default.existsSync(filePath)) {
255
- console.error("[aiMockPlugin] Mock file not found:", filePath);
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
- const nextChunk = chunks[index + 1];
345
- if (!nextChunk) {
346
- if (options.includeDone) {
347
- writeSseEvent(res, { event: "done", data: { done: true } });
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
- cleanup();
350
- res.end();
384
+ const chunks2 = applyChunkMutations(normalizeChunks(raw2), options);
385
+ streamChunks(req, res, chunks2, options);
351
386
  return;
352
387
  }
353
- const interval = typeof nextChunk.delayMs === "number" ? nextChunk.delayMs : options.minIntervalMs + Math.floor(
354
- Math.random() * (options.maxIntervalMs - options.minIntervalMs + 1)
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
- schedule(() => writeChunk(chunks[0], 0), options.firstChunkDelayMs);
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");