mokup 0.0.0 → 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/dist/vite.mjs CHANGED
@@ -1,10 +1,12 @@
1
1
  import { cwd } from 'node:process';
2
2
  import chokidar from 'chokidar';
3
3
  import { Buffer } from 'node:buffer';
4
- import { matchRouteTokens, parseRouteTemplate, compareRouteScore } from '@mokup/runtime';
5
- import { resolve, isAbsolute, join, normalize, extname, relative, dirname, basename } from 'pathe';
4
+ import { Hono } from 'hono';
5
+ import { PatternRouter } from 'hono/router/pattern-router';
6
+ import { resolve, isAbsolute, join, normalize, extname, dirname, relative, basename } from 'pathe';
6
7
  import { promises } from 'node:fs';
7
8
  import { createRequire } from 'node:module';
9
+ import { compareRouteScore, parseRouteTemplate } from '@mokup/runtime';
8
10
  import { pathToFileURL } from 'node:url';
9
11
  import { build } from 'esbuild';
10
12
  import { parse } from 'jsonc-parser';
@@ -115,25 +117,113 @@ function delay(ms) {
115
117
  return new Promise((resolve2) => setTimeout(resolve2, ms));
116
118
  }
117
119
 
118
- function extractQuery(parsedUrl) {
119
- const query = {};
120
- for (const [key, value] of parsedUrl.searchParams.entries()) {
121
- const current = query[key];
122
- if (typeof current === "undefined") {
123
- query[key] = value;
124
- } else if (Array.isArray(current)) {
125
- current.push(value);
126
- } else {
127
- query[key] = [current, value];
120
+ function toHonoPath(route) {
121
+ if (!route.tokens || route.tokens.length === 0) {
122
+ return "/";
123
+ }
124
+ const segments = route.tokens.map((token) => {
125
+ if (token.type === "static") {
126
+ return token.value;
127
+ }
128
+ if (token.type === "param") {
129
+ return `:${token.name}`;
130
+ }
131
+ if (token.type === "catchall") {
132
+ return `:${token.name}{.+}`;
133
+ }
134
+ return `:${token.name}{.+}?`;
135
+ });
136
+ return `/${segments.join("/")}`;
137
+ }
138
+ function applyRouteOverrides(response, route) {
139
+ const headers = new Headers(response.headers);
140
+ const hasHeaders = !!route.headers && Object.keys(route.headers).length > 0;
141
+ if (route.headers) {
142
+ for (const [key, value] of Object.entries(route.headers)) {
143
+ headers.set(key, value);
144
+ }
145
+ }
146
+ const status = route.status ?? response.status;
147
+ if (status === response.status && !hasHeaders) {
148
+ return response;
149
+ }
150
+ return new Response(response.body, { status, headers });
151
+ }
152
+ function normalizeHandlerValue(c, value) {
153
+ if (value instanceof Response) {
154
+ return value;
155
+ }
156
+ if (typeof value === "undefined") {
157
+ const response = c.body(null);
158
+ if (response.status === 200) {
159
+ return new Response(response.body, {
160
+ status: 204,
161
+ headers: response.headers
162
+ });
128
163
  }
164
+ return response;
129
165
  }
130
- return query;
166
+ if (typeof value === "string") {
167
+ return c.text(value);
168
+ }
169
+ if (value instanceof Uint8Array || value instanceof ArrayBuffer) {
170
+ if (!c.res.headers.get("content-type")) {
171
+ c.header("content-type", "application/octet-stream");
172
+ }
173
+ const data = value instanceof ArrayBuffer ? new Uint8Array(value) : new Uint8Array(value);
174
+ return c.body(data);
175
+ }
176
+ return c.json(value);
177
+ }
178
+ function createRouteHandler(route) {
179
+ return async (c) => {
180
+ const value = typeof route.handler === "function" ? await route.handler(c) : route.handler;
181
+ return normalizeHandlerValue(c, value);
182
+ };
183
+ }
184
+ function createFinalizeMiddleware(route) {
185
+ return async (c, next) => {
186
+ const response = await next();
187
+ const resolved = response ?? c.res;
188
+ if (route.delay && route.delay > 0) {
189
+ await delay(route.delay);
190
+ }
191
+ return applyRouteOverrides(resolved, route);
192
+ };
193
+ }
194
+ function wrapMiddleware(handler) {
195
+ return async (c, next) => {
196
+ const response = await handler(c, next);
197
+ return response ?? c.res;
198
+ };
199
+ }
200
+ function createHonoApp(routes) {
201
+ const app = new Hono({ router: new PatternRouter(), strict: false });
202
+ for (const route of routes) {
203
+ const middlewares = route.middlewares?.map((entry) => wrapMiddleware(entry.handle)) ?? [];
204
+ app.on(
205
+ route.method,
206
+ toHonoPath(route),
207
+ createFinalizeMiddleware(route),
208
+ ...middlewares,
209
+ createRouteHandler(route)
210
+ );
211
+ }
212
+ return app;
131
213
  }
132
214
  async function readRawBody(req) {
133
- return new Promise((resolve, reject) => {
215
+ return await new Promise((resolve, reject) => {
134
216
  const chunks = [];
135
217
  req.on("data", (chunk) => {
136
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
218
+ if (typeof chunk === "string") {
219
+ chunks.push(Buffer.from(chunk));
220
+ return;
221
+ }
222
+ if (chunk instanceof Uint8Array) {
223
+ chunks.push(chunk);
224
+ return;
225
+ }
226
+ chunks.push(Buffer.from(String(chunk)));
137
227
  });
138
228
  req.on("end", () => {
139
229
  if (chunks.length === 0) {
@@ -145,121 +235,74 @@ async function readRawBody(req) {
145
235
  req.on("error", reject);
146
236
  });
147
237
  }
148
- async function readRequestBody(req, parsedUrl) {
149
- const query = extractQuery(parsedUrl);
150
- const rawBody = await readRawBody(req);
151
- if (!rawBody) {
152
- return { query, body: void 0, rawBody: void 0 };
153
- }
154
- const rawText = rawBody.toString("utf8");
155
- const contentTypeHeader = req.headers["content-type"];
156
- const contentTypeValue = Array.isArray(contentTypeHeader) ? contentTypeHeader[0] ?? "" : contentTypeHeader ?? "";
157
- const contentType = contentTypeValue.split(";")[0]?.trim() ?? "";
158
- if (contentType === "application/json" || contentType.endsWith("+json")) {
159
- try {
160
- return { query, body: JSON.parse(rawText), rawBody: rawText };
161
- } catch {
162
- return { query, body: rawText, rawBody: rawText };
238
+ function buildHeaders(headers) {
239
+ const result = new Headers();
240
+ for (const [key, value] of Object.entries(headers)) {
241
+ if (typeof value === "undefined") {
242
+ continue;
243
+ }
244
+ if (Array.isArray(value)) {
245
+ result.set(key, value.join(","));
246
+ } else {
247
+ result.set(key, value);
163
248
  }
164
249
  }
165
- if (contentType === "application/x-www-form-urlencoded") {
166
- const params = new URLSearchParams(rawText);
167
- const body = Object.fromEntries(params.entries());
168
- return { query, body, rawBody: rawText };
169
- }
170
- return { query, body: rawText, rawBody: rawText };
250
+ return result;
171
251
  }
172
- function applyHeaders(res, headers) {
173
- if (!headers) {
174
- return;
175
- }
176
- for (const [key, value] of Object.entries(headers)) {
177
- res.setHeader(key, value);
252
+ async function toRequest(req) {
253
+ const url = new URL(req.url ?? "/", "http://mokup.local");
254
+ const method = req.method ?? "GET";
255
+ const headers = buildHeaders(req.headers);
256
+ const init = { method, headers };
257
+ const rawBody = await readRawBody(req);
258
+ if (rawBody && method !== "GET" && method !== "HEAD") {
259
+ init.body = rawBody;
178
260
  }
261
+ return new Request(url.toString(), init);
179
262
  }
180
- function writeResponse(res, body) {
181
- if (typeof body === "undefined") {
182
- if (!res.headersSent && res.statusCode === 200) {
183
- res.statusCode = 204;
184
- }
263
+ async function sendResponse(res, response) {
264
+ res.statusCode = response.status;
265
+ response.headers.forEach((value, key) => {
266
+ res.setHeader(key, value);
267
+ });
268
+ if (!response.body) {
185
269
  res.end();
186
270
  return;
187
271
  }
188
- if (Buffer.isBuffer(body)) {
189
- if (!res.getHeader("Content-Type")) {
190
- res.setHeader("Content-Type", "application/octet-stream");
191
- }
192
- res.end(body);
193
- return;
194
- }
195
- if (typeof body === "string") {
196
- if (!res.getHeader("Content-Type")) {
197
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
198
- }
199
- res.end(body);
200
- return;
201
- }
202
- if (!res.getHeader("Content-Type")) {
203
- res.setHeader("Content-Type", "application/json; charset=utf-8");
204
- }
205
- res.end(JSON.stringify(body));
272
+ const buffer = new Uint8Array(await response.arrayBuffer());
273
+ res.end(buffer);
206
274
  }
207
- function findMatchingRoute(routes, method, pathname) {
208
- for (const route of routes) {
209
- if (route.method !== method) {
210
- continue;
211
- }
212
- const matched = matchRouteTokens(route.tokens, pathname);
213
- if (matched) {
214
- return { route, params: matched.params };
215
- }
216
- }
217
- return null;
275
+ function hasMatch(app, method, pathname) {
276
+ const matchMethod = method === "HEAD" ? "GET" : method;
277
+ const match = app.router.match(matchMethod, pathname);
278
+ return !!match && match[0].length > 0;
218
279
  }
219
- function createMiddleware(getRoutes, logger) {
280
+ function createMiddleware(getApp, logger) {
220
281
  return async (req, res, next) => {
282
+ const app = getApp();
283
+ if (!app) {
284
+ return next();
285
+ }
221
286
  const url = req.url ?? "/";
222
287
  const parsedUrl = new URL(url, "http://mokup.local");
223
288
  const pathname = parsedUrl.pathname;
224
289
  const method = normalizeMethod(req.method) ?? "GET";
225
- const matched = findMatchingRoute(getRoutes(), method, pathname);
226
- if (!matched) {
290
+ if (!hasMatch(app, method, pathname)) {
227
291
  return next();
228
292
  }
293
+ const startedAt = Date.now();
229
294
  try {
230
- const { query, body, rawBody } = await readRequestBody(req, parsedUrl);
231
- const mockReq = {
232
- url: pathname,
233
- method,
234
- headers: req.headers,
235
- query,
236
- body,
237
- params: matched.params
238
- };
239
- if (rawBody) {
240
- mockReq.rawBody = rawBody;
241
- }
242
- const ctx = {
243
- delay: (ms) => delay(ms),
244
- json: (data) => data
245
- };
246
- const startedAt = Date.now();
247
- const responseValue = typeof matched.route.response === "function" ? await matched.route.response(mockReq, res, ctx) : matched.route.response;
295
+ const response = await app.fetch(await toRequest(req));
248
296
  if (res.writableEnded) {
249
297
  return;
250
298
  }
251
- if (matched.route.delay && matched.route.delay > 0) {
252
- await delay(matched.route.delay);
253
- }
254
- applyHeaders(res, matched.route.headers);
255
- if (matched.route.status) {
256
- res.statusCode = matched.route.status;
257
- }
258
- writeResponse(res, responseValue);
299
+ await sendResponse(res, response);
259
300
  logger.info(`${method} ${pathname} ${Date.now() - startedAt}ms`);
260
301
  } catch (error) {
261
- res.statusCode = 500;
262
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
302
+ if (!res.headersSent) {
303
+ res.statusCode = 500;
304
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
305
+ }
263
306
  res.end("Mock handler error");
264
307
  logger.error("Mock handler failed:", error);
265
308
  }
@@ -286,6 +329,43 @@ function normalizePlaygroundPath(value) {
286
329
  const normalized = value.startsWith("/") ? value : `/${value}`;
287
330
  return normalized.length > 1 && normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
288
331
  }
332
+ function normalizeBase(base) {
333
+ if (!base || base === "/") {
334
+ return "";
335
+ }
336
+ return base.endsWith("/") ? base.slice(0, -1) : base;
337
+ }
338
+ function injectPlaygroundHmr(html, base) {
339
+ if (html.includes("mokup-playground-hmr")) {
340
+ return html;
341
+ }
342
+ const normalizedBase = normalizeBase(base);
343
+ const clientPath = `${normalizedBase}/@vite/client`;
344
+ const snippet = [
345
+ '<script type="module" id="mokup-playground-hmr">',
346
+ `import('${clientPath}').then(({ createHotContext }) => {`,
347
+ " const hot = createHotContext('/@mokup/playground')",
348
+ " hot.on('mokup:routes-changed', () => {",
349
+ " const api = window.__MOKUP_PLAYGROUND__",
350
+ " if (api && typeof api.reloadRoutes === 'function') {",
351
+ " api.reloadRoutes()",
352
+ " return",
353
+ " }",
354
+ " window.location.reload()",
355
+ " })",
356
+ "}).catch(() => {})",
357
+ "<\/script>"
358
+ ].join("\n");
359
+ if (html.includes("</body>")) {
360
+ return html.replace("</body>", `${snippet}
361
+ </body>`);
362
+ }
363
+ return `${html}
364
+ ${snippet}`;
365
+ }
366
+ function isViteDevServer$1(server) {
367
+ return !!server && "ws" in server;
368
+ }
289
369
  function resolvePlaygroundOptions(playground) {
290
370
  if (playground === false) {
291
371
  return { enabled: false, path: "/_mokup" };
@@ -312,14 +392,105 @@ function sendFile(res, content, contentType) {
312
392
  res.setHeader("Content-Type", contentType);
313
393
  res.end(content);
314
394
  }
315
- function toPlaygroundRoute(route) {
395
+ function toPosixPath(value) {
396
+ return value.replace(/\\/g, "/");
397
+ }
398
+ function normalizePath(value) {
399
+ return toPosixPath(normalize(value));
400
+ }
401
+ function isAncestor(parent, child) {
402
+ const normalizedParent = normalizePath(parent).replace(/\/$/, "");
403
+ const normalizedChild = normalizePath(child);
404
+ return normalizedChild === normalizedParent || normalizedChild.startsWith(`${normalizedParent}/`);
405
+ }
406
+ function resolveGroupRoot(dirs, serverRoot) {
407
+ if (!dirs || dirs.length === 0) {
408
+ return serverRoot ?? cwd();
409
+ }
410
+ if (serverRoot) {
411
+ const normalizedRoot = normalizePath(serverRoot);
412
+ const canUseRoot = dirs.every((dir) => isAncestor(normalizedRoot, dir));
413
+ if (canUseRoot) {
414
+ return normalizedRoot;
415
+ }
416
+ }
417
+ if (dirs.length === 1) {
418
+ return normalizePath(dirname(dirs[0]));
419
+ }
420
+ let common = normalizePath(dirs[0]);
421
+ for (const dir of dirs.slice(1)) {
422
+ const normalizedDir = normalizePath(dir);
423
+ while (common && !isAncestor(common, normalizedDir)) {
424
+ const parent = normalizePath(dirname(common));
425
+ if (parent === common) {
426
+ break;
427
+ }
428
+ common = parent;
429
+ }
430
+ }
431
+ if (!common || common === "/") {
432
+ return serverRoot ?? cwd();
433
+ }
434
+ return common;
435
+ }
436
+ function formatRouteFile(file, root) {
437
+ if (!root) {
438
+ return toPosixPath(file);
439
+ }
440
+ const rel = toPosixPath(relative(root, file));
441
+ if (!rel || rel.startsWith("..")) {
442
+ return toPosixPath(file);
443
+ }
444
+ return rel;
445
+ }
446
+ function resolveGroups(dirs, root) {
447
+ const groups = [];
448
+ const seen = /* @__PURE__ */ new Set();
449
+ for (const dir of dirs) {
450
+ const normalized = normalizePath(dir);
451
+ if (seen.has(normalized)) {
452
+ continue;
453
+ }
454
+ seen.add(normalized);
455
+ const rel = toPosixPath(relative(root, normalized));
456
+ const label = rel && !rel.startsWith("..") ? rel : normalized;
457
+ groups.push({
458
+ key: normalized,
459
+ label,
460
+ path: normalized
461
+ });
462
+ }
463
+ return groups;
464
+ }
465
+ function resolveRouteGroup(routeFile, groups) {
466
+ if (groups.length === 0) {
467
+ return void 0;
468
+ }
469
+ const normalizedFile = toPosixPath(normalize(routeFile));
470
+ let matched;
471
+ for (const group of groups) {
472
+ if (normalizedFile === group.path || normalizedFile.startsWith(`${group.path}/`)) {
473
+ if (!matched || group.path.length > matched.path.length) {
474
+ matched = group;
475
+ }
476
+ }
477
+ }
478
+ return matched;
479
+ }
480
+ function toPlaygroundRoute(route, root, groups) {
481
+ const matchedGroup = resolveRouteGroup(route.file, groups);
482
+ const middlewareSources = route.middlewares?.map((entry) => formatRouteFile(entry.source, root));
316
483
  return {
317
484
  method: route.method,
318
485
  url: route.template,
319
- file: route.file,
320
- type: typeof route.response === "function" ? "handler" : "static",
486
+ file: formatRouteFile(route.file, root),
487
+ type: typeof route.handler === "function" ? "handler" : "static",
321
488
  status: route.status,
322
- delay: route.delay
489
+ delay: route.delay,
490
+ middlewareCount: middlewareSources?.length ?? 0,
491
+ middlewares: middlewareSources,
492
+ groupKey: matchedGroup?.key,
493
+ group: matchedGroup?.label
323
494
  };
324
495
  }
325
496
  function createPlaygroundMiddleware(params) {
@@ -337,11 +508,20 @@ function createPlaygroundMiddleware(params) {
337
508
  return next();
338
509
  }
339
510
  const subPath = pathname.slice(playgroundPath.length);
511
+ if (subPath === "") {
512
+ const suffix = url.search ?? "";
513
+ res.statusCode = 302;
514
+ res.setHeader("Location", `${playgroundPath}/${suffix}`);
515
+ res.end();
516
+ return;
517
+ }
340
518
  if (subPath === "" || subPath === "/" || subPath === "/index.html") {
341
519
  try {
342
520
  const html = await promises.readFile(indexPath, "utf8");
521
+ const server = params.getServer?.();
522
+ const output = isViteDevServer$1(server) ? injectPlaygroundHmr(html, server.config.base ?? "/") : html;
343
523
  const contentType = mimeTypes[".html"] ?? "text/html; charset=utf-8";
344
- sendFile(res, html, contentType);
524
+ sendFile(res, output, contentType);
345
525
  } catch (error) {
346
526
  params.logger.error("Failed to load playground index:", error);
347
527
  res.statusCode = 500;
@@ -350,11 +530,16 @@ function createPlaygroundMiddleware(params) {
350
530
  return;
351
531
  }
352
532
  if (subPath === "/routes") {
533
+ const server = params.getServer?.();
534
+ const dirs = params.getDirs?.() ?? [];
535
+ const baseRoot = resolveGroupRoot(dirs, server?.config?.root);
536
+ const groups = resolveGroups(dirs, baseRoot);
353
537
  const routes = params.getRoutes();
354
538
  sendJson(res, {
355
539
  basePath: playgroundPath,
356
540
  count: routes.length,
357
- routes: routes.map(toPlaygroundRoute)
541
+ groups: groups.map((group) => ({ key: group.key, label: group.label })),
542
+ routes: routes.map((route) => toPlaygroundRoute(route, baseRoot, groups))
358
543
  });
359
544
  return;
360
545
  }
@@ -377,6 +562,267 @@ function createPlaygroundMiddleware(params) {
377
562
  };
378
563
  }
379
564
 
565
+ const jsonExtensions = /* @__PURE__ */ new Set([".json", ".jsonc"]);
566
+ function resolveTemplate(template, prefix) {
567
+ const normalized = template.startsWith("/") ? template : `/${template}`;
568
+ if (!prefix) {
569
+ return normalized;
570
+ }
571
+ const normalizedPrefix = normalizePrefix(prefix);
572
+ if (!normalizedPrefix) {
573
+ return normalized;
574
+ }
575
+ if (normalized === normalizedPrefix || normalized.startsWith(`${normalizedPrefix}/`)) {
576
+ return normalized;
577
+ }
578
+ if (normalized === "/") {
579
+ return `${normalizedPrefix}/`;
580
+ }
581
+ return `${normalizedPrefix}${normalized}`;
582
+ }
583
+ function stripMethodSuffix(base) {
584
+ const segments = base.split(".");
585
+ const last = segments.at(-1);
586
+ if (last && methodSuffixSet.has(last.toLowerCase())) {
587
+ segments.pop();
588
+ return {
589
+ name: segments.join("."),
590
+ method: last.toUpperCase()
591
+ };
592
+ }
593
+ return {
594
+ name: base,
595
+ method: void 0
596
+ };
597
+ }
598
+ function deriveRouteFromFile(file, rootDir, logger) {
599
+ const rel = toPosix(relative(rootDir, file));
600
+ const ext = extname(rel);
601
+ const withoutExt = rel.slice(0, rel.length - ext.length);
602
+ const dir = dirname(withoutExt);
603
+ const base = basename(withoutExt);
604
+ const { name, method } = stripMethodSuffix(base);
605
+ const resolvedMethod = method ?? (jsonExtensions.has(ext) ? "GET" : void 0);
606
+ if (!resolvedMethod) {
607
+ logger.warn(`Skip mock without method suffix: ${file}`);
608
+ return null;
609
+ }
610
+ if (!name) {
611
+ logger.warn(`Skip mock with empty route name: ${file}`);
612
+ return null;
613
+ }
614
+ const joined = dir === "." ? name : join(dir, name);
615
+ const segments = toPosix(joined).split("/");
616
+ if (segments.at(-1) === "index") {
617
+ segments.pop();
618
+ }
619
+ const template = segments.length === 0 ? "/" : `/${segments.join("/")}`;
620
+ const parsed = parseRouteTemplate(template);
621
+ if (parsed.errors.length > 0) {
622
+ for (const error of parsed.errors) {
623
+ logger.warn(`${error} in ${file}`);
624
+ }
625
+ return null;
626
+ }
627
+ for (const warning of parsed.warnings) {
628
+ logger.warn(`${warning} in ${file}`);
629
+ }
630
+ return {
631
+ template: parsed.template,
632
+ method: resolvedMethod,
633
+ tokens: parsed.tokens,
634
+ score: parsed.score
635
+ };
636
+ }
637
+ function resolveRule(params) {
638
+ const method = params.derivedMethod;
639
+ if (!method) {
640
+ params.logger.warn(`Skip mock without method suffix: ${params.file}`);
641
+ return null;
642
+ }
643
+ const template = resolveTemplate(params.derivedTemplate, params.prefix);
644
+ const parsed = parseRouteTemplate(template);
645
+ if (parsed.errors.length > 0) {
646
+ for (const error of parsed.errors) {
647
+ params.logger.warn(`${error} in ${params.file}`);
648
+ }
649
+ return null;
650
+ }
651
+ for (const warning of parsed.warnings) {
652
+ params.logger.warn(`${warning} in ${params.file}`);
653
+ }
654
+ const route = {
655
+ file: params.file,
656
+ template: parsed.template,
657
+ method,
658
+ tokens: parsed.tokens,
659
+ score: parsed.score,
660
+ handler: params.rule.handler
661
+ };
662
+ if (typeof params.rule.status === "number") {
663
+ route.status = params.rule.status;
664
+ }
665
+ if (params.rule.headers) {
666
+ route.headers = params.rule.headers;
667
+ }
668
+ if (typeof params.rule.delay === "number") {
669
+ route.delay = params.rule.delay;
670
+ }
671
+ return route;
672
+ }
673
+ function sortRoutes(routes) {
674
+ return routes.sort((a, b) => {
675
+ if (a.method !== b.method) {
676
+ return a.method.localeCompare(b.method);
677
+ }
678
+ const scoreCompare = compareRouteScore(a.score, b.score);
679
+ if (scoreCompare !== 0) {
680
+ return scoreCompare;
681
+ }
682
+ return a.template.localeCompare(b.template);
683
+ });
684
+ }
685
+
686
+ const configExtensions = [".ts", ".js", ".mjs", ".cjs"];
687
+ async function loadModule$1(file) {
688
+ const ext = configExtensions.find((extension) => file.endsWith(extension));
689
+ if (ext === ".cjs") {
690
+ const require = createRequire(import.meta.url);
691
+ delete require.cache[file];
692
+ return require(file);
693
+ }
694
+ if (ext === ".js" || ext === ".mjs") {
695
+ return import(`${pathToFileURL(file).href}?t=${Date.now()}`);
696
+ }
697
+ if (ext === ".ts") {
698
+ const result = await build({
699
+ entryPoints: [file],
700
+ bundle: true,
701
+ format: "esm",
702
+ platform: "node",
703
+ sourcemap: "inline",
704
+ target: "es2020",
705
+ write: false
706
+ });
707
+ const output = result.outputFiles[0];
708
+ const code = output?.text ?? "";
709
+ const dataUrl = `data:text/javascript;base64,${Buffer.from(code).toString(
710
+ "base64"
711
+ )}`;
712
+ return import(`${dataUrl}#${Date.now()}`);
713
+ }
714
+ return null;
715
+ }
716
+ async function loadModuleWithVite$1(server, file) {
717
+ const asDevServer = server;
718
+ if ("ssrLoadModule" in asDevServer) {
719
+ const moduleNode = asDevServer.moduleGraph.getModuleById(file);
720
+ if (moduleNode) {
721
+ asDevServer.moduleGraph.invalidateModule(moduleNode);
722
+ }
723
+ return asDevServer.ssrLoadModule(file);
724
+ }
725
+ return loadModule$1(file);
726
+ }
727
+ function getConfigFileCandidates(dir) {
728
+ return configExtensions.map((extension) => join(dir, `index.config${extension}`));
729
+ }
730
+ async function findConfigFile(dir, cache) {
731
+ const cached = cache.get(dir);
732
+ if (cached !== void 0) {
733
+ return cached;
734
+ }
735
+ for (const candidate of getConfigFileCandidates(dir)) {
736
+ try {
737
+ await promises.stat(candidate);
738
+ cache.set(dir, candidate);
739
+ return candidate;
740
+ } catch {
741
+ continue;
742
+ }
743
+ }
744
+ cache.set(dir, null);
745
+ return null;
746
+ }
747
+ async function loadConfig(file, server) {
748
+ const mod = server ? await loadModuleWithVite$1(server, file) : await loadModule$1(file);
749
+ if (!mod) {
750
+ return null;
751
+ }
752
+ const value = mod?.default ?? mod;
753
+ if (!value || typeof value !== "object") {
754
+ return null;
755
+ }
756
+ return value;
757
+ }
758
+ function normalizeMiddlewares(value, source, logger) {
759
+ if (!value) {
760
+ return [];
761
+ }
762
+ const list = Array.isArray(value) ? value : [value];
763
+ const middlewares = [];
764
+ for (const entry of list) {
765
+ if (typeof entry !== "function") {
766
+ logger.warn(`Invalid middleware in ${source}`);
767
+ continue;
768
+ }
769
+ middlewares.push({ handle: entry, source });
770
+ }
771
+ return middlewares;
772
+ }
773
+ async function resolveDirectoryConfig(params) {
774
+ const { file, rootDir, server, logger, configCache, fileCache } = params;
775
+ const resolvedRoot = normalize(rootDir);
776
+ const resolvedFileDir = normalize(dirname(file));
777
+ const chain = [];
778
+ let current = resolvedFileDir;
779
+ while (true) {
780
+ chain.push(current);
781
+ if (current === resolvedRoot) {
782
+ break;
783
+ }
784
+ const parent = dirname(current);
785
+ if (parent === current) {
786
+ break;
787
+ }
788
+ current = parent;
789
+ }
790
+ chain.reverse();
791
+ const merged = { middlewares: [] };
792
+ for (const dir of chain) {
793
+ const configPath = await findConfigFile(dir, fileCache);
794
+ if (!configPath) {
795
+ continue;
796
+ }
797
+ let config = configCache.get(configPath);
798
+ if (config === void 0) {
799
+ config = await loadConfig(configPath, server);
800
+ configCache.set(configPath, config);
801
+ }
802
+ if (!config) {
803
+ logger.warn(`Invalid config in ${configPath}`);
804
+ continue;
805
+ }
806
+ if (config.headers) {
807
+ merged.headers = { ...merged.headers ?? {}, ...config.headers };
808
+ }
809
+ if (typeof config.status === "number") {
810
+ merged.status = config.status;
811
+ }
812
+ if (typeof config.delay === "number") {
813
+ merged.delay = config.delay;
814
+ }
815
+ if (typeof config.enabled === "boolean") {
816
+ merged.enabled = config.enabled;
817
+ }
818
+ const normalized = normalizeMiddlewares(config.middleware, configPath, logger);
819
+ if (normalized.length > 0) {
820
+ merged.middlewares.push(...normalized);
821
+ }
822
+ }
823
+ return merged;
824
+ }
825
+
380
826
  async function walkDir(dir, rootDir, files) {
381
827
  const entries = await promises.readdir(dir, { withFileTypes: true });
382
828
  for (const entry of entries) {
@@ -415,6 +861,9 @@ function isSupportedFile(file) {
415
861
  if (file.endsWith(".d.ts")) {
416
862
  return false;
417
863
  }
864
+ if (basename(file).startsWith("index.config.")) {
865
+ return false;
866
+ }
418
867
  const ext = extname(file).toLowerCase();
419
868
  return supportedExtensions.has(ext);
420
869
  }
@@ -486,7 +935,7 @@ async function loadRules(file, server, logger) {
486
935
  }
487
936
  return [
488
937
  {
489
- response: json
938
+ handler: json
490
939
  }
491
940
  ];
492
941
  }
@@ -501,149 +950,19 @@ async function loadRules(file, server, logger) {
501
950
  if (typeof value === "function") {
502
951
  return [
503
952
  {
504
- response: value
953
+ handler: value
505
954
  }
506
955
  ];
507
956
  }
508
957
  return [value];
509
958
  }
510
959
 
511
- function resolveMethod(fileMethod, ruleMethod, logger) {
512
- if (ruleMethod) {
513
- const normalized = normalizeMethod(ruleMethod);
514
- if (normalized) {
515
- return normalized;
516
- }
517
- logger.warn(`Unknown method "${ruleMethod}", falling back to file method`);
518
- }
519
- if (fileMethod) {
520
- return fileMethod;
521
- }
522
- return "GET";
523
- }
524
- function resolveTemplate(template, prefix) {
525
- const normalized = template.startsWith("/") ? template : `/${template}`;
526
- if (!prefix) {
527
- return normalized;
528
- }
529
- const normalizedPrefix = normalizePrefix(prefix);
530
- if (!normalizedPrefix) {
531
- return normalized;
532
- }
533
- if (normalized === normalizedPrefix || normalized.startsWith(`${normalizedPrefix}/`)) {
534
- return normalized;
535
- }
536
- if (normalized === "/") {
537
- return `${normalizedPrefix}/`;
538
- }
539
- return `${normalizedPrefix}${normalized}`;
540
- }
541
- function stripMethodSuffix(base) {
542
- const segments = base.split(".");
543
- const last = segments.at(-1);
544
- if (last && methodSuffixSet.has(last.toLowerCase())) {
545
- segments.pop();
546
- return {
547
- name: segments.join("."),
548
- method: last.toUpperCase()
549
- };
550
- }
551
- return {
552
- name: base,
553
- method: void 0
554
- };
555
- }
556
- function deriveRouteFromFile(file, rootDir, logger) {
557
- const rel = toPosix(relative(rootDir, file));
558
- const ext = extname(rel);
559
- const withoutExt = rel.slice(0, rel.length - ext.length);
560
- const dir = dirname(withoutExt);
561
- const base = basename(withoutExt);
562
- const { name, method } = stripMethodSuffix(base);
563
- if (!method) {
564
- logger.warn(`Skip mock without method suffix: ${file}`);
565
- return null;
566
- }
567
- if (!name) {
568
- logger.warn(`Skip mock with empty route name: ${file}`);
569
- return null;
570
- }
571
- const joined = dir === "." ? name : join(dir, name);
572
- const segments = toPosix(joined).split("/");
573
- if (segments.at(-1) === "index") {
574
- segments.pop();
575
- }
576
- const template = segments.length === 0 ? "/" : `/${segments.join("/")}`;
577
- const parsed = parseRouteTemplate(template);
578
- if (parsed.errors.length > 0) {
579
- for (const error of parsed.errors) {
580
- logger.warn(`${error} in ${file}`);
581
- }
582
- return null;
583
- }
584
- for (const warning of parsed.warnings) {
585
- logger.warn(`${warning} in ${file}`);
586
- }
587
- return {
588
- template: parsed.template,
589
- method,
590
- tokens: parsed.tokens,
591
- score: parsed.score
592
- };
593
- }
594
- function resolveRule(params) {
595
- const method = resolveMethod(params.derivedMethod, params.rule.method, params.logger);
596
- if (!method) {
597
- params.logger.warn(`Invalid mock method in ${params.file}`);
598
- return null;
599
- }
600
- const template = resolveTemplate(params.rule.url ?? params.derivedTemplate, params.prefix);
601
- const parsed = parseRouteTemplate(template);
602
- if (parsed.errors.length > 0) {
603
- for (const error of parsed.errors) {
604
- params.logger.warn(`${error} in ${params.file}`);
605
- }
606
- return null;
607
- }
608
- for (const warning of parsed.warnings) {
609
- params.logger.warn(`${warning} in ${params.file}`);
610
- }
611
- const route = {
612
- file: params.file,
613
- template: parsed.template,
614
- method,
615
- tokens: parsed.tokens,
616
- score: parsed.score,
617
- response: params.rule.response
618
- };
619
- if (typeof params.rule.status === "number") {
620
- route.status = params.rule.status;
621
- }
622
- if (params.rule.headers) {
623
- route.headers = params.rule.headers;
624
- }
625
- if (typeof params.rule.delay === "number") {
626
- route.delay = params.rule.delay;
627
- }
628
- return route;
629
- }
630
- function sortRoutes(routes) {
631
- return routes.sort((a, b) => {
632
- if (a.method !== b.method) {
633
- return a.method.localeCompare(b.method);
634
- }
635
- const scoreCompare = compareRouteScore(a.score, b.score);
636
- if (scoreCompare !== 0) {
637
- return scoreCompare;
638
- }
639
- return a.template.localeCompare(b.template);
640
- });
641
- }
642
-
643
960
  async function scanRoutes(params) {
644
961
  const routes = [];
645
962
  const seen = /* @__PURE__ */ new Set();
646
963
  const files = await collectFiles(params.dirs);
964
+ const configCache = /* @__PURE__ */ new Map();
965
+ const fileCache = /* @__PURE__ */ new Map();
647
966
  for (const fileInfo of files) {
648
967
  if (!isSupportedFile(fileInfo.file)) {
649
968
  continue;
@@ -651,6 +970,20 @@ async function scanRoutes(params) {
651
970
  if (!matchesFilter(fileInfo.file, params.include, params.exclude)) {
652
971
  continue;
653
972
  }
973
+ const configParams = {
974
+ file: fileInfo.file,
975
+ rootDir: fileInfo.rootDir,
976
+ logger: params.logger,
977
+ configCache,
978
+ fileCache
979
+ };
980
+ if (params.server) {
981
+ configParams.server = params.server;
982
+ }
983
+ const config = await resolveDirectoryConfig(configParams);
984
+ if (config.enabled === false) {
985
+ continue;
986
+ }
654
987
  const derived = deriveRouteFromFile(fileInfo.file, fileInfo.rootDir, params.logger);
655
988
  if (!derived) {
656
989
  continue;
@@ -660,8 +993,18 @@ async function scanRoutes(params) {
660
993
  if (!rule || typeof rule !== "object") {
661
994
  continue;
662
995
  }
663
- if (typeof rule.response === "undefined") {
664
- params.logger.warn(`Skip mock without response: ${fileInfo.file}`);
996
+ const ruleValue = rule;
997
+ const unsupportedKeys = ["response", "url", "method"].filter(
998
+ (key2) => key2 in ruleValue
999
+ );
1000
+ if (unsupportedKeys.length > 0) {
1001
+ params.logger.warn(
1002
+ `Skip mock with unsupported fields (${unsupportedKeys.join(", ")}): ${fileInfo.file}`
1003
+ );
1004
+ continue;
1005
+ }
1006
+ if (typeof rule.handler === "undefined") {
1007
+ params.logger.warn(`Skip mock without handler: ${fileInfo.file}`);
665
1008
  continue;
666
1009
  }
667
1010
  const resolved = resolveRule({
@@ -675,6 +1018,18 @@ async function scanRoutes(params) {
675
1018
  if (!resolved) {
676
1019
  continue;
677
1020
  }
1021
+ if (config.headers) {
1022
+ resolved.headers = { ...config.headers, ...resolved.headers ?? {} };
1023
+ }
1024
+ if (typeof resolved.status === "undefined" && typeof config.status === "number") {
1025
+ resolved.status = config.status;
1026
+ }
1027
+ if (typeof resolved.delay === "undefined" && typeof config.delay === "number") {
1028
+ resolved.delay = config.delay;
1029
+ }
1030
+ if (config.middlewares.length > 0) {
1031
+ resolved.middlewares = config.middlewares;
1032
+ }
678
1033
  const key = `${resolved.method} ${resolved.template}`;
679
1034
  if (seen.has(key)) {
680
1035
  params.logger.warn(`Duplicate mock route ${key} from ${fileInfo.file}`);
@@ -686,35 +1041,100 @@ async function scanRoutes(params) {
686
1041
  return sortRoutes(routes);
687
1042
  }
688
1043
 
1044
+ function buildRouteSignature(routes) {
1045
+ return routes.map(
1046
+ (route) => [
1047
+ route.method,
1048
+ route.template,
1049
+ route.file,
1050
+ typeof route.handler === "function" ? "handler" : "static",
1051
+ route.status ?? "",
1052
+ route.delay ?? ""
1053
+ ].join("|")
1054
+ ).join("\n");
1055
+ }
1056
+ function isViteDevServer(server) {
1057
+ return !!server && "ws" in server;
1058
+ }
1059
+ function normalizeOptions(options) {
1060
+ const list = Array.isArray(options) ? options : [options];
1061
+ return list.length > 0 ? list : [{}];
1062
+ }
1063
+ function resolvePlaygroundInput(list) {
1064
+ for (const entry of list) {
1065
+ if (typeof entry.playground !== "undefined") {
1066
+ return entry.playground;
1067
+ }
1068
+ }
1069
+ return void 0;
1070
+ }
689
1071
  function createMokupPlugin(options = {}) {
690
1072
  let root = cwd();
691
1073
  let routes = [];
1074
+ let app = null;
692
1075
  let previewWatcher = null;
693
- const logger = createLogger(options.log !== false);
694
- const watchEnabled = options.watch !== false;
695
- const playgroundConfig = resolvePlaygroundOptions(options.playground);
1076
+ let currentServer = null;
1077
+ let lastSignature = null;
1078
+ const optionList = normalizeOptions(options);
1079
+ const logEnabled = optionList.every((entry) => entry.log !== false);
1080
+ const watchEnabled = optionList.every((entry) => entry.watch !== false);
1081
+ const playgroundConfig = resolvePlaygroundOptions(resolvePlaygroundInput(optionList));
1082
+ const logger = createLogger(logEnabled);
1083
+ const resolveAllDirs = () => {
1084
+ const dirs = [];
1085
+ const seen = /* @__PURE__ */ new Set();
1086
+ for (const entry of optionList) {
1087
+ for (const dir of resolveDirs(entry.dir, root)) {
1088
+ if (seen.has(dir)) {
1089
+ continue;
1090
+ }
1091
+ seen.add(dir);
1092
+ dirs.push(dir);
1093
+ }
1094
+ }
1095
+ return dirs;
1096
+ };
696
1097
  const playgroundMiddleware = createPlaygroundMiddleware({
697
1098
  getRoutes: () => routes,
698
1099
  config: playgroundConfig,
699
- logger
1100
+ logger,
1101
+ getServer: () => currentServer,
1102
+ getDirs: () => resolveAllDirs()
700
1103
  });
701
1104
  const refreshRoutes = async (server) => {
702
- const dirs = resolveDirs(options.dir, root);
703
- const scanParams = {
704
- dirs,
705
- prefix: options.prefix ?? "",
706
- logger
707
- };
708
- if (options.include) {
709
- scanParams.include = options.include;
710
- }
711
- if (options.exclude) {
712
- scanParams.exclude = options.exclude;
1105
+ const collected = [];
1106
+ for (const entry of optionList) {
1107
+ const dirs = resolveDirs(entry.dir, root);
1108
+ const scanParams = {
1109
+ dirs,
1110
+ prefix: entry.prefix ?? "",
1111
+ logger
1112
+ };
1113
+ if (entry.include) {
1114
+ scanParams.include = entry.include;
1115
+ }
1116
+ if (entry.exclude) {
1117
+ scanParams.exclude = entry.exclude;
1118
+ }
1119
+ if (server) {
1120
+ scanParams.server = server;
1121
+ }
1122
+ const scanned = await scanRoutes(scanParams);
1123
+ collected.push(...scanned);
713
1124
  }
714
- if (server) {
715
- scanParams.server = server;
1125
+ routes = sortRoutes(collected);
1126
+ app = createHonoApp(routes);
1127
+ const signature = buildRouteSignature(routes);
1128
+ if (isViteDevServer(server) && server.ws) {
1129
+ if (lastSignature && signature !== lastSignature) {
1130
+ server.ws.send({
1131
+ type: "custom",
1132
+ event: "mokup:routes-changed",
1133
+ data: { ts: Date.now() }
1134
+ });
1135
+ }
716
1136
  }
717
- routes = await scanRoutes(scanParams);
1137
+ lastSignature = signature;
718
1138
  };
719
1139
  return {
720
1140
  name: "mokup:vite",
@@ -723,13 +1143,14 @@ function createMokupPlugin(options = {}) {
723
1143
  root = config.root;
724
1144
  },
725
1145
  async configureServer(server) {
1146
+ currentServer = server;
726
1147
  await refreshRoutes(server);
727
1148
  server.middlewares.use(playgroundMiddleware);
728
- server.middlewares.use(createMiddleware(() => routes, logger));
1149
+ server.middlewares.use(createMiddleware(() => app, logger));
729
1150
  if (!watchEnabled) {
730
1151
  return;
731
1152
  }
732
- const dirs = resolveDirs(options.dir, root);
1153
+ const dirs = resolveAllDirs();
733
1154
  server.watcher.add(dirs);
734
1155
  const scheduleRefresh = createDebouncer(80, () => refreshRoutes(server));
735
1156
  server.watcher.on("add", (file) => {
@@ -749,13 +1170,14 @@ function createMokupPlugin(options = {}) {
749
1170
  });
750
1171
  },
751
1172
  async configurePreviewServer(server) {
1173
+ currentServer = server;
752
1174
  await refreshRoutes(server);
753
1175
  server.middlewares.use(playgroundMiddleware);
754
- server.middlewares.use(createMiddleware(() => routes, logger));
1176
+ server.middlewares.use(createMiddleware(() => app, logger));
755
1177
  if (!watchEnabled) {
756
1178
  return;
757
1179
  }
758
- const dirs = resolveDirs(options.dir, root);
1180
+ const dirs = resolveAllDirs();
759
1181
  previewWatcher = chokidar.watch(dirs, { ignoreInitial: true });
760
1182
  const scheduleRefresh = createDebouncer(80, () => refreshRoutes(server));
761
1183
  previewWatcher.on("add", scheduleRefresh);