api-json-server 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +636 -0
- package/dist/behavior.js +44 -0
- package/dist/history/historyRecorder.js +66 -0
- package/dist/history/types.js +2 -0
- package/dist/index.js +126 -5
- package/dist/loadSpec.js +32 -0
- package/dist/logger/customLogger.js +75 -0
- package/dist/logger/formatters.js +82 -0
- package/dist/logger/types.js +2 -0
- package/dist/openapi.js +152 -0
- package/dist/registerEndpoints.js +97 -0
- package/dist/requestMatch.js +99 -0
- package/dist/responseRenderer.js +98 -0
- package/dist/server.js +210 -0
- package/dist/spec.js +146 -0
- package/dist/stringTemplate.js +55 -0
- package/examples/auth-variants.json +31 -0
- package/examples/basic-crud.json +46 -0
- package/examples/companies-nested.json +47 -0
- package/examples/orders-and-matches.json +49 -0
- package/examples/users-faker.json +35 -0
- package/mock.spec.json +1 -0
- package/mockserve.spec.schema.json +7 -0
- package/package.json +20 -3
- package/scripts/build-schema.ts +21 -0
- package/src/behavior.ts +56 -0
- package/src/history/historyRecorder.ts +77 -0
- package/src/history/types.ts +25 -0
- package/src/index.ts +124 -85
- package/src/loadSpec.ts +5 -2
- package/src/logger/customLogger.ts +85 -0
- package/src/logger/formatters.ts +74 -0
- package/src/logger/types.ts +30 -0
- package/src/openapi.ts +203 -0
- package/src/registerEndpoints.ts +94 -162
- package/src/requestMatch.ts +104 -0
- package/src/responseRenderer.ts +112 -0
- package/src/server.ts +236 -14
- package/src/spec.ts +108 -8
- package/src/stringTemplate.ts +55 -0
- package/tests/behavior.test.ts +88 -0
- package/tests/cors.test.ts +128 -0
- package/tests/faker.test.ts +175 -0
- package/tests/fixtures/spec.basic.json +39 -0
- package/tests/headers.test.ts +124 -0
- package/tests/helpers.ts +28 -0
- package/tests/history.test.ts +188 -0
- package/tests/matching.test.ts +245 -0
- package/tests/server.test.ts +73 -0
- package/tests/template.test.ts +90 -0
- package/src/template.ts +0 -61
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../mockserve.spec.schema.json",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"settings": {
|
|
5
|
+
"delayMs": 0,
|
|
6
|
+
"errorRate": 0,
|
|
7
|
+
"errorStatus": 500,
|
|
8
|
+
"errorResponse": { "error": "Mock error" },
|
|
9
|
+
"fakerSeed": 42
|
|
10
|
+
},
|
|
11
|
+
"endpoints": [
|
|
12
|
+
{
|
|
13
|
+
"method": "GET",
|
|
14
|
+
"path": "/companies",
|
|
15
|
+
"response": {
|
|
16
|
+
"companies": {
|
|
17
|
+
"__repeat": {
|
|
18
|
+
"min": 3,
|
|
19
|
+
"max": 5,
|
|
20
|
+
"template": {
|
|
21
|
+
"id": { "__faker": "string.uuid" },
|
|
22
|
+
"name": { "__faker": "company.name" },
|
|
23
|
+
"domain": { "__faker": "internet.domainName" },
|
|
24
|
+
"address": {
|
|
25
|
+
"street": { "__faker": "location.streetAddress" },
|
|
26
|
+
"city": { "__faker": "location.city" },
|
|
27
|
+
"zip": { "__faker": "location.zipCode" },
|
|
28
|
+
"country": { "__faker": "location.country" }
|
|
29
|
+
},
|
|
30
|
+
"contacts": {
|
|
31
|
+
"__repeat": {
|
|
32
|
+
"count": 2,
|
|
33
|
+
"min": 0,
|
|
34
|
+
"template": {
|
|
35
|
+
"name": { "__faker": "person.fullName" },
|
|
36
|
+
"email": { "__faker": "internet.email" },
|
|
37
|
+
"phone": { "__faker": "phone.number" }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../mockserve.spec.schema.json",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"settings": {
|
|
5
|
+
"delayMs": 0,
|
|
6
|
+
"errorRate": 0,
|
|
7
|
+
"errorStatus": 500,
|
|
8
|
+
"errorResponse": { "error": "Mock error" }
|
|
9
|
+
},
|
|
10
|
+
"endpoints": [
|
|
11
|
+
{
|
|
12
|
+
"method": "GET",
|
|
13
|
+
"path": "/orders",
|
|
14
|
+
"match": { "query": { "status": "pending" } },
|
|
15
|
+
"response": {
|
|
16
|
+
"status": "{{query.status}}",
|
|
17
|
+
"orders": {
|
|
18
|
+
"__repeat": {
|
|
19
|
+
"min": 5,
|
|
20
|
+
"max": 8,
|
|
21
|
+
"template": {
|
|
22
|
+
"id": { "__faker": "string.uuid" },
|
|
23
|
+
"total": { "__faker": { "method": "number.int", "args": [ { "min": 20, "max": 200 } ] } },
|
|
24
|
+
"placedAt": { "__faker": { "method": "date.recent", "args": [7] } }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"method": "POST",
|
|
32
|
+
"path": "/orders",
|
|
33
|
+
"status": 201,
|
|
34
|
+
"response": {
|
|
35
|
+
"id": { "__faker": "string.uuid" },
|
|
36
|
+
"priority": "{{body.priority}}",
|
|
37
|
+
"message": "Order created"
|
|
38
|
+
},
|
|
39
|
+
"variants": [
|
|
40
|
+
{
|
|
41
|
+
"name": "high priority",
|
|
42
|
+
"match": { "body": { "priority": "high" } },
|
|
43
|
+
"status": 202,
|
|
44
|
+
"response": { "queued": true, "message": "Order queued for review" }
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../mockserve.spec.schema.json",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"settings": {
|
|
5
|
+
"delayMs": 0,
|
|
6
|
+
"errorRate": 0,
|
|
7
|
+
"errorStatus": 500,
|
|
8
|
+
"errorResponse": { "error": "Mock error" },
|
|
9
|
+
"fakerSeed": 123
|
|
10
|
+
},
|
|
11
|
+
"endpoints": [
|
|
12
|
+
{
|
|
13
|
+
"method": "GET",
|
|
14
|
+
"path": "/users",
|
|
15
|
+
"response": {
|
|
16
|
+
"users": {
|
|
17
|
+
"__repeat": {
|
|
18
|
+
"min": 10,
|
|
19
|
+
"max": 15,
|
|
20
|
+
"template": {
|
|
21
|
+
"id": { "__faker": "string.uuid" },
|
|
22
|
+
"firstName": { "__faker": "person.firstName" },
|
|
23
|
+
"lastName": { "__faker": "person.lastName" },
|
|
24
|
+
"avatarUrl": { "__faker": "image.avatar" },
|
|
25
|
+
"phone": { "__faker": "phone.number" },
|
|
26
|
+
"email": { "__faker": "internet.email" },
|
|
27
|
+
"company": { "__faker": "company.name" },
|
|
28
|
+
"joinedAt": { "__faker": { "method": "date.recent", "args": [30] } }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
package/mock.spec.json
CHANGED
package/package.json
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "api-json-server",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "tsx src/index.ts",
|
|
8
8
|
"build": "tsc",
|
|
9
|
-
"start": "node dist/index.js"
|
|
9
|
+
"start": "node dist/index.js",
|
|
10
|
+
"test": "vitest run",
|
|
11
|
+
"test:watch": "vitest",
|
|
12
|
+
"schema:build": "tsx scripts/build-schema.ts"
|
|
10
13
|
},
|
|
11
14
|
"bin": {
|
|
12
15
|
"mockserve": "dist/index.js"
|
|
@@ -17,12 +20,26 @@
|
|
|
17
20
|
"type": "commonjs",
|
|
18
21
|
"devDependencies": {
|
|
19
22
|
"@types/node": "^25.0.9",
|
|
23
|
+
"@types/supertest": "^6.0.3",
|
|
24
|
+
"@types/swagger-ui-dist": "^3.30.6",
|
|
25
|
+
"supertest": "^7.2.2",
|
|
20
26
|
"tsx": "^4.21.0",
|
|
21
|
-
"typescript": "^5.9.3"
|
|
27
|
+
"typescript": "^5.9.3",
|
|
28
|
+
"vitest": "^4.0.17",
|
|
29
|
+
"zod-to-json-schema": "^3.25.1"
|
|
22
30
|
},
|
|
23
31
|
"dependencies": {
|
|
32
|
+
"@faker-js/faker": "^10.2.0",
|
|
33
|
+
"@fastify/cookie": "^11.0.2",
|
|
34
|
+
"@fastify/cors": "^11.2.0",
|
|
35
|
+
"@fastify/http-proxy": "^11.4.1",
|
|
36
|
+
"@fastify/static": "^9.0.0",
|
|
37
|
+
"chalk": "^5.6.2",
|
|
24
38
|
"commander": "^14.0.2",
|
|
25
39
|
"fastify": "^5.7.1",
|
|
40
|
+
"pino-pretty": "^13.1.3",
|
|
41
|
+
"swagger-ui-dist": "^5.31.0",
|
|
42
|
+
"yaml": "^2.8.2",
|
|
26
43
|
"zod": "^4.3.5"
|
|
27
44
|
}
|
|
28
45
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
3
|
+
import { MockSpecSchema } from "../src/spec.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build the JSON schema file from the Zod spec.
|
|
7
|
+
*/
|
|
8
|
+
async function main() {
|
|
9
|
+
const schema = MockSpecSchema as unknown as Parameters<typeof zodToJsonSchema>[0];
|
|
10
|
+
const jsonSchema = zodToJsonSchema(schema, {
|
|
11
|
+
name: "MockServeSpec"
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
await writeFile("mockserve.spec.schema.json", JSON.stringify(jsonSchema, null, 2), "utf-8");
|
|
15
|
+
console.log("Wrote mockserve.spec.schema.json");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
main().catch((err) => {
|
|
19
|
+
console.error(err);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
});
|
package/src/behavior.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { TemplateValue } from "./spec.js";
|
|
2
|
+
import { faker } from "@faker-js/faker";
|
|
3
|
+
|
|
4
|
+
export type DelayConfig = number | { min: number; max: number };
|
|
5
|
+
|
|
6
|
+
export type BehaviorSettings = {
|
|
7
|
+
delayMs: number;
|
|
8
|
+
errorRate: number;
|
|
9
|
+
errorStatus: number;
|
|
10
|
+
errorResponse: TemplateValue;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type BehaviorOverrides = Partial<BehaviorSettings> & {
|
|
14
|
+
delay?: DelayConfig;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve a delay value from either a number or a range configuration.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveDelay(delay?: DelayConfig): number {
|
|
21
|
+
if (!delay) return 0;
|
|
22
|
+
if (typeof delay === "number") return delay;
|
|
23
|
+
return faker.number.int({ min: delay.min, max: delay.max });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Pause for the given number of milliseconds.
|
|
28
|
+
*/
|
|
29
|
+
export function sleep(ms: number): Promise<void> {
|
|
30
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Decide whether a request should fail based on an error rate.
|
|
35
|
+
*/
|
|
36
|
+
export function shouldFail(errorRate: number): boolean {
|
|
37
|
+
if (errorRate <= 0) return false;
|
|
38
|
+
if (errorRate >= 1) return true;
|
|
39
|
+
return Math.random() < errorRate;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve behavior settings with precedence: chosen overrides -> endpoint overrides -> global settings.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveBehavior(
|
|
46
|
+
settings: BehaviorSettings,
|
|
47
|
+
endpointOverrides?: BehaviorOverrides,
|
|
48
|
+
chosenOverrides?: BehaviorOverrides
|
|
49
|
+
): BehaviorSettings {
|
|
50
|
+
return {
|
|
51
|
+
delayMs: chosenOverrides?.delayMs ?? endpointOverrides?.delayMs ?? settings.delayMs,
|
|
52
|
+
errorRate: chosenOverrides?.errorRate ?? endpointOverrides?.errorRate ?? settings.errorRate,
|
|
53
|
+
errorStatus: chosenOverrides?.errorStatus ?? endpointOverrides?.errorStatus ?? settings.errorStatus,
|
|
54
|
+
errorResponse: chosenOverrides?.errorResponse ?? endpointOverrides?.errorResponse ?? settings.errorResponse
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { HistoryEntry, HistoryFilter } from "./types.js";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* In-memory request history recorder.
|
|
6
|
+
*/
|
|
7
|
+
export class HistoryRecorder {
|
|
8
|
+
private entries: HistoryEntry[] = [];
|
|
9
|
+
private readonly maxEntries: number;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create a new history recorder.
|
|
13
|
+
* @param maxEntries Maximum number of entries to keep (default 1000).
|
|
14
|
+
*/
|
|
15
|
+
constructor(maxEntries = 1000) {
|
|
16
|
+
this.maxEntries = maxEntries;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Record a new request.
|
|
21
|
+
*/
|
|
22
|
+
record(entry: Omit<HistoryEntry, "id" | "timestamp">): HistoryEntry {
|
|
23
|
+
const fullEntry: HistoryEntry = {
|
|
24
|
+
id: randomUUID(),
|
|
25
|
+
timestamp: new Date().toISOString(),
|
|
26
|
+
...entry
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
this.entries.push(fullEntry);
|
|
30
|
+
|
|
31
|
+
// Keep only the most recent entries
|
|
32
|
+
if (this.entries.length > this.maxEntries) {
|
|
33
|
+
this.entries.shift();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return fullEntry;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get all history entries, optionally filtered.
|
|
41
|
+
*/
|
|
42
|
+
query(filter?: HistoryFilter): HistoryEntry[] {
|
|
43
|
+
let results = this.entries;
|
|
44
|
+
|
|
45
|
+
if (filter?.endpoint) {
|
|
46
|
+
results = results.filter((e) => e.path === filter.endpoint);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (filter?.method) {
|
|
50
|
+
results = results.filter((e) => e.method.toUpperCase() === filter.method?.toUpperCase());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (filter?.statusCode !== undefined) {
|
|
54
|
+
results = results.filter((e) => e.statusCode === filter.statusCode);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (filter?.limit && filter.limit > 0) {
|
|
58
|
+
results = results.slice(-filter.limit);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return results;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Clear all history entries.
|
|
66
|
+
*/
|
|
67
|
+
clear(): void {
|
|
68
|
+
this.entries = [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get the total number of recorded entries.
|
|
73
|
+
*/
|
|
74
|
+
count(): number {
|
|
75
|
+
return this.entries.length;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request history entry structure.
|
|
3
|
+
*/
|
|
4
|
+
export interface HistoryEntry {
|
|
5
|
+
id: string;
|
|
6
|
+
timestamp: string;
|
|
7
|
+
method: string;
|
|
8
|
+
url: string;
|
|
9
|
+
path: string;
|
|
10
|
+
query: Record<string, unknown>;
|
|
11
|
+
headers: Record<string, string | string[] | undefined>;
|
|
12
|
+
body: unknown;
|
|
13
|
+
statusCode?: number;
|
|
14
|
+
responseTime?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Filter options for querying history.
|
|
19
|
+
*/
|
|
20
|
+
export interface HistoryFilter {
|
|
21
|
+
endpoint?: string;
|
|
22
|
+
method?: string;
|
|
23
|
+
statusCode?: number;
|
|
24
|
+
limit?: number;
|
|
25
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { Command } from "commander";
|
|
4
4
|
import { watch } from "node:fs";
|
|
5
|
+
import { createLogger, logServerStart, logServerReload } from "./logger/customLogger.js";
|
|
6
|
+
import type { LoggerOptions } from "./logger/types.js";
|
|
5
7
|
|
|
6
8
|
const program = new Command();
|
|
7
9
|
|
|
@@ -17,106 +19,143 @@ program
|
|
|
17
19
|
.option("-s, --spec <path>", "Path to the spec JSON file", "mock.spec.json")
|
|
18
20
|
.option("--watch", "Reload when spec file changes", true)
|
|
19
21
|
.option("--no-watch", "Disable reload when spec file changes")
|
|
20
|
-
.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const specPath = opts.spec;
|
|
29
|
-
|
|
30
|
-
const { loadSpecFromFile } = await import("./loadSpec.js");
|
|
31
|
-
const { buildServer } = await import("./server.js");
|
|
22
|
+
.option("--base-url <url>", "Public base URL used in OpenAPI servers[] (e.g. https://example.com)")
|
|
23
|
+
.option("--log-format <format>", "Log format: pretty or json", "pretty")
|
|
24
|
+
.option("--log-level <level>", "Log level: trace, debug, info, warn, error, fatal", "info")
|
|
25
|
+
.action(async (opts: { port: string; spec: string; watch: boolean; baseUrl?: string; logFormat: string; logLevel: string }) => {
|
|
26
|
+
await startCommand(opts);
|
|
27
|
+
});
|
|
32
28
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Run the mock server CLI command.
|
|
31
|
+
*/
|
|
32
|
+
async function startCommand(opts: { port: string; spec: string; watch: boolean; baseUrl?: string; logFormat: string; logLevel: string }): Promise<void> {
|
|
33
|
+
const port = Number(opts.port);
|
|
34
|
+
|
|
35
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
36
|
+
console.error(`Invalid port: ${opts.port}`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const specPath = opts.spec;
|
|
41
|
+
|
|
42
|
+
// Create logger based on CLI options
|
|
43
|
+
const logFormat = opts.logFormat === "json" ? "json" : "pretty";
|
|
44
|
+
const logLevel = ["trace", "debug", "info", "warn", "error", "fatal"].includes(opts.logLevel)
|
|
45
|
+
? opts.logLevel as LoggerOptions["level"]
|
|
46
|
+
: "info";
|
|
47
|
+
|
|
48
|
+
const logger = createLogger({
|
|
49
|
+
enabled: true,
|
|
50
|
+
format: logFormat,
|
|
51
|
+
level: logLevel
|
|
52
|
+
});
|
|
36
53
|
|
|
37
|
-
|
|
38
|
-
|
|
54
|
+
const { loadSpecFromFile } = await import("./loadSpec.js");
|
|
55
|
+
const { buildServer } = await import("./server.js");
|
|
39
56
|
|
|
40
|
-
|
|
41
|
-
|
|
57
|
+
let app = null as null | import("fastify").FastifyInstance;
|
|
58
|
+
let isReloading = false;
|
|
59
|
+
let debounceTimer: NodeJS.Timeout | null = null;
|
|
42
60
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
throw err;
|
|
49
|
-
}
|
|
61
|
+
/**
|
|
62
|
+
* Build and start a server using the current spec file.
|
|
63
|
+
*/
|
|
64
|
+
async function startWithSpec() {
|
|
65
|
+
const loadedAt = new Date().toISOString();
|
|
50
66
|
|
|
51
|
-
|
|
52
|
-
|
|
67
|
+
const spec = await loadSpecFromFile(specPath);
|
|
68
|
+
logger.info(`Loaded spec v${spec.version} with ${spec.endpoints.length} endpoint(s).`);
|
|
53
69
|
|
|
54
|
-
|
|
70
|
+
const nextApp = buildServer(spec, { specPath, loadedAt, baseUrl: opts.baseUrl, logger: true });
|
|
71
|
+
try {
|
|
72
|
+
await nextApp.listen({ port, host: "0.0.0.0" });
|
|
73
|
+
} catch (err) {
|
|
74
|
+
nextApp.log.error(err);
|
|
75
|
+
throw err;
|
|
55
76
|
}
|
|
56
77
|
|
|
57
|
-
|
|
58
|
-
if (isReloading) return;
|
|
59
|
-
isReloading = true;
|
|
78
|
+
logServerStart(nextApp.log, port, specPath);
|
|
60
79
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
// 1) Stop accepting requests on the old server FIRST
|
|
65
|
-
if (app) {
|
|
66
|
-
console.log("Closing current server...");
|
|
67
|
-
await app.close();
|
|
68
|
-
console.log("Current server closed.");
|
|
69
|
-
app = null;
|
|
70
|
-
}
|
|
80
|
+
return nextApp;
|
|
81
|
+
}
|
|
71
82
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
// At this point the old server may already be closed. We want visibility.
|
|
80
|
-
console.error(String(err));
|
|
81
|
-
|
|
82
|
-
// Optional: try to start again to avoid being down
|
|
83
|
-
try {
|
|
84
|
-
if (!app) {
|
|
85
|
-
console.log("Attempting to start server again after reload failure...");
|
|
86
|
-
app = await startWithSpec();
|
|
87
|
-
console.log("Recovery start succeeded.");
|
|
88
|
-
}
|
|
89
|
-
} catch (err2) {
|
|
90
|
-
console.error("Recovery start failed. Server is down until next successful reload.");
|
|
91
|
-
console.error(String(err2));
|
|
92
|
-
}
|
|
93
|
-
} finally {
|
|
94
|
-
isReloading = false;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
83
|
+
/**
|
|
84
|
+
* Reload the server when the spec changes.
|
|
85
|
+
*/
|
|
86
|
+
async function reload() {
|
|
87
|
+
if (isReloading) return;
|
|
88
|
+
isReloading = true;
|
|
97
89
|
|
|
98
|
-
// Initial start
|
|
99
90
|
try {
|
|
91
|
+
logger.info("Reloading spec...");
|
|
92
|
+
|
|
93
|
+
// 1) Stop accepting requests on the old server FIRST
|
|
94
|
+
if (app) {
|
|
95
|
+
logger.debug("Closing current server...");
|
|
96
|
+
await app.close();
|
|
97
|
+
logger.debug("Current server closed.");
|
|
98
|
+
app = null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 2) Start a new server on the same port with the updated spec
|
|
100
102
|
app = await startWithSpec();
|
|
103
|
+
|
|
104
|
+
logServerReload(logger, true);
|
|
101
105
|
} catch (err) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
106
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
107
|
+
logServerReload(logger, false, errorMsg);
|
|
105
108
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
109
|
+
// Optional: try to start again to avoid being down
|
|
110
|
+
try {
|
|
111
|
+
if (!app) {
|
|
112
|
+
logger.info("Attempting to start server again after reload failure...");
|
|
113
|
+
app = await startWithSpec();
|
|
114
|
+
logger.info("Recovery start succeeded.");
|
|
115
|
+
}
|
|
116
|
+
} catch (err2) {
|
|
117
|
+
logger.error("Recovery start failed. Server is down until next successful reload.");
|
|
118
|
+
logger.error(err2);
|
|
119
|
+
}
|
|
120
|
+
} finally {
|
|
121
|
+
isReloading = false;
|
|
119
122
|
}
|
|
120
|
-
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Initial start
|
|
126
|
+
try {
|
|
127
|
+
app = await startWithSpec();
|
|
128
|
+
} catch (err) {
|
|
129
|
+
logger.error(err);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Watch spec for changes
|
|
134
|
+
/**
|
|
135
|
+
* Handle file changes with a debounced reload.
|
|
136
|
+
*/
|
|
137
|
+
function onSpecChange(): void {
|
|
138
|
+
debounceTimer = scheduleReload(reload, debounceTimer);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (opts.watch) {
|
|
142
|
+
logger.info(`Watching spec file for changes: ${specPath}`);
|
|
143
|
+
|
|
144
|
+
// fs.watch emits multiple events; debounce to avoid rapid reload loops
|
|
145
|
+
watch(specPath, onSpecChange);
|
|
146
|
+
} else {
|
|
147
|
+
logger.info("Watch disabled (--no-watch).");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Schedule a debounced reload when the spec changes.
|
|
153
|
+
*/
|
|
154
|
+
function scheduleReload(reload: () => Promise<void>, debounceTimer: NodeJS.Timeout | null): NodeJS.Timeout {
|
|
155
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
156
|
+
return setTimeout(() => {
|
|
157
|
+
void reload();
|
|
158
|
+
}, 200);
|
|
159
|
+
}
|
|
121
160
|
|
|
122
161
|
program.parse(process.argv);
|
package/src/loadSpec.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { readFile } from
|
|
2
|
-
import { MockSpecSchema, type MockSpecInferSchema } from
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { MockSpecSchema, type MockSpecInferSchema } from "./spec.js";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Load and validate a mock spec from disk.
|
|
6
|
+
*/
|
|
4
7
|
export async function loadSpecFromFile(specPath: string): Promise<MockSpecInferSchema> {
|
|
5
8
|
let raw: string
|
|
6
9
|
try {
|