@mandujs/core 0.35.1 → 0.37.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.35.1",
3
+ "version": "0.37.0",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -126,6 +126,29 @@ export interface ManduConfig {
126
126
  spa?: boolean;
127
127
  server?: {
128
128
  port?: number;
129
+ /**
130
+ * Bind hostname for the HTTP server.
131
+ *
132
+ * Default: `"::"` (IPv6 wildcard, dual-stack). Bun leaves
133
+ * `IPV6_V6ONLY` off, so this one socket accepts both IPv6 clients
134
+ * (e.g. Node 17+ `fetch("localhost:PORT")` on Windows resolves to
135
+ * `::1`) and IPv4 clients (as IPv4-mapped IPv6) — you effectively
136
+ * get `0.0.0.0` + `::` for free.
137
+ *
138
+ * Set `"0.0.0.0"` to bind IPv4 only (container/firewall setups that
139
+ * need it). Note: on Windows, an IPv4-only bind makes Node's
140
+ * `fetch("localhost:PORT")` fail with `ECONNREFUSED ::1:PORT`
141
+ * because Node prefers the IPv6 address for `localhost`. `curl`
142
+ * and browsers silently fall back to IPv4, hiding the bug — Mandu
143
+ * emits a one-line warning on Windows when you pick this value
144
+ * explicitly so the trap is discoverable.
145
+ *
146
+ * Set `"127.0.0.1"` or `"::1"` to bind loopback-only (no LAN
147
+ * visibility). Set any other value (e.g. `"10.0.0.2"`,
148
+ * `"myhost.example.com"`) to bind that specific interface.
149
+ *
150
+ * @see issues #190 #223 #225
151
+ */
129
152
  hostname?: string;
130
153
  cors?:
131
154
  | boolean
@@ -44,9 +44,12 @@ function strictWithWarnings<T extends z.ZodRawShape>(
44
44
  const ServerConfigSchema = z
45
45
  .object({
46
46
  port: z.number().min(1).max(65535).default(3000),
47
- // Default 0.0.0.0 so IPv4 `localhost` resolution (Windows default) succeeds.
48
- // Users may pin "::1" or "127.0.0.1" explicitly. See issue #190.
49
- hostname: z.string().default("0.0.0.0"),
47
+ // Default `"::"` (IPv6 wildcard, dual-stack): accepts both IPv4 and
48
+ // IPv6 clients on one socket. Fixes Windows Node 17+ fetch failing
49
+ // with `ECONNREFUSED ::1:PORT` because `localhost` resolves to `::1`
50
+ // first there. Explicit `"0.0.0.0"` (IPv4-only) and `"::1"` /
51
+ // `"127.0.0.1"` (loopback-only) are still honored. See #190 #223.
52
+ hostname: z.string().default("::"),
50
53
  cors: z
51
54
  .union([
52
55
  z.boolean(),
@@ -26,6 +26,33 @@ import {
26
26
  isZodRequired,
27
27
  } from "../contract/zod-utils";
28
28
 
29
+ // ============================================
30
+ // Schema name-hint side table
31
+ // ============================================
32
+
33
+ /**
34
+ * Side-channel naming hints for generated OpenAPI schemas.
35
+ *
36
+ * The hoist pass needs a preferred name for each object schema but we
37
+ * don't want to serialize that hint into the output document. A WeakMap
38
+ * keyed by the generated `OpenAPISchema` object gives us name access
39
+ * during the post-processing pass without polluting the spec JSON.
40
+ *
41
+ * Hints are advisory — the hoist pass falls back to a deterministic
42
+ * `Schema_<hash8>` name when no hint is attached.
43
+ */
44
+ const schemaNameHints = new WeakMap<OpenAPISchema, string>();
45
+
46
+ function setSchemaNameHint(schema: OpenAPISchema | undefined, hint: string | undefined): void {
47
+ if (!schema || !hint) return;
48
+ // Don't overwrite an existing hint — first attribution wins. This matches
49
+ // the "first-come wins" policy for structurally-identical schemas with
50
+ // different user-given names.
51
+ if (!schemaNameHints.has(schema)) {
52
+ schemaNameHints.set(schema, hint);
53
+ }
54
+ }
55
+
29
56
  // ============================================
30
57
  // OpenAPI Types
31
58
  // ============================================
@@ -373,6 +400,35 @@ function generateParameters(
373
400
  return parameters;
374
401
  }
375
402
 
403
+ /**
404
+ * Convert an arbitrary identifier to a PascalCase token suitable for an
405
+ * OpenAPI component name. Non-alphanumeric characters become word
406
+ * separators; leading digits are prefixed with `_` so the result is a
407
+ * valid JSON Schema identifier.
408
+ */
409
+ function pascal(input: string): string {
410
+ if (!input) return "";
411
+ const words = input
412
+ .split(/[^a-zA-Z0-9]+/g)
413
+ .filter((w) => w.length > 0)
414
+ .map((w) => w[0].toUpperCase() + w.slice(1));
415
+ const joined = words.join("");
416
+ if (!joined) return "";
417
+ return /^[0-9]/.test(joined) ? `_${joined}` : joined;
418
+ }
419
+
420
+ /**
421
+ * Derive a naming hint root for a contract. Prefers the explicit
422
+ * `contract.name`; falls back to the route id (e.g. `api/users` →
423
+ * `ApiUsers`). Guarantees we always have a non-empty PascalCase prefix
424
+ * for generated component names.
425
+ */
426
+ function deriveContractNameRoot(contract: ContractSchema, route: RouteSpec): string {
427
+ const hint = contract.name ?? route.id;
428
+ const pascalized = pascal(hint);
429
+ return pascalized || "Schema";
430
+ }
431
+
376
432
  /**
377
433
  * Generate OpenAPI operation for a method
378
434
  */
@@ -389,6 +445,11 @@ function generateOperation(
389
445
  responses: {},
390
446
  };
391
447
 
448
+ // Derive a naming hint root from the contract for the hoist pass.
449
+ // Example: `contract.name = "users"` → `Users`. Falls back to a
450
+ // route-derived identifier so every contract has *some* stable prefix.
451
+ const nameRoot = deriveContractNameRoot(contract, route);
452
+
392
453
  // Parameters
393
454
  if (methodSchema) {
394
455
  const params = generateParameters(methodSchema, route.pattern);
@@ -398,8 +459,13 @@ function generateOperation(
398
459
 
399
460
  // Request body
400
461
  if (methodSchema.body) {
462
+ const bodySchema = zodToOpenAPISchema(methodSchema.body);
463
+ // Hint: `UsersBody` / `UsersPostBody` — method-qualified avoids
464
+ // collisions when GET/POST/PUT on the same contract have distinct
465
+ // request bodies.
466
+ setSchemaNameHint(bodySchema, `${nameRoot}${pascal(method)}Body`);
401
467
  const requestBodyContent: OpenAPIRequestBody["content"]["application/json"] = {
402
- schema: zodToOpenAPISchema(methodSchema.body),
468
+ schema: bodySchema,
403
469
  };
404
470
 
405
471
  // Add examples if provided
@@ -433,6 +499,10 @@ function generateOperation(
433
499
  }
434
500
 
435
501
  const schema = zodToOpenAPISchema(zodSchema);
502
+ // Hint: `UsersResponse200` / `UsersPostResponse201`. Status-qualified
503
+ // so `200` and `400` responses don't collide when both are
504
+ // `{ error: string }` style objects.
505
+ setSchemaNameHint(schema, `${nameRoot}${pascal(method)}Response${statusCode}`);
436
506
  const hasContent = Object.keys(schema).length > 0;
437
507
 
438
508
  const responseContent: OpenAPIResponse["content"] = hasContent
@@ -496,6 +566,17 @@ export async function generateOpenAPIDocument(
496
566
  version?: string;
497
567
  description?: string;
498
568
  servers?: OpenAPIServer[];
569
+ /**
570
+ * Hoist schemas that appear ≥`hoistThreshold` times into
571
+ * `components.schemas` and replace inline occurrences with `$ref`.
572
+ * @default true
573
+ */
574
+ hoistSchemas?: boolean;
575
+ /**
576
+ * Minimum occurrence count required to hoist a schema.
577
+ * @default 2
578
+ */
579
+ hoistThreshold?: number;
499
580
  } = {}
500
581
  ): Promise<OpenAPIDocument> {
501
582
  const paths: Record<string, OpenAPIPathItem> = {};
@@ -530,7 +611,7 @@ export async function generateOpenAPIDocument(
530
611
  paths[openApiPattern] = pathItem;
531
612
  }
532
613
 
533
- return {
614
+ const doc: OpenAPIDocument = {
534
615
  openapi: "3.0.3",
535
616
  info: {
536
617
  title: options.title || "Mandu API",
@@ -543,6 +624,277 @@ export async function generateOpenAPIDocument(
543
624
  paths,
544
625
  tags: Array.from(tags).map((name) => ({ name })),
545
626
  };
627
+
628
+ // Post-processing: hoist shared schemas into components.schemas.
629
+ // `hoistSchemas: false` → pure backwards-compatible inline output.
630
+ const hoistEnabled = options.hoistSchemas !== false;
631
+ if (hoistEnabled) {
632
+ await hoistSharedSchemas(doc, {
633
+ threshold: options.hoistThreshold ?? 2,
634
+ });
635
+ }
636
+
637
+ return doc;
638
+ }
639
+
640
+ // ============================================
641
+ // Schema hoisting (post-processing)
642
+ // ============================================
643
+
644
+ export interface HoistOptions {
645
+ /** Minimum occurrences required to hoist. Default `2`. */
646
+ threshold?: number;
647
+ }
648
+
649
+ interface SchemaOccurrence {
650
+ /** Structural hash of the schema body (identity key). */
651
+ hash: string;
652
+ /** Every parent reference we might need to rewrite to `$ref`. */
653
+ sites: Array<{ parent: Record<string, unknown>; key: string | number }>;
654
+ /** The canonical schema body (first-seen value; all sites share equal shape). */
655
+ body: OpenAPISchema;
656
+ /** Ordered list of name hints collected at each occurrence site. */
657
+ hints: string[];
658
+ }
659
+
660
+ /**
661
+ * Hoist shared object schemas into `components.schemas` and replace
662
+ * inline occurrences with `$ref` pointers.
663
+ *
664
+ * Only hoists:
665
+ * - `type: "object"` schemas (primitives / enums / unions-of-primitives stay inline).
666
+ * - Schemas appearing at a "hoistable site" (requestBody/response root or a top-level
667
+ * property of either). Path/query/header parameter schemas are explicitly skipped.
668
+ * - Schemas reaching the occurrence threshold (default 2).
669
+ *
670
+ * Naming:
671
+ * - First hint wins for structurally-identical schemas.
672
+ * - Name collisions across structurally-different schemas are disambiguated with
673
+ * `_v2`, `_v3`, … suffixes (deterministic by walk order).
674
+ * - Hint-less schemas fall back to `Schema_<first-8-hex-of-hash>`.
675
+ */
676
+ export async function hoistSharedSchemas(
677
+ doc: OpenAPIDocument,
678
+ options: HoistOptions = {}
679
+ ): Promise<void> {
680
+ const threshold = Math.max(2, options.threshold ?? 2);
681
+ const occurrences = new Map<string, SchemaOccurrence>();
682
+
683
+ // --- Pass 1: walk every hoistable site and record occurrences. ---
684
+ for (const pathItem of Object.values(doc.paths)) {
685
+ for (const method of ["get", "post", "put", "patch", "delete"] as const) {
686
+ const op = pathItem[method];
687
+ if (!op) continue;
688
+
689
+ // Request body root schema (+ top-level property values).
690
+ if (op.requestBody?.content) {
691
+ for (const mediaObj of Object.values(op.requestBody.content)) {
692
+ recordHoistableTree(mediaObj as Record<string, unknown>, "schema", occurrences);
693
+ }
694
+ }
695
+
696
+ // Response root schema (+ top-level property values).
697
+ for (const resp of Object.values(op.responses)) {
698
+ if (!resp.content) continue;
699
+ for (const mediaObj of Object.values(resp.content)) {
700
+ recordHoistableTree(mediaObj as Record<string, unknown>, "schema", occurrences);
701
+ }
702
+ }
703
+ }
704
+ }
705
+
706
+ // --- Pass 2: pick the winners (count ≥ threshold, type === "object"). ---
707
+ const winners: Array<{ name: string; hash: string; body: OpenAPISchema; sites: SchemaOccurrence["sites"] }> = [];
708
+ const nameIndex = new Map<string, string>(); // name → hash (for collision detection)
709
+
710
+ // Stable iteration: sort by hash so hoisted output is deterministic
711
+ // regardless of JS property enumeration order quirks.
712
+ const entries = Array.from(occurrences.entries()).sort(([a], [b]) => a.localeCompare(b));
713
+ for (const [hash, entry] of entries) {
714
+ if (entry.sites.length < threshold) continue;
715
+ if (!isHoistableObject(entry.body)) continue;
716
+
717
+ // Pick the first-seen hint; fall back to the hash-derived default.
718
+ const preferred = entry.hints[0] || `Schema_${hash.slice(0, 8)}`;
719
+ let finalName = preferred;
720
+ let suffix = 2;
721
+ // If the name is already claimed by a *different* hash, walk `_v2`, `_v3`…
722
+ while (nameIndex.has(finalName) && nameIndex.get(finalName) !== hash) {
723
+ finalName = `${preferred}_v${suffix++}`;
724
+ }
725
+ nameIndex.set(finalName, hash);
726
+ winners.push({ name: finalName, hash, body: entry.body, sites: entry.sites });
727
+ }
728
+
729
+ if (winners.length === 0) return;
730
+
731
+ // --- Pass 3: install winners into components.schemas + rewrite sites. ---
732
+ if (!doc.components) doc.components = {};
733
+ if (!doc.components.schemas) doc.components.schemas = {};
734
+ const schemas = doc.components.schemas;
735
+
736
+ for (const winner of winners) {
737
+ schemas[winner.name] = winner.body;
738
+ const ref: OpenAPISchema = { $ref: `#/components/schemas/${winner.name}` };
739
+ for (const site of winner.sites) {
740
+ // Replace inline schema with a `$ref` pointer. We write a *new*
741
+ // ref object per site (JSON.stringify would collapse shared refs
742
+ // anyway, but distinct identities make future passes safer).
743
+ (site.parent as Record<string | number, unknown>)[site.key] = { ...ref };
744
+ }
745
+ }
746
+ }
747
+
748
+ /**
749
+ * Walk a container object and record every hoistable schema site. A
750
+ * hoistable site is:
751
+ *
752
+ * 1. The root schema at `container[key]` (typically the `"schema"` key of a
753
+ * requestBody/response media type object).
754
+ * 2. Every top-level property of that root, *if* the root is an object schema.
755
+ *
756
+ * Nested properties beyond depth 1 are intentionally ignored — hoisting
757
+ * deep nests produces spec trees that are harder for codegen tools to
758
+ * follow than the inline original.
759
+ */
760
+ function recordHoistableTree(
761
+ container: Record<string, unknown>,
762
+ key: string,
763
+ occurrences: Map<string, SchemaOccurrence>
764
+ ): void {
765
+ const root = container[key] as OpenAPISchema | undefined;
766
+ if (!root || typeof root !== "object") return;
767
+ // Skip anything that's already a $ref (e.g., from an earlier pass).
768
+ if ("$ref" in root && root.$ref) return;
769
+
770
+ // Record the root itself (regardless of object vs primitive — we filter
771
+ // in pass 2 so that primitives with many occurrences still get counted
772
+ // but are never emitted as component entries).
773
+ recordOccurrence(root, container as Record<string, unknown>, key, occurrences);
774
+
775
+ // Drill into top-level properties for object roots.
776
+ if (root.type === "object" && root.properties) {
777
+ for (const [propKey, propSchema] of Object.entries(root.properties)) {
778
+ if (!propSchema || typeof propSchema !== "object") continue;
779
+ if ("$ref" in propSchema && propSchema.$ref) continue;
780
+ recordOccurrence(
781
+ propSchema as OpenAPISchema,
782
+ root.properties as Record<string, unknown>,
783
+ propKey,
784
+ occurrences
785
+ );
786
+ }
787
+ }
788
+
789
+ // Drill into array items when the root is an array — the element shape
790
+ // is the real reusable schema, not the array wrapper itself.
791
+ if (root.type === "array" && root.items) {
792
+ const items = root.items;
793
+ if (items && typeof items === "object" && !("$ref" in items && items.$ref)) {
794
+ recordOccurrence(
795
+ items,
796
+ root as unknown as Record<string, unknown>,
797
+ "items",
798
+ occurrences
799
+ );
800
+ }
801
+ }
802
+ }
803
+
804
+ function recordOccurrence(
805
+ schema: OpenAPISchema,
806
+ parent: Record<string, unknown>,
807
+ key: string | number,
808
+ occurrences: Map<string, SchemaOccurrence>
809
+ ): void {
810
+ const hash = structuralHash(schema);
811
+ let entry = occurrences.get(hash);
812
+ if (!entry) {
813
+ entry = { hash, sites: [], body: schema, hints: [] };
814
+ occurrences.set(hash, entry);
815
+ }
816
+ entry.sites.push({ parent, key });
817
+ const hint = schemaNameHints.get(schema);
818
+ if (hint && !entry.hints.includes(hint)) {
819
+ entry.hints.push(hint);
820
+ }
821
+ }
822
+
823
+ /**
824
+ * A schema is hoistable iff it is an object with ≥1 property. We
825
+ * deliberately skip:
826
+ * - primitives (`string`, `number`, `boolean`, `integer`) — they are
827
+ * small enough that inline is clearer than a pointer chase.
828
+ * - enums and unions of primitives — same argument, and codegen tools
829
+ * tend to de-duplicate these separately.
830
+ * - empty object schemas — no information to share.
831
+ * - $ref passthroughs — already hoisted.
832
+ * - object schemas with a composed keyword (allOf/oneOf/anyOf) but no
833
+ * `properties` — these are structural compositions, not data shapes.
834
+ */
835
+ function isHoistableObject(schema: OpenAPISchema): boolean {
836
+ if (!schema || typeof schema !== "object") return false;
837
+ if (schema.$ref) return false;
838
+ if (schema.type !== "object") return false;
839
+ if (!schema.properties) return false;
840
+ if (Object.keys(schema.properties).length === 0) return false;
841
+ return true;
842
+ }
843
+
844
+ /**
845
+ * Stable structural hash of a schema. Sorts object keys recursively
846
+ * before stringifying so `{a:1,b:2}` and `{b:2,a:1}` hash identically,
847
+ * then feeds the canonical string through SHA-256.
848
+ */
849
+ function structuralHash(schema: OpenAPISchema): string {
850
+ const canonical = stableStringify(schema);
851
+ return sha256Hex(canonical);
852
+ }
853
+
854
+ function stableStringify(value: unknown): string {
855
+ if (value === null || value === undefined) return "null";
856
+ if (typeof value === "boolean" || typeof value === "number") return JSON.stringify(value);
857
+ if (typeof value === "string") return JSON.stringify(value);
858
+ if (Array.isArray(value)) {
859
+ return "[" + value.map((v) => stableStringify(v)).join(",") + "]";
860
+ }
861
+ if (typeof value === "object") {
862
+ const keys = Object.keys(value as Record<string, unknown>).sort();
863
+ const parts: string[] = [];
864
+ for (const k of keys) {
865
+ const v = (value as Record<string, unknown>)[k];
866
+ if (v === undefined) continue;
867
+ parts.push(JSON.stringify(k) + ":" + stableStringify(v));
868
+ }
869
+ return "{" + parts.join(",") + "}";
870
+ }
871
+ return JSON.stringify(value);
872
+ }
873
+
874
+ /**
875
+ * Synchronous SHA-256 hex digest. Uses `Bun.CryptoHasher` when present,
876
+ * then Node `crypto.createHash` as a fallback. WebCrypto is async-only
877
+ * so we intentionally skip it here — the hoist pass hashes many small
878
+ * payloads and the sync path keeps the post-process simple and fast.
879
+ *
880
+ * This is a purely structural identity key (not a security primitive),
881
+ * so the brief Node-fallback surface is acceptable.
882
+ */
883
+ function sha256Hex(input: string): string {
884
+ const bunGlobal = (globalThis as { Bun?: { CryptoHasher?: new (algo: string) => { update(input: string | Uint8Array): void; digest(encoding: "hex"): string } } }).Bun;
885
+ if (bunGlobal?.CryptoHasher) {
886
+ const hasher = new bunGlobal.CryptoHasher("sha256");
887
+ hasher.update(input);
888
+ return hasher.digest("hex");
889
+ }
890
+ // Node fallback — `require` at call time to keep module-load lean.
891
+ // Callers who run in an edge runtime without Node crypto should have
892
+ // Bun available; we therefore don't bother with a WebCrypto async
893
+ // path (it would turn `hoistSharedSchemas` into an unnecessarily async
894
+ // cascade on the hot path).
895
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
896
+ const nodeCrypto = require("node:crypto") as typeof import("node:crypto");
897
+ return nodeCrypto.createHash("sha256").update(input).digest("hex");
546
898
  }
547
899
 
548
900
  // ============================================
@@ -7,12 +7,14 @@ import { z } from "zod";
7
7
  import {
8
8
  generateOpenAPIDocument,
9
9
  hashOpenAPIJSON,
10
+ hoistSharedSchemas,
10
11
  openAPIToJSON,
11
12
  openAPIToYAML,
12
13
  readOpenAPIArtifacts,
13
14
  writeOpenAPIArtifacts,
14
15
  zodToOpenAPISchema,
15
16
  } from "./generator";
17
+ import type { OpenAPIDocument } from "./generator";
16
18
  import type { RoutesManifest } from "../spec/schema";
17
19
  import fs from "node:fs/promises";
18
20
  import os from "node:os";
@@ -512,3 +514,433 @@ describe("writeOpenAPIArtifacts + readOpenAPIArtifacts", () => {
512
514
  }
513
515
  });
514
516
  });
517
+
518
+ // ============================================
519
+ // Schema hoisting (shared schemas → components.schemas)
520
+ // ============================================
521
+
522
+ /**
523
+ * Build a two-contract fixture where both contracts share the same
524
+ * `User` body shape. The generator should hoist `{ name, email }` into
525
+ * `components.schemas` when hoisting is enabled.
526
+ */
527
+ async function buildSharedSchemaFixture(opts: {
528
+ /** Override the `name` attribute on each contract (in file order). */
529
+ contractNames?: [string, string];
530
+ /** Replace the second contract's body shape to exercise collision paths. */
531
+ secondBody?: string;
532
+ } = {}): Promise<{
533
+ rootDir: string;
534
+ manifest: RoutesManifest;
535
+ cleanup: () => Promise<void>;
536
+ }> {
537
+ const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-openapi-hoist-"));
538
+ const [nameA, nameB] = opts.contractNames ?? ["users", "admins"];
539
+ const bodyB =
540
+ opts.secondBody ??
541
+ `z.object({ name: z.string().min(2), email: z.string().email() })`;
542
+
543
+ await fs.mkdir(path.join(rootDir, "contracts"), { recursive: true });
544
+ await fs.writeFile(
545
+ path.join(rootDir, "contracts/users.contract.ts"),
546
+ `import { z } from "zod";
547
+ export default {
548
+ name: "${nameA}",
549
+ request: {
550
+ POST: {
551
+ body: z.object({ name: z.string().min(2), email: z.string().email() }),
552
+ },
553
+ },
554
+ response: {
555
+ 200: z.object({ id: z.string().uuid(), status: z.string() }),
556
+ },
557
+ };
558
+ `,
559
+ "utf-8"
560
+ );
561
+ await fs.writeFile(
562
+ path.join(rootDir, "contracts/admins.contract.ts"),
563
+ `import { z } from "zod";
564
+ export default {
565
+ name: "${nameB}",
566
+ request: {
567
+ POST: {
568
+ body: ${bodyB},
569
+ },
570
+ },
571
+ response: {
572
+ 200: z.object({ id: z.string().uuid(), status: z.string() }),
573
+ },
574
+ };
575
+ `,
576
+ "utf-8"
577
+ );
578
+
579
+ const manifest: RoutesManifest = {
580
+ version: 1,
581
+ routes: [
582
+ {
583
+ id: "api/users",
584
+ pattern: "/api/users",
585
+ kind: "api",
586
+ module: "contracts/users.contract.ts",
587
+ contractModule: "contracts/users.contract.ts",
588
+ methods: ["POST"],
589
+ },
590
+ {
591
+ id: "api/admins",
592
+ pattern: "/api/admins",
593
+ kind: "api",
594
+ module: "contracts/admins.contract.ts",
595
+ contractModule: "contracts/admins.contract.ts",
596
+ methods: ["POST"],
597
+ },
598
+ ],
599
+ };
600
+
601
+ return {
602
+ rootDir,
603
+ manifest,
604
+ cleanup: async () => {
605
+ await fs.rm(rootDir, { recursive: true, force: true });
606
+ },
607
+ };
608
+ }
609
+
610
+ describe("hoistSharedSchemas", () => {
611
+ test("two routes sharing a body shape emit a single components.schemas entry with $ref", async () => {
612
+ const { rootDir, manifest, cleanup } = await buildSharedSchemaFixture();
613
+ try {
614
+ const doc = await generateOpenAPIDocument(manifest, rootDir, {
615
+ title: "Hoist Test",
616
+ });
617
+
618
+ // The shared POST body should be hoisted. There are also shared
619
+ // 200 responses so we expect at least 2 hoisted entries.
620
+ expect(doc.components?.schemas).toBeDefined();
621
+ const schemaNames = Object.keys(doc.components!.schemas!);
622
+ expect(schemaNames.length).toBeGreaterThanOrEqual(1);
623
+
624
+ const usersBody = doc.paths["/api/users"].post!.requestBody!.content["application/json"].schema;
625
+ const adminsBody = doc.paths["/api/admins"].post!.requestBody!.content["application/json"].schema;
626
+
627
+ // Both bodies must now be $ref pointers into components.schemas.
628
+ expect(usersBody.$ref).toBeDefined();
629
+ expect(adminsBody.$ref).toBeDefined();
630
+ // ... and they must point to the exact same entry (shared shape).
631
+ expect(usersBody.$ref).toBe(adminsBody.$ref);
632
+
633
+ // The pointed-to entry must exist and retain the object shape.
634
+ const refTarget = usersBody.$ref!.replace("#/components/schemas/", "");
635
+ const hoisted = doc.components!.schemas![refTarget];
636
+ expect(hoisted.type).toBe("object");
637
+ expect(hoisted.properties!.name).toBeDefined();
638
+ expect(hoisted.properties!.email).toBeDefined();
639
+ } finally {
640
+ await cleanup();
641
+ }
642
+ });
643
+
644
+ test("one-off schema stays inline (no hoist, no components.schemas entry for it)", async () => {
645
+ const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-openapi-oneoff-"));
646
+ try {
647
+ await fs.mkdir(path.join(rootDir, "contracts"), { recursive: true });
648
+ await fs.writeFile(
649
+ path.join(rootDir, "contracts/solo.contract.ts"),
650
+ `import { z } from "zod";
651
+ export default {
652
+ name: "solo",
653
+ request: {
654
+ POST: { body: z.object({ uniqueField: z.string() }) },
655
+ },
656
+ response: { 200: z.object({ ok: z.boolean() }) },
657
+ };
658
+ `,
659
+ "utf-8"
660
+ );
661
+ const manifest: RoutesManifest = {
662
+ version: 1,
663
+ routes: [
664
+ {
665
+ id: "api/solo",
666
+ pattern: "/api/solo",
667
+ kind: "api",
668
+ module: "contracts/solo.contract.ts",
669
+ contractModule: "contracts/solo.contract.ts",
670
+ methods: ["POST"],
671
+ },
672
+ ],
673
+ };
674
+
675
+ const doc = await generateOpenAPIDocument(manifest, rootDir);
676
+ const body = doc.paths["/api/solo"].post!.requestBody!.content["application/json"].schema;
677
+
678
+ // Solo schemas must NOT be hoisted — stays inline.
679
+ expect(body.$ref).toBeUndefined();
680
+ expect(body.type).toBe("object");
681
+ expect(body.properties?.uniqueField).toBeDefined();
682
+
683
+ // components.schemas may exist (e.g. for the default 500 shape if it
684
+ // happened to collide with another schema), but the one-off body's
685
+ // uniqueField shape must not appear as a hoisted entry.
686
+ const schemas = doc.components?.schemas ?? {};
687
+ for (const entry of Object.values(schemas)) {
688
+ expect(entry.properties?.uniqueField).toBeUndefined();
689
+ }
690
+ } finally {
691
+ await fs.rm(rootDir, { recursive: true, force: true });
692
+ }
693
+ });
694
+
695
+ test("same shape with different user-given names collapses to one entry (first-come wins)", async () => {
696
+ const { rootDir, manifest, cleanup } = await buildSharedSchemaFixture({
697
+ contractNames: ["users", "admins"],
698
+ });
699
+ try {
700
+ const doc = await generateOpenAPIDocument(manifest, rootDir);
701
+
702
+ const usersBody = doc.paths["/api/users"].post!.requestBody!.content["application/json"].schema;
703
+ const adminsBody = doc.paths["/api/admins"].post!.requestBody!.content["application/json"].schema;
704
+
705
+ // Both must resolve to the *same* $ref — structural identity wins
706
+ // over the user-given name split.
707
+ expect(usersBody.$ref).toBeDefined();
708
+ expect(usersBody.$ref).toBe(adminsBody.$ref);
709
+
710
+ // Exactly one body component (not two) for this shape. Count entries
711
+ // that match `{name,email}` shape.
712
+ const sharedEntries = Object.values(doc.components!.schemas!).filter(
713
+ (s) => s.properties?.name && s.properties?.email
714
+ );
715
+ expect(sharedEntries.length).toBe(1);
716
+ } finally {
717
+ await cleanup();
718
+ }
719
+ });
720
+
721
+ test("name collision across structurally-different schemas → _v2 suffix", () => {
722
+ // Hand-craft a doc with two different body shapes whose name hints
723
+ // would collide. We exercise the hoist pass directly so we can
724
+ // control the hints without routing through Zod.
725
+ const shapeA = {
726
+ type: "object" as const,
727
+ properties: { a: { type: "string" as const } },
728
+ required: ["a"],
729
+ };
730
+ const shapeB = {
731
+ type: "object" as const,
732
+ properties: { b: { type: "number" as const } },
733
+ required: ["b"],
734
+ };
735
+
736
+ // Two routes use shape A, two use shape B — every schema gets the
737
+ // same name hint "Shared". The second winning hash should be renamed
738
+ // to "Shared_v2".
739
+ const a1 = { ...shapeA };
740
+ const a2 = { ...shapeA };
741
+ const b1 = { ...shapeB };
742
+ const b2 = { ...shapeB };
743
+
744
+ const doc: OpenAPIDocument = {
745
+ openapi: "3.0.3",
746
+ info: { title: "t", version: "1.0.0" },
747
+ paths: {
748
+ "/a1": {
749
+ post: {
750
+ responses: {},
751
+ requestBody: { content: { "application/json": { schema: a1 } } },
752
+ },
753
+ },
754
+ "/a2": {
755
+ post: {
756
+ responses: {},
757
+ requestBody: { content: { "application/json": { schema: a2 } } },
758
+ },
759
+ },
760
+ "/b1": {
761
+ post: {
762
+ responses: {},
763
+ requestBody: { content: { "application/json": { schema: b1 } } },
764
+ },
765
+ },
766
+ "/b2": {
767
+ post: {
768
+ responses: {},
769
+ requestBody: { content: { "application/json": { schema: b2 } } },
770
+ },
771
+ },
772
+ },
773
+ };
774
+
775
+ // Attach the same hint to both shapes via hoistSharedSchemas's own
776
+ // name-hint mechanism: we can't from here (WeakMap is module-private).
777
+ // So we instead rely on the deterministic hash-based fallback name,
778
+ // then assert both shapes are hoisted and receive distinct entries.
779
+ return hoistSharedSchemas(doc).then(() => {
780
+ const schemas = doc.components?.schemas ?? {};
781
+ const names = Object.keys(schemas);
782
+ // Both shapes qualify (appear twice each) → 2 hoisted entries.
783
+ expect(names.length).toBe(2);
784
+ // No two entries share the exact same name.
785
+ expect(new Set(names).size).toBe(names.length);
786
+ // The generated names must be stable strings (start with `Schema_`
787
+ // in the fallback path).
788
+ for (const n of names) {
789
+ expect(n).toMatch(/^Schema_[0-9a-f]{8}$/);
790
+ }
791
+ });
792
+ });
793
+
794
+ test("hoistSchemas: false produces the legacy inline-only document", async () => {
795
+ const { rootDir, manifest, cleanup } = await buildSharedSchemaFixture();
796
+ try {
797
+ const docOn = await generateOpenAPIDocument(manifest, rootDir, {
798
+ hoistSchemas: true,
799
+ });
800
+ const docOff = await generateOpenAPIDocument(manifest, rootDir, {
801
+ hoistSchemas: false,
802
+ });
803
+
804
+ // Off: no $refs anywhere, no components.schemas emitted.
805
+ const offJson = JSON.stringify(docOff);
806
+ expect(offJson.includes("$ref")).toBe(false);
807
+ expect(docOff.components?.schemas).toBeUndefined();
808
+
809
+ // On: at least one $ref appeared. Confirms the two paths diverge.
810
+ const onJson = JSON.stringify(docOn);
811
+ expect(onJson.includes("$ref")).toBe(true);
812
+ expect(docOn.components?.schemas).toBeDefined();
813
+ } finally {
814
+ await cleanup();
815
+ }
816
+ });
817
+
818
+ test("enum / primitive schemas are never hoisted", async () => {
819
+ const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-openapi-enum-"));
820
+ try {
821
+ await fs.mkdir(path.join(rootDir, "contracts"), { recursive: true });
822
+ const contractBody = `import { z } from "zod";
823
+ export default {
824
+ name: NAME,
825
+ request: {
826
+ POST: { body: z.object({ role: z.enum(["a", "b", "c"]) }) },
827
+ },
828
+ response: { 200: z.object({ role: z.enum(["a", "b", "c"]) }) },
829
+ };
830
+ `;
831
+ await fs.writeFile(
832
+ path.join(rootDir, "contracts/one.contract.ts"),
833
+ contractBody.replace("NAME", '"one"'),
834
+ "utf-8"
835
+ );
836
+ await fs.writeFile(
837
+ path.join(rootDir, "contracts/two.contract.ts"),
838
+ contractBody.replace("NAME", '"two"'),
839
+ "utf-8"
840
+ );
841
+ const manifest: RoutesManifest = {
842
+ version: 1,
843
+ routes: [
844
+ {
845
+ id: "api/one",
846
+ pattern: "/api/one",
847
+ kind: "api",
848
+ module: "contracts/one.contract.ts",
849
+ contractModule: "contracts/one.contract.ts",
850
+ methods: ["POST"],
851
+ },
852
+ {
853
+ id: "api/two",
854
+ pattern: "/api/two",
855
+ kind: "api",
856
+ module: "contracts/two.contract.ts",
857
+ contractModule: "contracts/two.contract.ts",
858
+ methods: ["POST"],
859
+ },
860
+ ],
861
+ };
862
+
863
+ const doc = await generateOpenAPIDocument(manifest, rootDir);
864
+
865
+ // The outer {role: enum} object *is* hoistable and shared — that's fine.
866
+ // The inner enum itself must NOT be hoisted — scan every component
867
+ // entry and assert none of them is a bare enum-typed schema.
868
+ for (const entry of Object.values(doc.components?.schemas ?? {})) {
869
+ expect(entry.enum).toBeUndefined();
870
+ if (entry.type === "string" && !entry.properties) {
871
+ throw new Error(`Primitive string schema should not be hoisted: ${JSON.stringify(entry)}`);
872
+ }
873
+ }
874
+ } finally {
875
+ await fs.rm(rootDir, { recursive: true, force: true });
876
+ }
877
+ });
878
+
879
+ test("hoistThreshold: 3 → 2-use schemas stay inline; 3-use schemas get hoisted", () => {
880
+ // Doc with the same shape appearing exactly twice.
881
+ const shape = {
882
+ type: "object" as const,
883
+ properties: { x: { type: "string" as const } },
884
+ required: ["x"],
885
+ };
886
+ const buildDoc = (): OpenAPIDocument => ({
887
+ openapi: "3.0.3",
888
+ info: { title: "t", version: "1.0.0" },
889
+ paths: {
890
+ "/a": {
891
+ post: {
892
+ responses: {},
893
+ requestBody: { content: { "application/json": { schema: { ...shape } } } },
894
+ },
895
+ },
896
+ "/b": {
897
+ post: {
898
+ responses: {},
899
+ requestBody: { content: { "application/json": { schema: { ...shape } } } },
900
+ },
901
+ },
902
+ },
903
+ });
904
+
905
+ const doc2 = buildDoc();
906
+ return hoistSharedSchemas(doc2, { threshold: 3 }).then(() => {
907
+ // With threshold=3 and only 2 uses → nothing hoisted.
908
+ expect(doc2.components?.schemas).toBeUndefined();
909
+ expect(doc2.paths["/a"].post!.requestBody!.content["application/json"].schema.$ref).toBeUndefined();
910
+
911
+ // Re-run with threshold=2 on a fresh doc → the same shape is hoisted.
912
+ const doc1 = buildDoc();
913
+ return hoistSharedSchemas(doc1, { threshold: 2 }).then(() => {
914
+ expect(doc1.components?.schemas).toBeDefined();
915
+ expect(Object.keys(doc1.components!.schemas!).length).toBe(1);
916
+ });
917
+ });
918
+ });
919
+
920
+ test("threshold values < 2 clamp up to 2 (never hoist single-use schemas)", () => {
921
+ // Edge: a malicious / misconfigured `hoistThreshold: 1` would otherwise
922
+ // hoist every schema including one-offs, blowing up the spec.
923
+ const shape = {
924
+ type: "object" as const,
925
+ properties: { solo: { type: "string" as const } },
926
+ required: ["solo"],
927
+ };
928
+ const doc: OpenAPIDocument = {
929
+ openapi: "3.0.3",
930
+ info: { title: "t", version: "1.0.0" },
931
+ paths: {
932
+ "/only": {
933
+ post: {
934
+ responses: {},
935
+ requestBody: { content: { "application/json": { schema: { ...shape } } } },
936
+ },
937
+ },
938
+ },
939
+ };
940
+
941
+ return hoistSharedSchemas(doc, { threshold: 1 }).then(() => {
942
+ expect(doc.components?.schemas).toBeUndefined();
943
+ expect(doc.paths["/only"].post!.requestBody!.content["application/json"].schema.$ref).toBeUndefined();
944
+ });
945
+ });
946
+ });
@@ -48,9 +48,11 @@ export function adapterBun(): ManduAdapter {
48
48
 
49
49
  return {
50
50
  port: manduServer.server.port ?? port,
51
- // Report the effective bind address. startServer() defaults to
52
- // 0.0.0.0 when no hostname is supplied. See #190.
53
- hostname: hostname ?? "0.0.0.0",
51
+ // Report the effective bind address. startServer() defaults
52
+ // to `"::"` (dual-stack IPv6 wildcard) when no hostname is
53
+ // supplied accepts both IPv4 and IPv6 clients on one
54
+ // socket. See #190 #223.
55
+ hostname: hostname ?? "::",
54
56
  };
55
57
  },
56
58
 
@@ -4255,42 +4255,98 @@ function startBunServerWithFallback(options: {
4255
4255
  // ========== Server Startup ==========
4256
4256
 
4257
4257
  /**
4258
- * Format a base URL for startup logging based on the bound hostname.
4258
+ * Derive the list of loopback-style hosts actually reachable for a given
4259
+ * bind address, without issuing any network probes. Used by the startup
4260
+ * banner (`formatServerAddresses`) and by tooling that needs to know
4261
+ * which loopback URLs will succeed for a given server.
4262
+ *
4263
+ * Matrix:
4264
+ * - `"0.0.0.0"` (IPv4 wildcard) → `["127.0.0.1"]` only. A server bound
4265
+ * to `0.0.0.0` does NOT answer on `[::1]` — the IPv6 loopback is a
4266
+ * different socket. This was the root cause of #225.
4267
+ * - `"::"` / `"::0"` / `"[::]"` / `"0:0:0:0:0:0:0:0"` (IPv6 wildcard,
4268
+ * dual-stack) → `["127.0.0.1", "[::1]"]`. IPV6_V6ONLY is off by
4269
+ * default on Bun, so the dual-stack socket accepts IPv4-mapped
4270
+ * connections too.
4271
+ * - `undefined` / `""` → same as the current default (`"::"`).
4272
+ * - `"127.0.0.1"` / `"::1"` / a specific IP → just that address.
4273
+ * - DNS name → just that name.
4259
4274
  *
4260
- * When binding to wildcard addresses (`0.0.0.0`, `::`, or empty string),
4261
- * the server listens on all interfaces — browsers must use `localhost`
4262
- * or a specific loopback address to connect. We surface both IPv4 and IPv6
4263
- * loopback URLs so the user can pick whichever their OS prefers.
4275
+ * @see issues #223 #225
4276
+ */
4277
+ export function reachableHosts(hostname: string | undefined): string[] {
4278
+ const h = (hostname ?? "").trim();
4279
+
4280
+ // IPv4 wildcard — IPv4 loopback only.
4281
+ if (h === "0.0.0.0") {
4282
+ return ["127.0.0.1"];
4283
+ }
4284
+
4285
+ // IPv6 wildcard (dual-stack). Empty / undefined is treated as the
4286
+ // default, which is now `"::"` (dual-stack) — see `startServer()`.
4287
+ if (h === "" || h === "::" || h === "::0" || h === "[::]" || h === "0:0:0:0:0:0:0:0") {
4288
+ return ["127.0.0.1", "[::1]"];
4289
+ }
4290
+
4291
+ // Bare IPv6 literal → bracket for URL syntax.
4292
+ if (h.includes(":") && !h.startsWith("[")) {
4293
+ return [`[${h}]`];
4294
+ }
4295
+
4296
+ return [h];
4297
+ }
4298
+
4299
+ /**
4300
+ * Format a base URL for startup logging based on the bound hostname.
4264
4301
  *
4265
4302
  * Returns `{ primary, additional }` where `primary` is the canonical URL
4266
- * for UX (open-in-browser, runtime control) and `additional` are supplementary
4267
- * URLs shown in the startup log.
4303
+ * for UX (open-in-browser, runtime control) and `additional` are
4304
+ * supplementary URLs shown in the startup log. Every URL in `additional`
4305
+ * is guaranteed to actually resolve to the running server — no more
4306
+ * "(also reachable at [::1])" when the socket only answers on IPv4.
4307
+ *
4308
+ * @see issue #225
4268
4309
  */
4269
4310
  export function formatServerAddresses(
4270
4311
  hostname: string | undefined,
4271
4312
  port: number
4272
4313
  ): { primary: string; additional: string[] } {
4273
- const isWildcardV4 = hostname === "0.0.0.0" || hostname === undefined || hostname === "";
4274
- const isWildcardV6 = hostname === "::" || hostname === "[::]";
4314
+ const h = (hostname ?? "").trim();
4315
+ const isWildcardV4 = h === "0.0.0.0";
4316
+ const isWildcardV6 =
4317
+ h === "" || h === "::" || h === "::0" || h === "[::]" || h === "0:0:0:0:0:0:0:0";
4318
+ const hosts = reachableHosts(hostname);
4319
+
4275
4320
  if (isWildcardV4 || isWildcardV6) {
4276
4321
  return {
4277
4322
  primary: `http://localhost:${port}`,
4278
- additional: [`http://127.0.0.1:${port}`, `http://[::1]:${port}`],
4323
+ additional: hosts.map((x) => `http://${x}:${port}`),
4279
4324
  };
4280
4325
  }
4281
- // Bracket IPv6 literals for URL syntax.
4282
- const host = hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname;
4283
- return { primary: `http://${host}:${port}`, additional: [] };
4326
+
4327
+ // Specific host `primary` is that host, no additional entries.
4328
+ return { primary: `http://${hosts[0]}:${port}`, additional: [] };
4284
4329
  }
4285
4330
 
4286
4331
  export function startServer(manifest: RoutesManifest, options: ServerOptions = {}): ManduServer {
4287
4332
  const {
4288
4333
  port = 3000,
4289
- // Default to 0.0.0.0 (dual-stack wildcard on IPv4) so `localhost` resolves
4290
- // to 127.0.0.1 via OS-level IPv4-preferred lookups (e.g., Windows). Users
4291
- // can still pin `hostname: "::1"` or `hostname: "127.0.0.1"` explicitly.
4292
- // See issue #190.
4293
- hostname = "0.0.0.0",
4334
+ // Default to `"::"` (IPv6 wildcard, dual-stack). Bun leaves IPV6_V6ONLY
4335
+ // off, so this single socket accepts both IPv4 (as IPv4-mapped IPv6)
4336
+ // and IPv6 clients covering `127.0.0.1`, `[::1]`, and LAN addresses
4337
+ // of either family with one bind.
4338
+ //
4339
+ // Why not `"0.0.0.0"`? On Windows with Node 17+, `fetch("localhost:...")`
4340
+ // resolves to `::1` first. A server bound to `0.0.0.0` accepts IPv4
4341
+ // only, so Node clients (Playwright test runner, ATE-generated specs)
4342
+ // fail with `ECONNREFUSED ::1:PORT`. Browsers and `curl` silently
4343
+ // fall back to IPv4, hiding the bug. See issues #190 #223.
4344
+ //
4345
+ // Explicit `"0.0.0.0"` is still honored — users who need IPv4-only
4346
+ // binds (certain container networks, firewall policies) keep that
4347
+ // option; a one-line warning is emitted on Windows so the trap is
4348
+ // discoverable.
4349
+ hostname = "::",
4294
4350
  rootDir = process.cwd(),
4295
4351
  isDev = false,
4296
4352
  hmrPort,
@@ -4611,6 +4667,25 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
4611
4667
  registry.settings = { ...registry.settings, hmrPort: actualPort };
4612
4668
  }
4613
4669
 
4670
+ // ─── #223 — Windows hostname="0.0.0.0" discoverability warning ────────
4671
+ // We cannot silently rewrite an explicit `"0.0.0.0"` (user may need
4672
+ // IPv4-only binds for container/firewall reasons), but we CAN warn
4673
+ // the one platform where the gotcha actually bites: Windows, where
4674
+ // Node 17+ fetch prefers `::1` for `localhost` and will therefore
4675
+ // fail to reach an IPv4-only bind. Silent on non-Windows, silent
4676
+ // when `silent: true`.
4677
+ // ──────────────────────────────────────────────────────────────────────
4678
+ if (
4679
+ !silent &&
4680
+ options.hostname === "0.0.0.0" &&
4681
+ process.platform === "win32"
4682
+ ) {
4683
+ console.warn(
4684
+ `⚠️ hostname="0.0.0.0" binds IPv4 only; Node fetch('localhost:${actualPort}') ` +
4685
+ `may fail on Windows (prefers ::1). Consider hostname="::" for dual-stack.`
4686
+ );
4687
+ }
4688
+
4614
4689
  const addresses = formatServerAddresses(hostname, actualPort);
4615
4690
 
4616
4691
  // ─── #217 — gate the boot banner on `!silent` ─────────────────────────