@websublime/vite-plugin-open-api-server 0.24.0-next.0 → 0.24.0-next.10

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/index.js CHANGED
@@ -1,11 +1,13 @@
1
- import { existsSync } from 'fs';
2
1
  import { createRequire } from 'module';
3
- import path2, { dirname, join } from 'path';
4
- import { fileURLToPath } from 'url';
5
- import { createOpenApiServer, executeSeeds } from '@websublime/vite-plugin-open-api-core';
6
- export { defineHandlers, defineSeeds } from '@websublime/vite-plugin-open-api-core';
7
2
  import pc from 'picocolors';
3
+ import { existsSync } from 'fs';
4
+ import path, { dirname, join } from 'path';
5
+ import { executeSeeds, createOpenApiServer, mountDevToolsRoutes, createWebSocketHub } from '@websublime/vite-plugin-open-api-core';
6
+ export { defineHandlers, defineSeeds } from '@websublime/vite-plugin-open-api-core';
8
7
  import fg from 'fast-glob';
8
+ import { fileURLToPath } from 'url';
9
+ import { Hono } from 'hono';
10
+ import { cors } from 'hono/cors';
9
11
 
10
12
  // src/plugin.ts
11
13
  function printBanner(info, options) {
@@ -109,7 +111,7 @@ async function directoryExists(dirPath) {
109
111
  // src/handlers.ts
110
112
  async function loadHandlers(handlersDir, viteServer, cwd = process.cwd(), logger = console) {
111
113
  const handlers = /* @__PURE__ */ new Map();
112
- const absoluteDir = path2.resolve(cwd, handlersDir);
114
+ const absoluteDir = path.resolve(cwd, handlersDir);
113
115
  const dirExists = await directoryExists(absoluteDir);
114
116
  if (!dirExists) {
115
117
  return {
@@ -126,7 +128,7 @@ async function loadHandlers(handlersDir, viteServer, cwd = process.cwd(), logger
126
128
  ignore: ["node_modules/**", "dist/**"]
127
129
  });
128
130
  for (const file of files) {
129
- const absolutePath = path2.join(absoluteDir, file);
131
+ const absolutePath = path.join(absoluteDir, file);
130
132
  const fileHandlers = await loadHandlerFile(absolutePath, viteServer, logger);
131
133
  for (const [operationId, handler] of Object.entries(fileHandlers)) {
132
134
  if (handlers.has(operationId)) {
@@ -173,7 +175,7 @@ async function loadHandlerFile(filePath, viteServer, logger) {
173
175
  }
174
176
  }
175
177
  async function getHandlerFiles(handlersDir, cwd = process.cwd()) {
176
- const absoluteDir = path2.resolve(cwd, handlersDir);
178
+ const absoluteDir = path.resolve(cwd, handlersDir);
177
179
  const dirExists = await directoryExists(absoluteDir);
178
180
  if (!dirExists) {
179
181
  return [];
@@ -187,6 +189,90 @@ async function getHandlerFiles(handlersDir, cwd = process.cwd()) {
187
189
  });
188
190
  return files;
189
191
  }
192
+ async function loadSeeds(seedsDir, viteServer, cwd = process.cwd(), logger = console) {
193
+ const seeds = /* @__PURE__ */ new Map();
194
+ const absoluteDir = path.resolve(cwd, seedsDir);
195
+ const dirExists = await directoryExists(absoluteDir);
196
+ if (!dirExists) {
197
+ return {
198
+ seeds,
199
+ fileCount: 0,
200
+ files: []
201
+ };
202
+ }
203
+ const pattern = "**/*.seeds.{ts,js,mjs}";
204
+ const files = await fg(pattern, {
205
+ cwd: absoluteDir,
206
+ absolute: false,
207
+ onlyFiles: true,
208
+ ignore: ["node_modules/**", "dist/**"]
209
+ });
210
+ for (const file of files) {
211
+ const absolutePath = path.join(absoluteDir, file);
212
+ const fileSeeds = await loadSeedFile(absolutePath, viteServer, logger);
213
+ for (const [schemaName, seedFn] of Object.entries(fileSeeds)) {
214
+ if (seeds.has(schemaName)) {
215
+ logger.warn(
216
+ `[vite-plugin-open-api-server] Duplicate seed for schema "${schemaName}" in ${file}. Using last definition.`
217
+ );
218
+ }
219
+ seeds.set(schemaName, seedFn);
220
+ }
221
+ }
222
+ return {
223
+ seeds,
224
+ fileCount: files.length,
225
+ files
226
+ };
227
+ }
228
+ async function loadSeedFile(filePath, viteServer, logger) {
229
+ try {
230
+ const moduleNode = viteServer.moduleGraph.getModuleById(filePath);
231
+ if (moduleNode) {
232
+ viteServer.moduleGraph.invalidateModule(moduleNode);
233
+ }
234
+ const module = await viteServer.ssrLoadModule(filePath);
235
+ const seeds = module.default ?? module.seeds ?? module;
236
+ if (!seeds || typeof seeds !== "object") {
237
+ logger.warn(
238
+ `[vite-plugin-open-api-server] Invalid seed file ${filePath}: expected object export`
239
+ );
240
+ return {};
241
+ }
242
+ const validSeeds = {};
243
+ for (const [key, value] of Object.entries(seeds)) {
244
+ if (typeof value === "function") {
245
+ validSeeds[key] = value;
246
+ }
247
+ }
248
+ return validSeeds;
249
+ } catch (error) {
250
+ logger.error(
251
+ `[vite-plugin-open-api-server] Failed to load seed file ${filePath}:`,
252
+ error instanceof Error ? error.message : error
253
+ );
254
+ return {};
255
+ }
256
+ }
257
+ async function getSeedFiles(seedsDir, cwd = process.cwd()) {
258
+ const absoluteDir = path.resolve(cwd, seedsDir);
259
+ const dirExists = await directoryExists(absoluteDir);
260
+ if (!dirExists) {
261
+ return [];
262
+ }
263
+ const pattern = "**/*.seeds.{ts,js,mjs}";
264
+ const files = await fg(pattern, {
265
+ cwd: absoluteDir,
266
+ absolute: true,
267
+ onlyFiles: true,
268
+ ignore: ["node_modules/**", "dist/**"]
269
+ });
270
+ return files;
271
+ }
272
+
273
+ // src/hot-reload.ts
274
+ var nodeModulesRe = /(^|[\\/])node_modules([\\/]|$)/;
275
+ var distRe = /(^|[\\/])dist([\\/]|$)/;
190
276
  async function createFileWatcher(options) {
191
277
  const {
192
278
  handlersDir,
@@ -200,8 +286,8 @@ async function createFileWatcher(options) {
200
286
  const watchers = [];
201
287
  const readyPromises = [];
202
288
  let isWatching = true;
203
- const handlerPattern = "**/*.handlers.{ts,js,mjs}";
204
- const seedPattern = "**/*.seeds.{ts,js,mjs}";
289
+ const handlerRe = /\.handlers\.(ts|js|mjs)$/;
290
+ const seedRe = /\.seeds\.(ts|js|mjs)$/;
205
291
  const safeInvoke = (callback, filePath, context) => {
206
292
  Promise.resolve().then(() => callback(filePath)).catch((error) => {
207
293
  logger.error(
@@ -210,80 +296,103 @@ async function createFileWatcher(options) {
210
296
  );
211
297
  });
212
298
  };
213
- if (handlersDir && onHandlerChange) {
214
- const absoluteHandlersDir = path2.resolve(cwd, handlersDir);
215
- const handlerWatcher = watch(handlerPattern, {
216
- cwd: absoluteHandlersDir,
217
- ignoreInitial: true,
218
- ignored: ["**/node_modules/**", "**/dist/**"],
219
- persistent: true,
220
- awaitWriteFinish: {
221
- stabilityThreshold: 100,
222
- pollInterval: 50
299
+ const buildIgnored = (pattern) => {
300
+ return (filePath, stats) => {
301
+ if (nodeModulesRe.test(filePath) || distRe.test(filePath)) {
302
+ return true;
223
303
  }
224
- });
225
- handlerWatcher.on("add", (file) => {
226
- const absolutePath = path2.join(absoluteHandlersDir, file);
227
- safeInvoke(onHandlerChange, absolutePath, "Handler add");
228
- });
229
- handlerWatcher.on("change", (file) => {
230
- const absolutePath = path2.join(absoluteHandlersDir, file);
231
- safeInvoke(onHandlerChange, absolutePath, "Handler change");
232
- });
233
- handlerWatcher.on("unlink", (file) => {
234
- const absolutePath = path2.join(absoluteHandlersDir, file);
235
- safeInvoke(onHandlerChange, absolutePath, "Handler unlink");
236
- });
237
- handlerWatcher.on("error", (error) => {
238
- logger.error("[vite-plugin-open-api-server] Handler watcher error:", error);
239
- });
240
- readyPromises.push(
241
- new Promise((resolve) => {
242
- handlerWatcher.on("ready", () => resolve());
243
- })
244
- );
245
- watchers.push(handlerWatcher);
246
- }
247
- if (seedsDir && onSeedChange) {
248
- const absoluteSeedsDir = path2.resolve(cwd, seedsDir);
249
- const seedWatcher = watch(seedPattern, {
250
- cwd: absoluteSeedsDir,
251
- ignoreInitial: true,
252
- ignored: ["**/node_modules/**", "**/dist/**"],
253
- persistent: true,
254
- awaitWriteFinish: {
255
- stabilityThreshold: 100,
256
- pollInterval: 50
304
+ if (!stats) {
305
+ return false;
257
306
  }
258
- });
259
- seedWatcher.on("add", (file) => {
260
- const absolutePath = path2.join(absoluteSeedsDir, file);
261
- safeInvoke(onSeedChange, absolutePath, "Seed add");
262
- });
263
- seedWatcher.on("change", (file) => {
264
- const absolutePath = path2.join(absoluteSeedsDir, file);
265
- safeInvoke(onSeedChange, absolutePath, "Seed change");
266
- });
267
- seedWatcher.on("unlink", (file) => {
268
- const absolutePath = path2.join(absoluteSeedsDir, file);
269
- safeInvoke(onSeedChange, absolutePath, "Seed unlink");
270
- });
271
- seedWatcher.on("error", (error) => {
272
- logger.error("[vite-plugin-open-api-server] Seed watcher error:", error);
273
- });
274
- readyPromises.push(
275
- new Promise((resolve) => {
276
- seedWatcher.on("ready", () => resolve());
277
- })
278
- );
279
- watchers.push(seedWatcher);
307
+ if (!stats.isFile()) {
308
+ return false;
309
+ }
310
+ return !pattern.test(filePath);
311
+ };
312
+ };
313
+ try {
314
+ if (handlersDir && onHandlerChange) {
315
+ const absoluteHandlersDir = path.resolve(cwd, handlersDir);
316
+ if (!existsSync(absoluteHandlersDir)) {
317
+ logger.warn(
318
+ `[vite-plugin-open-api-server] Handlers directory does not exist, skipping watcher: ${absoluteHandlersDir}`
319
+ );
320
+ } else {
321
+ const handlerWatcher = watch(absoluteHandlersDir, {
322
+ ignoreInitial: true,
323
+ ignored: buildIgnored(handlerRe),
324
+ persistent: true,
325
+ awaitWriteFinish: {
326
+ stabilityThreshold: 100,
327
+ pollInterval: 50
328
+ }
329
+ });
330
+ handlerWatcher.on("add", (file) => {
331
+ safeInvoke(onHandlerChange, file, "Handler add");
332
+ });
333
+ handlerWatcher.on("change", (file) => {
334
+ safeInvoke(onHandlerChange, file, "Handler change");
335
+ });
336
+ handlerWatcher.on("unlink", (file) => {
337
+ safeInvoke(onHandlerChange, file, "Handler unlink");
338
+ });
339
+ handlerWatcher.on("error", (error) => {
340
+ logger.error("[vite-plugin-open-api-server] Handler watcher error:", error);
341
+ });
342
+ readyPromises.push(
343
+ new Promise((resolve) => {
344
+ handlerWatcher.on("ready", () => resolve());
345
+ })
346
+ );
347
+ watchers.push(handlerWatcher);
348
+ }
349
+ }
350
+ if (seedsDir && onSeedChange) {
351
+ const absoluteSeedsDir = path.resolve(cwd, seedsDir);
352
+ if (!existsSync(absoluteSeedsDir)) {
353
+ logger.warn(
354
+ `[vite-plugin-open-api-server] Seeds directory does not exist, skipping watcher: ${absoluteSeedsDir}`
355
+ );
356
+ } else {
357
+ const seedWatcher = watch(absoluteSeedsDir, {
358
+ ignoreInitial: true,
359
+ ignored: buildIgnored(seedRe),
360
+ persistent: true,
361
+ awaitWriteFinish: {
362
+ stabilityThreshold: 100,
363
+ pollInterval: 50
364
+ }
365
+ });
366
+ seedWatcher.on("add", (file) => {
367
+ safeInvoke(onSeedChange, file, "Seed add");
368
+ });
369
+ seedWatcher.on("change", (file) => {
370
+ safeInvoke(onSeedChange, file, "Seed change");
371
+ });
372
+ seedWatcher.on("unlink", (file) => {
373
+ safeInvoke(onSeedChange, file, "Seed unlink");
374
+ });
375
+ seedWatcher.on("error", (error) => {
376
+ logger.error("[vite-plugin-open-api-server] Seed watcher error:", error);
377
+ });
378
+ readyPromises.push(
379
+ new Promise((resolve) => {
380
+ seedWatcher.on("ready", () => resolve());
381
+ })
382
+ );
383
+ watchers.push(seedWatcher);
384
+ }
385
+ }
386
+ } catch (error) {
387
+ await Promise.allSettled(watchers.map((w) => w.close()));
388
+ throw error;
280
389
  }
281
390
  const readyPromise = Promise.all(readyPromises).then(() => {
282
391
  });
283
392
  return {
284
393
  async close() {
285
394
  isWatching = false;
286
- await Promise.all(watchers.map((w) => w.close()));
395
+ await Promise.allSettled(watchers.map((w) => w.close()));
287
396
  },
288
397
  get isWatching() {
289
398
  return isWatching;
@@ -297,9 +406,12 @@ function debounce(fn, delay) {
297
406
  let timeoutId = null;
298
407
  let isRunning = false;
299
408
  let pendingArgs = null;
409
+ let cancelled = false;
300
410
  const execute = async (...args) => {
301
- if (isRunning) {
302
- pendingArgs = args;
411
+ if (cancelled || isRunning) {
412
+ if (isRunning && !cancelled) {
413
+ pendingArgs = args;
414
+ }
303
415
  return;
304
416
  }
305
417
  isRunning = true;
@@ -310,14 +422,15 @@ function debounce(fn, delay) {
310
422
  }
311
423
  } finally {
312
424
  isRunning = false;
313
- if (pendingArgs !== null) {
425
+ if (pendingArgs !== null && !cancelled) {
314
426
  const nextArgs = pendingArgs;
315
427
  pendingArgs = null;
316
428
  setTimeout(() => execute(...nextArgs), 0);
317
429
  }
318
430
  }
319
431
  };
320
- return (...args) => {
432
+ const debouncedFn = (...args) => {
433
+ if (cancelled) return;
321
434
  if (timeoutId !== null) {
322
435
  clearTimeout(timeoutId);
323
436
  }
@@ -326,86 +439,478 @@ function debounce(fn, delay) {
326
439
  execute(...args);
327
440
  }, delay);
328
441
  };
442
+ debouncedFn.cancel = () => {
443
+ cancelled = true;
444
+ if (timeoutId !== null) {
445
+ clearTimeout(timeoutId);
446
+ timeoutId = null;
447
+ }
448
+ pendingArgs = null;
449
+ };
450
+ return debouncedFn;
329
451
  }
330
- async function loadSeeds(seedsDir, viteServer, cwd = process.cwd(), logger = console) {
331
- const seeds = /* @__PURE__ */ new Map();
332
- const absoluteDir = path2.resolve(cwd, seedsDir);
333
- const dirExists = await directoryExists(absoluteDir);
334
- if (!dirExists) {
335
- return {
336
- seeds,
337
- fileCount: 0,
338
- files: []
339
- };
452
+ async function createPerSpecFileWatchers(instances, vite, cwd, options) {
453
+ const watchers = [];
454
+ const allDebouncedFns = [];
455
+ try {
456
+ for (const instance of instances) {
457
+ const debouncedHandlerReload = debounce(
458
+ () => reloadSpecHandlers(instance, vite, cwd, options),
459
+ 100
460
+ );
461
+ const debouncedSeedReload = debounce(
462
+ () => reloadSpecSeeds(instance, vite, cwd, options),
463
+ 100
464
+ );
465
+ allDebouncedFns.push(debouncedHandlerReload, debouncedSeedReload);
466
+ const innerWatcher = await createFileWatcher({
467
+ handlersDir: instance.config.handlersDir,
468
+ seedsDir: instance.config.seedsDir,
469
+ cwd,
470
+ logger: options.logger,
471
+ onHandlerChange: debouncedHandlerReload,
472
+ onSeedChange: debouncedSeedReload
473
+ });
474
+ watchers.push({
475
+ async close() {
476
+ debouncedHandlerReload.cancel();
477
+ debouncedSeedReload.cancel();
478
+ await innerWatcher.close();
479
+ },
480
+ get isWatching() {
481
+ return innerWatcher.isWatching;
482
+ },
483
+ get ready() {
484
+ return innerWatcher.ready;
485
+ }
486
+ });
487
+ }
488
+ } catch (error) {
489
+ for (const fn of allDebouncedFns) fn.cancel();
490
+ await Promise.allSettled(watchers.map((w) => w.close()));
491
+ throw error;
340
492
  }
341
- const pattern = "**/*.seeds.{ts,js,mjs}";
342
- const files = await fg(pattern, {
343
- cwd: absoluteDir,
344
- absolute: false,
345
- onlyFiles: true,
346
- ignore: ["node_modules/**", "dist/**"]
347
- });
348
- for (const file of files) {
349
- const absolutePath = path2.join(absoluteDir, file);
350
- const fileSeeds = await loadSeedFile(absolutePath, viteServer, logger);
351
- for (const [schemaName, seedFn] of Object.entries(fileSeeds)) {
352
- if (seeds.has(schemaName)) {
353
- logger.warn(
354
- `[vite-plugin-open-api-server] Duplicate seed for schema "${schemaName}" in ${file}. Using last definition.`
493
+ return watchers;
494
+ }
495
+ async function reloadSpecHandlers(instance, vite, cwd, options) {
496
+ try {
497
+ const logger = options.logger ?? console;
498
+ const handlersResult = await loadHandlers(instance.config.handlersDir, vite, cwd, logger);
499
+ instance.server.updateHandlers(handlersResult.handlers);
500
+ printReloadNotification("handlers", handlersResult.handlers.size, options);
501
+ } catch (error) {
502
+ printError(`Failed to reload handlers for spec "${instance.id}"`, error, options);
503
+ }
504
+ }
505
+ async function reloadSpecSeeds(instance, vite, cwd, options) {
506
+ try {
507
+ const logger = options.logger ?? console;
508
+ const seedsResult = await loadSeeds(instance.config.seedsDir, vite, cwd, logger);
509
+ let broadcastCount = seedsResult.seeds.size;
510
+ instance.server.store.clearAll();
511
+ if (seedsResult.seeds.size > 0) {
512
+ try {
513
+ await executeSeeds(seedsResult.seeds, instance.server.store, instance.server.document);
514
+ } catch (execError) {
515
+ broadcastCount = 0;
516
+ printError(
517
+ `Seeds loaded but executeSeeds failed for spec "${instance.id}"; store is now empty`,
518
+ execError,
519
+ options
355
520
  );
356
521
  }
357
- seeds.set(schemaName, seedFn);
358
522
  }
523
+ instance.server.wsHub.broadcast({
524
+ type: "seeds:updated",
525
+ data: { count: broadcastCount }
526
+ });
527
+ if (broadcastCount > 0) {
528
+ printReloadNotification("seeds", broadcastCount, options);
529
+ }
530
+ } catch (error) {
531
+ printError(`Failed to reload seeds for spec "${instance.id}"`, error, options);
359
532
  }
360
- return {
361
- seeds,
362
- fileCount: files.length,
363
- files
533
+ }
534
+
535
+ // src/multi-proxy.ts
536
+ var DEVTOOLS_PROXY_PATH = "/_devtools";
537
+ var API_PROXY_PATH = "/_api";
538
+ var WS_PROXY_PATH = "/_ws";
539
+ function getProxyConfig(vite) {
540
+ if (!vite.config.server) {
541
+ return null;
542
+ }
543
+ vite.config.server.proxy ??= {};
544
+ return vite.config.server.proxy;
545
+ }
546
+ function configureMultiProxy(vite, instances, port) {
547
+ const proxyConfig = getProxyConfig(vite);
548
+ if (!proxyConfig) {
549
+ return;
550
+ }
551
+ const httpTarget = `http://localhost:${port}`;
552
+ for (const instance of instances) {
553
+ const prefix = instance.config.proxyPath;
554
+ proxyConfig[prefix] = {
555
+ target: httpTarget,
556
+ changeOrigin: true,
557
+ rewrite: (path4) => {
558
+ if (path4 !== prefix && !path4.startsWith(`${prefix}/`) && !path4.startsWith(`${prefix}?`))
559
+ return path4;
560
+ const rest = path4.slice(prefix.length);
561
+ if (rest === "" || rest === "/") return "/";
562
+ if (rest.startsWith("?")) return `/${rest}`;
563
+ return rest;
564
+ },
565
+ headers: { "x-spec-id": instance.id }
566
+ };
567
+ }
568
+ proxyConfig[DEVTOOLS_PROXY_PATH] = {
569
+ target: httpTarget,
570
+ changeOrigin: true
571
+ };
572
+ proxyConfig[API_PROXY_PATH] = {
573
+ target: httpTarget,
574
+ changeOrigin: true
575
+ };
576
+ proxyConfig[WS_PROXY_PATH] = {
577
+ target: `ws://localhost:${port}`,
578
+ changeOrigin: true,
579
+ ws: true
364
580
  };
365
581
  }
366
- async function loadSeedFile(filePath, viteServer, logger) {
367
- try {
368
- const moduleNode = viteServer.moduleGraph.getModuleById(filePath);
369
- if (moduleNode) {
370
- viteServer.moduleGraph.invalidateModule(moduleNode);
371
- }
372
- const module = await viteServer.ssrLoadModule(filePath);
373
- const seeds = module.default ?? module.seeds ?? module;
374
- if (!seeds || typeof seeds !== "object") {
375
- logger.warn(
376
- `[vite-plugin-open-api-server] Invalid seed file ${filePath}: expected object export`
582
+
583
+ // package.json
584
+ var package_default = {
585
+ version: "0.24.0-next.9"};
586
+
587
+ // src/multi-internal-api.ts
588
+ var PACKAGE_VERSION = package_default.version;
589
+ function mountMultiSpecInternalApi(app, instances) {
590
+ const seen = /* @__PURE__ */ new Set();
591
+ for (const inst of instances) {
592
+ if (seen.has(inst.id)) {
593
+ throw new Error(
594
+ `[vite-plugin-open-api-server] Duplicate specId "${inst.id}" in instances array`
377
595
  );
378
- return {};
379
596
  }
380
- const validSeeds = {};
381
- for (const [key, value] of Object.entries(seeds)) {
382
- if (typeof value === "function") {
383
- validSeeds[key] = value;
597
+ seen.add(inst.id);
598
+ }
599
+ const instanceMap = new Map(instances.map((i) => [i.id, i]));
600
+ app.get("/_api/specs", (c) => {
601
+ const specs = instances.map((i) => ({
602
+ id: i.id,
603
+ title: i.info.title,
604
+ version: i.info.version,
605
+ proxyPath: i.config.proxyPath,
606
+ color: i.info.color,
607
+ endpoints: i.server.registry.endpoints.size,
608
+ schemas: i.server.store.getSchemas().length,
609
+ simulations: i.server.simulationManager.count()
610
+ }));
611
+ return c.json({ specs, count: specs.length });
612
+ });
613
+ app.get("/_api/registry", (c) => {
614
+ const registries = instances.map((i) => ({
615
+ specId: i.id,
616
+ specTitle: i.info.title,
617
+ specColor: i.info.color,
618
+ endpoints: Array.from(i.server.registry.endpoints.entries()).map(([key, entry]) => ({
619
+ ...entry,
620
+ key
621
+ })),
622
+ stats: i.server.registry.stats
623
+ }));
624
+ const totalEndpoints = registries.reduce((sum, r) => sum + r.endpoints.length, 0);
625
+ return c.json({
626
+ specs: registries,
627
+ totalEndpoints,
628
+ totalSpecs: registries.length
629
+ });
630
+ });
631
+ app.get("/_api/health", (c) => {
632
+ const specs = instances.map((i) => ({
633
+ id: i.id,
634
+ endpoints: i.server.registry.endpoints.size,
635
+ schemas: i.server.store.getSchemas().length,
636
+ simulations: i.server.simulationManager.count()
637
+ }));
638
+ return c.json({
639
+ status: "ok",
640
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
641
+ version: PACKAGE_VERSION,
642
+ totalSpecs: instances.length,
643
+ totalEndpoints: specs.reduce((s, i) => s + i.endpoints, 0),
644
+ specs
645
+ });
646
+ });
647
+ const specApi = new Hono();
648
+ specApi.use("/:specId/*", async (c, next) => {
649
+ const specId = c.req.param("specId");
650
+ const instance = instanceMap.get(specId);
651
+ if (!instance) {
652
+ return c.json({ error: `Unknown spec: ${specId}` }, 404);
653
+ }
654
+ c.set("specInstance", instance);
655
+ await next();
656
+ });
657
+ specApi.get("/:specId/registry", (c) => {
658
+ const instance = c.get("specInstance");
659
+ return c.json({
660
+ specId: instance.id,
661
+ endpoints: Array.from(instance.server.registry.endpoints.entries()).map(([key, entry]) => ({
662
+ ...entry,
663
+ key
664
+ })),
665
+ stats: instance.server.registry.stats
666
+ });
667
+ });
668
+ specApi.get("/:specId/store", (c) => {
669
+ const instance = c.get("specInstance");
670
+ const schemas = instance.server.store.getSchemas().map((schema) => ({
671
+ name: schema,
672
+ count: instance.server.store.getCount(schema),
673
+ idField: instance.server.store.getIdField(schema)
674
+ }));
675
+ return c.json({ specId: instance.id, schemas });
676
+ });
677
+ specApi.get("/:specId/store/:schema", (c) => {
678
+ const instance = c.get("specInstance");
679
+ const schema = c.req.param("schema");
680
+ const allItems = instance.server.store.list(schema);
681
+ const total = allItems.length;
682
+ const rawOffset = Number(c.req.query("offset"));
683
+ const offset = Number.isFinite(rawOffset) ? Math.max(Math.floor(rawOffset), 0) : 0;
684
+ const rawLimit = Number(c.req.query("limit"));
685
+ const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 0), 1e3) : Math.min(total, 1e3);
686
+ const items = limit === 0 ? [] : allItems.slice(offset, offset + limit);
687
+ return c.json({
688
+ specId: instance.id,
689
+ schema,
690
+ idField: instance.server.store.getIdField(schema),
691
+ items,
692
+ count: items.length,
693
+ total,
694
+ offset,
695
+ limit
696
+ });
697
+ });
698
+ specApi.get("/:specId/document", (c) => {
699
+ const instance = c.get("specInstance");
700
+ return c.json(instance.server.document);
701
+ });
702
+ specApi.get("/:specId/simulations", (c) => {
703
+ const instance = c.get("specInstance");
704
+ return c.json({
705
+ specId: instance.id,
706
+ simulations: instance.server.simulationManager.list(),
707
+ count: instance.server.simulationManager.count()
708
+ });
709
+ });
710
+ specApi.get("/:specId/timeline", (c) => {
711
+ const instance = c.get("specInstance");
712
+ const parsed = Number(c.req.query("limit"));
713
+ const limit = Number.isFinite(parsed) ? Math.min(Math.max(Math.floor(parsed), 0), 1e3) : 100;
714
+ const timeline = instance.server.getTimeline();
715
+ const entries = limit === 0 ? [] : timeline.slice(-limit);
716
+ return c.json({
717
+ specId: instance.id,
718
+ entries,
719
+ count: entries.length,
720
+ total: timeline.length,
721
+ limit
722
+ });
723
+ });
724
+ app.route("/_api/specs", specApi);
725
+ }
726
+
727
+ // src/multi-command.ts
728
+ var SPEC_SCOPED_COMMANDS = /* @__PURE__ */ new Set([
729
+ "get:store",
730
+ "set:store",
731
+ "clear:store",
732
+ "set:simulation",
733
+ "clear:simulation",
734
+ "reseed"
735
+ ]);
736
+ function sendError(hub, client, command, message) {
737
+ hub.sendTo(client, { type: "error", data: { command, message } });
738
+ }
739
+ function resolveInstance(specId, instanceMap, hub, client, commandType) {
740
+ if (!specId) return void 0;
741
+ const instance = instanceMap.get(specId);
742
+ if (!instance) {
743
+ sendError(hub, client, commandType, `Unknown spec: ${specId}`);
744
+ }
745
+ return instance;
746
+ }
747
+ function handleGetSpecs(hub, client, specsInfo, serverVersion) {
748
+ hub.sendTo(client, { type: "connected", data: { serverVersion, specs: specsInfo } });
749
+ }
750
+ function handleGetRegistry(cmd, hub, client, instances, instanceMap) {
751
+ const sendRegistry = (instance, id) => {
752
+ const registryEvent = {
753
+ type: "registry",
754
+ data: {
755
+ specId: id,
756
+ endpoints: Array.from(instance.server.registry.endpoints.entries()).map(([key, entry]) => ({
757
+ ...entry,
758
+ key
759
+ })),
760
+ stats: { ...instance.server.registry.stats }
384
761
  }
762
+ };
763
+ hub.sendTo(client, registryEvent);
764
+ };
765
+ const specId = cmd.data?.specId;
766
+ if (specId) {
767
+ const instance = resolveInstance(specId, instanceMap, hub, client, cmd.type);
768
+ if (!instance) return;
769
+ sendRegistry(instance, specId);
770
+ } else {
771
+ for (const instance of instances) {
772
+ sendRegistry(instance, instance.id);
385
773
  }
386
- return validSeeds;
387
- } catch (error) {
388
- logger.error(
389
- `[vite-plugin-open-api-server] Failed to load seed file ${filePath}:`,
390
- error instanceof Error ? error.message : error
391
- );
392
- return {};
393
774
  }
394
775
  }
395
- async function getSeedFiles(seedsDir, cwd = process.cwd()) {
396
- const absoluteDir = path2.resolve(cwd, seedsDir);
397
- const dirExists = await directoryExists(absoluteDir);
398
- if (!dirExists) {
399
- return [];
776
+ function handleGetTimeline(cmd, hub, client, instances, instanceMap) {
777
+ const specId = cmd.data?.specId;
778
+ const rawLimit = Number(cmd.data?.limit);
779
+ const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 0), 1e3) : 100;
780
+ const sendTimeline = (instance, id) => {
781
+ const timeline = instance.server.getTimeline();
782
+ const entries = limit === 0 ? [] : timeline.slice(-limit);
783
+ const timelineEvent = {
784
+ type: "timeline",
785
+ data: {
786
+ specId: id,
787
+ entries,
788
+ count: entries.length,
789
+ total: timeline.length
790
+ }
791
+ };
792
+ hub.sendTo(client, timelineEvent);
793
+ };
794
+ if (specId) {
795
+ const instance = resolveInstance(specId, instanceMap, hub, client, cmd.type);
796
+ if (!instance) return;
797
+ sendTimeline(instance, specId);
798
+ } else {
799
+ for (const instance of instances) {
800
+ sendTimeline(instance, instance.id);
801
+ }
400
802
  }
401
- const pattern = "**/*.seeds.{ts,js,mjs}";
402
- const files = await fg(pattern, {
403
- cwd: absoluteDir,
404
- absolute: true,
405
- onlyFiles: true,
406
- ignore: ["node_modules/**", "dist/**"]
803
+ }
804
+ function handleClearTimeline(cmd, hub, client, instances, instanceMap) {
805
+ const specId = cmd.data?.specId;
806
+ const clearAndNotify = (instance, id) => {
807
+ const count = instance.server.clearTimeline();
808
+ hub.sendTo(client, { type: "timeline:cleared", data: { specId: id, count } });
809
+ };
810
+ if (specId) {
811
+ const instance = resolveInstance(specId, instanceMap, hub, client, cmd.type);
812
+ if (!instance) return;
813
+ clearAndNotify(instance, specId);
814
+ } else {
815
+ for (const instance of instances) {
816
+ clearAndNotify(instance, instance.id);
817
+ }
818
+ }
819
+ }
820
+ function handleSpecScoped(cmd, hub, client, instanceMap) {
821
+ const specId = cmd.data?.specId;
822
+ if (!specId) {
823
+ sendError(hub, client, cmd.type, "specId is required for this command");
824
+ return;
825
+ }
826
+ const instance = resolveInstance(specId, instanceMap, hub, client, cmd.type);
827
+ if (!instance) return;
828
+ const { specId: _, ...coreData } = cmd.data ?? {};
829
+ const coreCommand = Object.keys(coreData).length > 0 ? { type: cmd.type, data: coreData } : { type: cmd.type };
830
+ instance.server.wsHub.handleMessage(client, JSON.stringify(coreCommand));
831
+ }
832
+ function createMultiSpecCommandHandler(deps) {
833
+ const { hub, instances, specsInfo, serverVersion } = deps;
834
+ const instanceMap = new Map(instances.map((i) => [i.id, i]));
835
+ return (client, command) => {
836
+ if (!command || typeof command !== "object" || !("type" in command)) {
837
+ return;
838
+ }
839
+ const cmd = command;
840
+ if (typeof cmd.type !== "string") {
841
+ return;
842
+ }
843
+ switch (cmd.type) {
844
+ case "get:specs":
845
+ handleGetSpecs(hub, client, specsInfo, serverVersion);
846
+ break;
847
+ case "get:registry":
848
+ handleGetRegistry(cmd, hub, client, instances, instanceMap);
849
+ break;
850
+ case "get:timeline":
851
+ handleGetTimeline(cmd, hub, client, instances, instanceMap);
852
+ break;
853
+ case "clear:timeline":
854
+ handleClearTimeline(cmd, hub, client, instances, instanceMap);
855
+ break;
856
+ default:
857
+ if (SPEC_SCOPED_COMMANDS.has(cmd.type)) {
858
+ handleSpecScoped(cmd, hub, client, instanceMap);
859
+ } else {
860
+ sendError(hub, client, cmd.type, `Unknown command type: ${cmd.type}`);
861
+ }
862
+ break;
863
+ }
864
+ };
865
+ }
866
+
867
+ // src/multi-ws.ts
868
+ var PACKAGE_VERSION2 = package_default.version;
869
+ var MULTI_SPEC_ONLY_COMMANDS = /* @__PURE__ */ new Set(["get:specs"]);
870
+ function createMultiSpecWebSocketHub(instances, specsInfo) {
871
+ const hub = createWebSocketHub({ autoConnect: false });
872
+ const originalAddClient = hub.addClient.bind(hub);
873
+ hub.addClient = (ws) => {
874
+ originalAddClient(ws);
875
+ hub.sendTo(ws, {
876
+ type: "connected",
877
+ // biome-ignore lint/suspicious/noExplicitAny: MultiSpecServerEvent extends ServerEvent with specs[]
878
+ data: { serverVersion: PACKAGE_VERSION2, specs: specsInfo }
879
+ });
880
+ };
881
+ for (const instance of instances) {
882
+ instance.server.wsHub.broadcast = (event) => {
883
+ const enriched = { type: event.type, data: { ...event.data, specId: instance.id } };
884
+ hub.broadcast(enriched);
885
+ };
886
+ instance.server.wsHub.sendTo = (client, event) => {
887
+ const enriched = { type: event.type, data: { ...event.data, specId: instance.id } };
888
+ return hub.sendTo(client, enriched);
889
+ };
890
+ }
891
+ const commandHandler = createMultiSpecCommandHandler({
892
+ hub,
893
+ instances,
894
+ specsInfo,
895
+ serverVersion: PACKAGE_VERSION2
407
896
  });
408
- return files;
897
+ hub.setCommandHandler(commandHandler);
898
+ const originalHandleMessage = hub.handleMessage.bind(hub);
899
+ hub.handleMessage = (client, message) => {
900
+ try {
901
+ const parsed = typeof message === "string" ? JSON.parse(message) : message;
902
+ if (parsed && typeof parsed === "object" && "type" in parsed) {
903
+ const cmd = parsed;
904
+ if (MULTI_SPEC_ONLY_COMMANDS.has(cmd.type)) {
905
+ commandHandler(client, cmd);
906
+ return;
907
+ }
908
+ }
909
+ } catch {
910
+ }
911
+ originalHandleMessage(client, message);
912
+ };
913
+ return hub;
409
914
  }
410
915
 
411
916
  // src/types.ts
@@ -426,6 +931,12 @@ function validateSpecs(specs) {
426
931
  }
427
932
  for (let i = 0; i < specs.length; i++) {
428
933
  const spec = specs[i];
934
+ if (!spec || typeof spec !== "object") {
935
+ throw new ValidationError(
936
+ "SPEC_NOT_FOUND",
937
+ `specs[${i}]: must be a SpecConfig object, got ${spec === null ? "null" : typeof spec}`
938
+ );
939
+ }
429
940
  if (!spec.spec || typeof spec.spec !== "string" || spec.spec.trim() === "") {
430
941
  const identifier = spec.id ? ` (id: "${spec.id}")` : "";
431
942
  throw new ValidationError(
@@ -440,9 +951,12 @@ function resolveOptions(options) {
440
951
  return {
441
952
  specs: options.specs.map((s) => ({
442
953
  spec: s.spec,
954
+ // Placeholder — populated by orchestrator after document processing
443
955
  id: s.id ?? "",
956
+ // Placeholder — populated by orchestrator after document processing
444
957
  proxyPath: s.proxyPath ?? "",
445
- proxyPathSource: s.proxyPath ? "explicit" : "auto",
958
+ // Preliminary overwritten by deriveProxyPath() during orchestration
959
+ proxyPathSource: s.proxyPath?.trim() ? "explicit" : "auto",
446
960
  handlersDir: s.handlersDir ?? "",
447
961
  seedsDir: s.seedsDir ?? "",
448
962
  idFields: s.idFields ?? {}
@@ -458,18 +972,468 @@ function resolveOptions(options) {
458
972
  };
459
973
  }
460
974
 
975
+ // src/proxy-path.ts
976
+ var RESERVED_PROXY_PATHS = [
977
+ DEVTOOLS_PROXY_PATH,
978
+ API_PROXY_PATH,
979
+ WS_PROXY_PATH
980
+ ];
981
+ function deriveProxyPath(explicitPath, document, specId) {
982
+ if (explicitPath.trim()) {
983
+ return {
984
+ proxyPath: normalizeProxyPath(explicitPath.trim(), specId),
985
+ proxyPathSource: "explicit"
986
+ };
987
+ }
988
+ const servers = document.servers;
989
+ const serverUrl = servers?.[0]?.url?.trim();
990
+ if (!serverUrl) {
991
+ throw new ValidationError(
992
+ "PROXY_PATH_MISSING",
993
+ `[${specId}] Cannot derive proxyPath: no servers defined in the OpenAPI document. Set an explicit proxyPath in the spec configuration.`
994
+ );
995
+ }
996
+ let path4;
997
+ let parsedUrl;
998
+ try {
999
+ parsedUrl = new URL(serverUrl);
1000
+ } catch {
1001
+ }
1002
+ if (parsedUrl) {
1003
+ try {
1004
+ path4 = decodeURIComponent(parsedUrl.pathname);
1005
+ } catch {
1006
+ path4 = parsedUrl.pathname;
1007
+ }
1008
+ } else {
1009
+ path4 = serverUrl;
1010
+ }
1011
+ return {
1012
+ proxyPath: normalizeProxyPath(path4, specId),
1013
+ proxyPathSource: "auto"
1014
+ };
1015
+ }
1016
+ function normalizeProxyPath(path4, specId) {
1017
+ path4 = path4.trim();
1018
+ const queryIdx = path4.indexOf("?");
1019
+ const hashIdx = path4.indexOf("#");
1020
+ const cutIdx = Math.min(
1021
+ queryIdx >= 0 ? queryIdx : path4.length,
1022
+ hashIdx >= 0 ? hashIdx : path4.length
1023
+ );
1024
+ let normalized = path4.slice(0, cutIdx);
1025
+ normalized = normalized.startsWith("/") ? normalized : `/${normalized}`;
1026
+ normalized = normalized.replace(/\/{2,}/g, "/");
1027
+ const segments = normalized.split("/");
1028
+ const resolved = [];
1029
+ for (const segment of segments) {
1030
+ if (segment === ".") {
1031
+ continue;
1032
+ }
1033
+ if (segment === "..") {
1034
+ if (resolved.length > 1) {
1035
+ resolved.pop();
1036
+ }
1037
+ continue;
1038
+ }
1039
+ resolved.push(segment);
1040
+ }
1041
+ normalized = resolved.join("/") || "/";
1042
+ if (normalized.length > 1 && normalized.endsWith("/")) {
1043
+ normalized = normalized.slice(0, -1);
1044
+ }
1045
+ if (normalized === "/") {
1046
+ throw new ValidationError(
1047
+ "PROXY_PATH_TOO_BROAD",
1048
+ `[${specId}] proxyPath "/" is too broad \u2014 it would capture all requests. Set a more specific proxyPath (e.g., "/api/v1").`
1049
+ );
1050
+ }
1051
+ return normalized;
1052
+ }
1053
+ function validateUniqueProxyPaths(specs) {
1054
+ const paths = /* @__PURE__ */ new Map();
1055
+ for (const spec of specs) {
1056
+ const path4 = spec.proxyPath?.trim();
1057
+ if (!path4) {
1058
+ continue;
1059
+ }
1060
+ validateNotReservedPath(path4, spec.id);
1061
+ if (paths.has(path4)) {
1062
+ throw new ValidationError(
1063
+ "PROXY_PATH_DUPLICATE",
1064
+ `Duplicate proxyPath "${path4}" used by specs "${paths.get(path4)}" and "${spec.id}". Each spec must have a unique proxyPath.`
1065
+ );
1066
+ }
1067
+ paths.set(path4, spec.id);
1068
+ }
1069
+ validateNoPrefixOverlaps(paths);
1070
+ }
1071
+ function validateNotReservedPath(path4, specId) {
1072
+ for (const reserved of RESERVED_PROXY_PATHS) {
1073
+ if (path4 === reserved || path4.startsWith(`${reserved}/`) || reserved.startsWith(path4)) {
1074
+ throw new ValidationError(
1075
+ "PROXY_PATH_OVERLAP",
1076
+ `[${specId}] proxyPath "${path4}" collides with reserved path "${reserved}" used by the shared DevTools/API/WebSocket service.`
1077
+ );
1078
+ }
1079
+ }
1080
+ }
1081
+ function validateNoPrefixOverlaps(paths) {
1082
+ const sortedPaths = Array.from(paths.entries()).sort(([a], [b]) => a.length - b.length);
1083
+ for (let i = 0; i < sortedPaths.length; i++) {
1084
+ for (let j = i + 1; j < sortedPaths.length; j++) {
1085
+ const [shorter, shorterId] = sortedPaths[i];
1086
+ const [longer, longerId] = sortedPaths[j];
1087
+ if (longer.startsWith(`${shorter}/`)) {
1088
+ throw new ValidationError(
1089
+ "PROXY_PATH_OVERLAP",
1090
+ `Overlapping proxyPaths: "${shorter}" (${shorterId}) is a prefix of "${longer}" (${longerId}). This would cause routing ambiguity.`
1091
+ );
1092
+ }
1093
+ if (longer.startsWith(shorter)) {
1094
+ throw new ValidationError(
1095
+ "PROXY_PATH_PREFIX_COLLISION",
1096
+ `Proxy path prefix collision: "${shorter}" (${shorterId}) is a string prefix of "${longer}" (${longerId}). Vite's proxy uses startsWith matching, so "${shorter}" would incorrectly capture requests meant for "${longer}".`
1097
+ );
1098
+ }
1099
+ }
1100
+ }
1101
+ }
1102
+
1103
+ // src/spec-id.ts
1104
+ function slugify(input) {
1105
+ return input.normalize("NFD").replace(new RegExp("\\p{M}", "gu"), "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
1106
+ }
1107
+ function deriveSpecId(explicitId, document) {
1108
+ if (explicitId.trim()) {
1109
+ const id2 = slugify(explicitId);
1110
+ if (!id2) {
1111
+ throw new ValidationError(
1112
+ "SPEC_ID_MISSING",
1113
+ `Cannot derive spec ID: explicit id "${explicitId}" produces an empty slug. Please provide an id containing ASCII letters or numbers.`
1114
+ );
1115
+ }
1116
+ return id2;
1117
+ }
1118
+ const title = document.info?.title;
1119
+ if (!title || !title.trim()) {
1120
+ throw new ValidationError(
1121
+ "SPEC_ID_MISSING",
1122
+ "Cannot derive spec ID: info.title is missing from the OpenAPI document. Please set an explicit id in the spec configuration."
1123
+ );
1124
+ }
1125
+ const id = slugify(title);
1126
+ if (!id) {
1127
+ throw new ValidationError(
1128
+ "SPEC_ID_MISSING",
1129
+ `Cannot derive spec ID: info.title "${title}" produces an empty slug. Please set an explicit id in the spec configuration.`
1130
+ );
1131
+ }
1132
+ return id;
1133
+ }
1134
+ function validateUniqueIds(ids) {
1135
+ const seen = /* @__PURE__ */ new Set();
1136
+ const duplicates = /* @__PURE__ */ new Set();
1137
+ for (const id of ids) {
1138
+ if (seen.has(id)) {
1139
+ duplicates.add(id);
1140
+ }
1141
+ seen.add(id);
1142
+ }
1143
+ if (duplicates.size > 0) {
1144
+ const list = [...duplicates].join(", ");
1145
+ throw new ValidationError(
1146
+ "SPEC_ID_DUPLICATE",
1147
+ `Duplicate spec IDs: ${list}. Each spec must have a unique ID. Set explicit ids in spec configuration to resolve.`
1148
+ );
1149
+ }
1150
+ }
1151
+
1152
+ // src/orchestrator.ts
1153
+ var SPEC_COLORS = [
1154
+ "#4ade80",
1155
+ // green
1156
+ "#60a5fa",
1157
+ // blue
1158
+ "#f472b6",
1159
+ // pink
1160
+ "#facc15",
1161
+ // yellow
1162
+ "#a78bfa",
1163
+ // purple
1164
+ "#fb923c",
1165
+ // orange
1166
+ "#2dd4bf",
1167
+ // teal
1168
+ "#f87171"
1169
+ // red
1170
+ ];
1171
+ async function processSpec(specConfig, index, options, vite, cwd, logger) {
1172
+ const server = await createOpenApiServer({
1173
+ spec: specConfig.spec,
1174
+ port: options.port,
1175
+ idFields: specConfig.idFields,
1176
+ handlers: /* @__PURE__ */ new Map(),
1177
+ seeds: /* @__PURE__ */ new Map(),
1178
+ timelineLimit: options.timelineLimit,
1179
+ cors: false,
1180
+ // CORS handled at main app level
1181
+ devtools: false,
1182
+ // DevTools mounted at main app level
1183
+ logger
1184
+ });
1185
+ const id = deriveSpecId(specConfig.id, server.document);
1186
+ const handlersDir = specConfig.handlersDir || `./mocks/${id}/handlers`;
1187
+ const seedsDir = specConfig.seedsDir || `./mocks/${id}/seeds`;
1188
+ const handlersResult = await loadHandlers(handlersDir, vite, cwd, logger);
1189
+ const seedsResult = await loadSeeds(seedsDir, vite, cwd, logger);
1190
+ if (handlersResult.handlers.size > 0) {
1191
+ server.updateHandlers(handlersResult.handlers, { silent: true });
1192
+ }
1193
+ if (seedsResult.seeds.size > 0) {
1194
+ await executeSeeds(seedsResult.seeds, server.store, server.document);
1195
+ }
1196
+ const { proxyPath, proxyPathSource } = deriveProxyPath(specConfig.proxyPath, server.document, id);
1197
+ const info = {
1198
+ id,
1199
+ title: server.document.info?.title ?? id,
1200
+ version: server.document.info?.version ?? "unknown",
1201
+ proxyPath,
1202
+ color: SPEC_COLORS[index % SPEC_COLORS.length],
1203
+ endpointCount: server.registry.endpoints.size,
1204
+ schemaCount: server.store.getSchemas().length
1205
+ };
1206
+ return {
1207
+ instance: { id, info, server, config: specConfig },
1208
+ resolvedConfig: { id, proxyPath, proxyPathSource, handlersDir, seedsDir }
1209
+ };
1210
+ }
1211
+ function buildCorsConfig(options) {
1212
+ const isWildcardOrigin = options.corsOrigin === "*" || Array.isArray(options.corsOrigin) && options.corsOrigin.includes("*");
1213
+ const effectiveCorsOrigin = isWildcardOrigin ? "*" : options.corsOrigin;
1214
+ return {
1215
+ origin: effectiveCorsOrigin,
1216
+ allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
1217
+ allowHeaders: ["Content-Type", "Authorization", "X-Requested-With", "X-Spec-Id"],
1218
+ exposeHeaders: ["Content-Length", "X-Request-Id"],
1219
+ maxAge: 86400,
1220
+ credentials: !isWildcardOrigin
1221
+ };
1222
+ }
1223
+ function mountDevToolsSpa(mainApp, logger) {
1224
+ const pluginDir = dirname(fileURLToPath(import.meta.url));
1225
+ const spaDir = join(pluginDir, "devtools-spa");
1226
+ const devtoolsSpaDir = existsSync(spaDir) ? spaDir : void 0;
1227
+ if (!devtoolsSpaDir) {
1228
+ logger.warn?.(
1229
+ "[vite-plugin-open-api-server] DevTools SPA not found at",
1230
+ spaDir,
1231
+ '- serving placeholder. Run "pnpm build" to include the SPA.'
1232
+ );
1233
+ }
1234
+ mountDevToolsRoutes(mainApp, {
1235
+ spaDir: devtoolsSpaDir,
1236
+ logger
1237
+ });
1238
+ }
1239
+ async function mountWebSocketRoute(mainApp, wsHub, logger) {
1240
+ let nodeWsModule;
1241
+ try {
1242
+ nodeWsModule = await import('@hono/node-ws');
1243
+ } catch (err) {
1244
+ const isModuleNotFound = err instanceof Error && ("code" in err ? err.code === "ERR_MODULE_NOT_FOUND" : err.message.includes("@hono/node-ws"));
1245
+ if (!isModuleNotFound) {
1246
+ throw err;
1247
+ }
1248
+ mainApp.get("/_ws", (c) => {
1249
+ return c.json(
1250
+ {
1251
+ message: "WebSocket endpoint - use ws:// protocol",
1252
+ note: "Install @hono/node-ws to enable WebSocket support"
1253
+ },
1254
+ 501
1255
+ );
1256
+ });
1257
+ logger.debug?.(
1258
+ "[vite-plugin-open-api-server] @hono/node-ws not available, WebSocket upgrade disabled"
1259
+ );
1260
+ return { injectWebSocket: null };
1261
+ }
1262
+ const nodeWs = nodeWsModule.createNodeWebSocket({ app: mainApp });
1263
+ mainApp.get(
1264
+ "/_ws",
1265
+ nodeWs.upgradeWebSocket(() => ({
1266
+ onOpen(_event, ws) {
1267
+ wsHub.addClient(ws);
1268
+ },
1269
+ onMessage(event, ws) {
1270
+ wsHub.handleMessage(ws, event.data);
1271
+ },
1272
+ onClose(_event, ws) {
1273
+ wsHub.removeClient(ws);
1274
+ }
1275
+ }))
1276
+ );
1277
+ logger.debug?.("[vite-plugin-open-api-server] WebSocket upgrade enabled at /_ws");
1278
+ return { injectWebSocket: nodeWs.injectWebSocket };
1279
+ }
1280
+ function createDispatchMiddleware(instanceMap) {
1281
+ return async (c, next) => {
1282
+ const rawSpecId = c.req.header("x-spec-id");
1283
+ if (!rawSpecId) {
1284
+ await next();
1285
+ return;
1286
+ }
1287
+ const specId = slugify(rawSpecId.trim());
1288
+ if (!specId) {
1289
+ await next();
1290
+ return;
1291
+ }
1292
+ const instance = instanceMap.get(specId);
1293
+ if (!instance) {
1294
+ return c.json({ error: `Unknown spec: ${specId}` }, 404);
1295
+ }
1296
+ return instance.server.app.fetch(c.req.raw);
1297
+ };
1298
+ }
1299
+ async function createOrchestrator(options, vite, cwd) {
1300
+ const logger = options.logger ?? console;
1301
+ const instances = [];
1302
+ for (let i = 0; i < options.specs.length; i++) {
1303
+ const { instance, resolvedConfig } = await processSpec(
1304
+ options.specs[i],
1305
+ i,
1306
+ options,
1307
+ vite,
1308
+ cwd,
1309
+ logger
1310
+ );
1311
+ instance.config.id = resolvedConfig.id;
1312
+ instance.config.proxyPath = resolvedConfig.proxyPath;
1313
+ instance.config.proxyPathSource = resolvedConfig.proxyPathSource;
1314
+ instance.config.handlersDir = resolvedConfig.handlersDir;
1315
+ instance.config.seedsDir = resolvedConfig.seedsDir;
1316
+ instances.push(instance);
1317
+ }
1318
+ validateUniqueIds(instances.map((inst) => inst.id));
1319
+ validateUniqueProxyPaths(
1320
+ instances.map((inst) => ({
1321
+ id: inst.id,
1322
+ proxyPath: inst.config.proxyPath
1323
+ }))
1324
+ );
1325
+ const mainApp = new Hono();
1326
+ const corsConfig = buildCorsConfig(options);
1327
+ if (options.cors) {
1328
+ mainApp.use("*", cors(corsConfig));
1329
+ for (const inst of instances) {
1330
+ inst.server.app.use("*", cors(corsConfig));
1331
+ }
1332
+ }
1333
+ if (options.devtools) {
1334
+ mountDevToolsSpa(mainApp, logger);
1335
+ }
1336
+ if (instances.length > 0) {
1337
+ mountMultiSpecInternalApi(mainApp, instances);
1338
+ }
1339
+ const specsInfo = instances.map((inst) => inst.info);
1340
+ const wsHub = createMultiSpecWebSocketHub(instances, specsInfo);
1341
+ const { injectWebSocket } = await mountWebSocketRoute(mainApp, wsHub, logger);
1342
+ const instanceMap = new Map(instances.map((inst) => [inst.id, inst]));
1343
+ mainApp.use("*", createDispatchMiddleware(instanceMap));
1344
+ let serverInstance = null;
1345
+ let boundPort = 0;
1346
+ async function startServerOnPort(fetchHandler, port) {
1347
+ let createAdaptorServer;
1348
+ try {
1349
+ const nodeServer = await import('@hono/node-server');
1350
+ createAdaptorServer = nodeServer.createAdaptorServer;
1351
+ } catch {
1352
+ throw new Error("@hono/node-server is required. Install with: npm install @hono/node-server");
1353
+ }
1354
+ const server = createAdaptorServer({ fetch: fetchHandler });
1355
+ const actualPort = await new Promise((resolve, reject) => {
1356
+ const onListening = () => {
1357
+ server.removeListener("error", onError);
1358
+ const addr = server.address();
1359
+ resolve(typeof addr === "object" && addr ? addr.port : port);
1360
+ };
1361
+ const onError = (err) => {
1362
+ server.removeListener("listening", onListening);
1363
+ server.close(() => {
1364
+ });
1365
+ if (err.code === "EADDRINUSE") {
1366
+ reject(new Error(`[vite-plugin-open-api-server] Port ${port} is already in use.`));
1367
+ } else {
1368
+ reject(new Error(`[vite-plugin-open-api-server] Server error: ${err.message}`));
1369
+ }
1370
+ };
1371
+ server.once("listening", onListening);
1372
+ server.once("error", onError);
1373
+ server.listen(port);
1374
+ });
1375
+ return { server, actualPort };
1376
+ }
1377
+ return {
1378
+ app: mainApp,
1379
+ instances,
1380
+ specsInfo,
1381
+ wsHub,
1382
+ get port() {
1383
+ return boundPort;
1384
+ },
1385
+ async start() {
1386
+ if (serverInstance) {
1387
+ throw new Error("[vite-plugin-open-api-server] Server already running. Call stop() first.");
1388
+ }
1389
+ const { server, actualPort } = await startServerOnPort(mainApp.fetch, options.port);
1390
+ serverInstance = server;
1391
+ boundPort = actualPort;
1392
+ if (injectWebSocket) {
1393
+ injectWebSocket(serverInstance);
1394
+ }
1395
+ logger.info(`[vite-plugin-open-api-server] Server started on http://localhost:${actualPort}`);
1396
+ },
1397
+ async stop() {
1398
+ const server = serverInstance;
1399
+ if (server) {
1400
+ try {
1401
+ wsHub.clear();
1402
+ if (typeof server.closeAllConnections === "function") {
1403
+ server.closeAllConnections();
1404
+ }
1405
+ await new Promise((resolve, reject) => {
1406
+ server.close((err) => {
1407
+ if (err) {
1408
+ reject(err);
1409
+ } else {
1410
+ resolve();
1411
+ }
1412
+ });
1413
+ });
1414
+ logger.info("[vite-plugin-open-api-server] Server stopped");
1415
+ } catch (err) {
1416
+ logger.error?.("[vite-plugin-open-api-server] Error closing server:", err);
1417
+ throw err;
1418
+ } finally {
1419
+ serverInstance = null;
1420
+ boundPort = 0;
1421
+ }
1422
+ }
1423
+ }
1424
+ };
1425
+ }
1426
+
461
1427
  // src/plugin.ts
462
1428
  var VIRTUAL_DEVTOOLS_TAB_ID = "virtual:open-api-devtools-tab";
463
1429
  var RESOLVED_VIRTUAL_DEVTOOLS_TAB_ID = `\0${VIRTUAL_DEVTOOLS_TAB_ID}`;
464
1430
  function openApiServer(options) {
465
1431
  const resolvedOptions = resolveOptions(options);
466
- let server = null;
467
- let vite = null;
468
- let fileWatcher = null;
1432
+ let orchestrator = null;
1433
+ let fileWatchers = [];
469
1434
  let cwd = process.cwd();
470
1435
  return {
471
1436
  name: "vite-plugin-open-api-server",
472
- // Only active in dev mode
473
1437
  apply: "serve",
474
1438
  /**
475
1439
  * Ensure @vue/devtools-api is available for the DevTools tab module
@@ -515,7 +1479,7 @@ import { addCustomTab } from '@vue/devtools-api';
515
1479
 
516
1480
  try {
517
1481
  // Route through Vite's proxy so it works in all environments
518
- const iframeSrc = window.location.origin + '/_devtools/';
1482
+ const iframeSrc = window.location.origin + '${DEVTOOLS_PROXY_PATH}/';
519
1483
 
520
1484
  addCustomTab({
521
1485
  name: 'vite-plugin-open-api-server',
@@ -538,78 +1502,50 @@ try {
538
1502
  *
539
1503
  * This hook is called when the dev server is created.
540
1504
  * We use it to:
541
- * 1. Start the OpenAPI mock server
542
- * 2. Configure the Vite proxy to forward API requests
543
- * 3. Set up file watching for hot reload
1505
+ * 1. Create and start the multi-spec orchestrator
1506
+ * 2. Configure Vite's multi-proxy for all specs
1507
+ * 3. Set up per-spec file watchers for hot reload
1508
+ * 4. Print startup banner
544
1509
  */
545
1510
  async configureServer(viteServer) {
546
- vite = viteServer;
547
1511
  cwd = viteServer.config.root;
548
1512
  if (!resolvedOptions.enabled) {
549
1513
  return;
550
1514
  }
551
1515
  try {
552
- const handlersResult = await loadHandlers(resolvedOptions.handlersDir, viteServer, cwd);
553
- const seedsResult = await loadSeeds(resolvedOptions.seedsDir, viteServer, cwd);
554
- let devtoolsSpaDir;
555
- if (resolvedOptions.devtools) {
556
- const pluginDir = dirname(fileURLToPath(import.meta.url));
557
- const spaDir = join(pluginDir, "devtools-spa");
558
- if (existsSync(spaDir)) {
559
- devtoolsSpaDir = spaDir;
560
- } else {
561
- resolvedOptions.logger?.warn?.(
562
- "[vite-plugin-open-api-server] DevTools SPA not found at",
563
- spaDir,
564
- '- serving placeholder. Run "pnpm build" to include the SPA.'
565
- );
566
- }
567
- }
568
- const opts = resolvedOptions;
569
- server = await createOpenApiServer({
570
- spec: opts.spec,
571
- port: opts.port,
572
- idFields: opts.idFields,
573
- handlers: handlersResult.handlers,
574
- // Seeds are populated via executeSeeds, not directly
575
- seeds: /* @__PURE__ */ new Map(),
576
- timelineLimit: opts.timelineLimit,
577
- devtools: opts.devtools,
578
- devtoolsSpaDir,
579
- cors: opts.cors,
580
- corsOrigin: opts.corsOrigin,
581
- logger: opts.logger
582
- });
583
- if (seedsResult.seeds.size > 0) {
584
- await executeSeeds(seedsResult.seeds, server.store, server.document);
585
- }
586
- await server.start();
587
- configureProxy(viteServer, resolvedOptions.proxyPath, resolvedOptions.port);
588
- const bannerInfo = extractBannerInfo(
589
- server.registry,
590
- {
591
- info: {
592
- title: server.document.info?.title ?? "OpenAPI Server",
593
- version: server.document.info?.version ?? "1.0.0"
594
- }
595
- },
596
- handlersResult.handlers.size,
597
- seedsResult.seeds.size,
1516
+ orchestrator = await createOrchestrator(resolvedOptions, viteServer, cwd);
1517
+ await orchestrator.start();
1518
+ configureMultiProxy(viteServer, orchestrator.instances, orchestrator.port);
1519
+ fileWatchers = await createPerSpecFileWatchers(
1520
+ orchestrator.instances,
1521
+ viteServer,
1522
+ cwd,
598
1523
  resolvedOptions
599
1524
  );
600
- printBanner(bannerInfo, resolvedOptions);
601
- await setupFileWatching();
1525
+ if (!resolvedOptions.silent && orchestrator.instances.length > 0) {
1526
+ const firstInstance = orchestrator.instances[0];
1527
+ const bannerInfo = extractBannerInfo(
1528
+ firstInstance.server.registry,
1529
+ {
1530
+ info: {
1531
+ title: firstInstance.server.document.info?.title ?? "OpenAPI Server",
1532
+ version: firstInstance.server.document.info?.version ?? "1.0.0"
1533
+ }
1534
+ },
1535
+ // Handler/seed counts are not tracked per-instance yet.
1536
+ // Multi-spec banner (Epic 5, Task 5.1) will display proper per-spec counts.
1537
+ 0,
1538
+ 0,
1539
+ resolvedOptions
1540
+ );
1541
+ printBanner(bannerInfo, resolvedOptions);
1542
+ }
602
1543
  } catch (error) {
1544
+ await teardown();
603
1545
  printError("Failed to start OpenAPI mock server", error, resolvedOptions);
604
1546
  throw error;
605
1547
  }
606
1548
  },
607
- /**
608
- * Clean up when Vite server closes
609
- */
610
- async closeBundle() {
611
- await cleanup();
612
- },
613
1549
  /**
614
1550
  * Inject Vue DevTools custom tab registration script
615
1551
  *
@@ -629,102 +1565,30 @@ try {
629
1565
  injectTo: "head"
630
1566
  }
631
1567
  ];
1568
+ },
1569
+ /**
1570
+ * Clean up when Vite server closes
1571
+ *
1572
+ * NOTE: closeBundle() is called by Vite when the dev server shuts down
1573
+ * (e.g., Ctrl+C). This is the same lifecycle hook used in v0.x.
1574
+ * While configureServer's viteServer.httpServer?.on('close', ...) is
1575
+ * an alternative, closeBundle() is more reliable across Vite versions
1576
+ * and is the established pattern in this codebase.
1577
+ */
1578
+ async closeBundle() {
1579
+ await teardown();
632
1580
  }
633
1581
  };
634
- function configureProxy(vite2, proxyPath, port) {
635
- const serverConfig = vite2.config.server ?? {};
636
- const proxyConfig = serverConfig.proxy ?? {};
637
- const escapedPath = proxyPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
638
- const pathPrefixRegex = new RegExp(`^${escapedPath}`);
639
- proxyConfig[proxyPath] = {
640
- target: `http://localhost:${port}`,
641
- changeOrigin: true,
642
- // Remove the proxy path prefix when forwarding
643
- rewrite: (path4) => path4.replace(pathPrefixRegex, "")
644
- };
645
- proxyConfig["/_devtools"] = {
646
- target: `http://localhost:${port}`,
647
- changeOrigin: true
648
- };
649
- proxyConfig["/_api"] = {
650
- target: `http://localhost:${port}`,
651
- changeOrigin: true
652
- };
653
- proxyConfig["/_ws"] = {
654
- target: `http://localhost:${port}`,
655
- changeOrigin: true,
656
- ws: true
657
- };
658
- if (vite2.config.server) {
659
- vite2.config.server.proxy = proxyConfig;
660
- }
661
- }
662
- async function setupFileWatching() {
663
- if (!server || !vite) return;
664
- const debouncedHandlerReload = debounce(reloadHandlers, 100);
665
- const debouncedSeedReload = debounce(reloadSeeds, 100);
666
- const watchOpts = resolvedOptions;
667
- fileWatcher = await createFileWatcher({
668
- handlersDir: watchOpts.handlersDir,
669
- seedsDir: watchOpts.seedsDir,
670
- cwd,
671
- onHandlerChange: debouncedHandlerReload,
672
- onSeedChange: debouncedSeedReload
673
- });
674
- }
675
- async function reloadHandlers() {
676
- if (!server || !vite) return;
677
- try {
678
- const handlersResult = await loadHandlers(
679
- // biome-ignore lint/suspicious/noExplicitAny: v0.x compat — plugin.ts rewritten in Task 1.7
680
- resolvedOptions.handlersDir,
681
- vite,
682
- cwd
683
- );
684
- server.updateHandlers(handlersResult.handlers);
685
- server.wsHub.broadcast({
686
- type: "handlers:updated",
687
- data: { count: handlersResult.handlers.size }
688
- });
689
- printReloadNotification("handlers", handlersResult.handlers.size, resolvedOptions);
690
- } catch (error) {
691
- printError("Failed to reload handlers", error, resolvedOptions);
692
- }
693
- }
694
- async function reloadSeeds() {
695
- if (!server || !vite) return;
696
- try {
697
- const seedsResult = await loadSeeds(
698
- // biome-ignore lint/suspicious/noExplicitAny: v0.x compat — plugin.ts rewritten in Task 1.7
699
- resolvedOptions.seedsDir,
700
- vite,
701
- cwd
702
- );
703
- if (seedsResult.seeds.size > 0) {
704
- server.store.clearAll();
705
- await executeSeeds(seedsResult.seeds, server.store, server.document);
706
- } else {
707
- server.store.clearAll();
1582
+ async function teardown() {
1583
+ await Promise.allSettled(fileWatchers.map((w) => w.close()));
1584
+ fileWatchers = [];
1585
+ if (orchestrator) {
1586
+ try {
1587
+ await orchestrator.stop();
1588
+ } catch {
708
1589
  }
709
- server.wsHub.broadcast({
710
- type: "seeds:updated",
711
- data: { count: seedsResult.seeds.size }
712
- });
713
- printReloadNotification("seeds", seedsResult.seeds.size, resolvedOptions);
714
- } catch (error) {
715
- printError("Failed to reload seeds", error, resolvedOptions);
716
- }
717
- }
718
- async function cleanup() {
719
- if (fileWatcher) {
720
- await fileWatcher.close();
721
- fileWatcher = null;
722
- }
723
- if (server) {
724
- await server.stop();
725
- server = null;
1590
+ orchestrator = null;
726
1591
  }
727
- vite = null;
728
1592
  }
729
1593
  }
730
1594
 
@@ -758,12 +1622,12 @@ async function registerDevTools(app, options = {}) {
758
1622
  console.warn("[OpenAPI DevTools] Failed to register with Vue DevTools:", error);
759
1623
  }
760
1624
  }
761
- function getDevToolsUrl(port = 3e3, host, protocol) {
1625
+ function getDevToolsUrl(port = 4e3, host, protocol) {
762
1626
  const actualProtocol = protocol || (typeof window !== "undefined" ? window.location.protocol.replace(":", "") : "http");
763
1627
  const actualHost = host || (typeof window !== "undefined" ? window.location.hostname : "localhost");
764
1628
  return `${actualProtocol}://${actualHost}:${port}/_devtools/`;
765
1629
  }
766
1630
 
767
- export { ValidationError, createFileWatcher, debounce, getDevToolsUrl, getHandlerFiles, getSeedFiles, loadHandlers, loadSeeds, openApiServer, registerDevTools };
1631
+ export { API_PROXY_PATH, DEVTOOLS_PROXY_PATH, SPEC_COLORS, ValidationError, WS_PROXY_PATH, configureMultiProxy, createFileWatcher, createOrchestrator, createPerSpecFileWatchers, debounce, deriveProxyPath, deriveSpecId, getDevToolsUrl, getHandlerFiles, getSeedFiles, loadHandlers, loadSeeds, normalizeProxyPath, openApiServer, registerDevTools, reloadSpecHandlers, reloadSpecSeeds, resolveOptions, slugify, validateSpecs, validateUniqueIds, validateUniqueProxyPaths };
768
1632
  //# sourceMappingURL=index.js.map
769
1633
  //# sourceMappingURL=index.js.map