@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 +1 -1
- package/src/config/mandu.ts +23 -0
- package/src/config/validate.ts +6 -3
- package/src/openapi/generator.ts +354 -2
- package/src/openapi/openapi.test.ts +432 -0
- package/src/runtime/adapter-bun.ts +5 -3
- package/src/runtime/server.ts +93 -18
package/package.json
CHANGED
package/src/config/mandu.ts
CHANGED
|
@@ -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
|
package/src/config/validate.ts
CHANGED
|
@@ -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
|
|
48
|
-
//
|
|
49
|
-
|
|
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(),
|
package/src/openapi/generator.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|
52
|
-
//
|
|
53
|
-
|
|
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
|
|
package/src/runtime/server.ts
CHANGED
|
@@ -4255,42 +4255,98 @@ function startBunServerWithFallback(options: {
|
|
|
4255
4255
|
// ========== Server Startup ==========
|
|
4256
4256
|
|
|
4257
4257
|
/**
|
|
4258
|
-
*
|
|
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
|
-
*
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
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
|
|
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
|
|
4274
|
-
const
|
|
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:
|
|
4323
|
+
additional: hosts.map((x) => `http://${x}:${port}`),
|
|
4279
4324
|
};
|
|
4280
4325
|
}
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
return { primary: `http://${
|
|
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
|
|
4290
|
-
//
|
|
4291
|
-
//
|
|
4292
|
-
//
|
|
4293
|
-
|
|
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` ─────────────────────────
|