@xirconsss/zero-mock 1.0.0 → 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 +136 -25
- package/dist/cli-wizard.js +151 -0
- package/dist/config-store.js +85 -0
- package/dist/errors.js +128 -0
- package/dist/index.js +116 -39
- package/dist/server/app.js +8 -9
- package/dist/server/bootstrap.js +14 -10
- package/dist/server/logger.js +78 -0
- package/dist/server/routes/dynamicRouter.js +113 -9
- package/dist/store/jsonStore.js +53 -30
- package/package.json +8 -2
- package/example/db.json +0 -45
package/README.md
CHANGED
|
@@ -1,37 +1,148 @@
|
|
|
1
|
-
#
|
|
1
|
+
# zero-mock
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](LICENSE)
|
|
4
|
+
[](tsconfig.json)
|
|
5
|
+
[](https://www.npmjs.com/package/@xirconsss/zero-mock)
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
**zero-mock** is a zero-config Node.js CLI that turns any JSON file into a persistent, production-grade REST API in seconds. It includes an interactive setup wizard, a high-contrast brutalist UI, and smart schema inference with automatic validation.
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
```bash
|
|
9
|
-
npm install -g @xirconsss/zero-mock
|
|
10
|
-
```
|
|
9
|
+
Built for frontend developers and architects who need a realistic, stable backend for prototypes, demos, and integration tests without the overhead of database orchestration.
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
```bash
|
|
14
|
-
npx @xirconsss/zero-mock -- --file ./data.json --port 3000
|
|
15
|
-
```
|
|
11
|
+
## Key Features
|
|
16
12
|
|
|
17
|
-
|
|
13
|
+
- **Zero-config** — your JSON file is your database and API definition.
|
|
14
|
+
- **Interactive wizard** — run `zero-mock` to start the setup guide.
|
|
15
|
+
- **Smart validation** — automatically infers Zod schemas from your data to validate `POST`, `PUT`, and `PATCH` requests.
|
|
16
|
+
- **Atomic and secure** — cross-platform atomic writes (Windows-safe) with file-locking to prevent data corruption.
|
|
17
|
+
- **Advanced REST** — supports filtering (`_gte`, `_lte`, `_like`), sorting (`_sort`, `_order`), and pagination (`_page`, `_limit`).
|
|
18
|
+
- **Realistic simulation** — simulate network latency and configure custom CORS origins/methods.
|
|
19
|
+
- **Watch mode** — hot-reloads data from disk on manual file changes without a server restart.
|
|
20
|
+
- **Session memory** — remembers your last used configuration for a faster workflow.
|
|
18
21
|
|
|
19
|
-
|
|
20
|
-
npx @xirconsss/zero-mock -- --file ./data.json --port 3000 --delay 2000 --watch
|
|
21
|
-
```
|
|
22
|
+
---
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
## Installation
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
npm exec -- @xirconsss/zero-mock -- --file ./data.json --port 3000 --delay 2000
|
|
27
|
-
```
|
|
26
|
+
### Global
|
|
28
27
|
|
|
29
|
-
|
|
28
|
+
```bash
|
|
29
|
+
npm install -g @xirconsss/zero-mock
|
|
30
|
+
```
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
zero-mock --file ./data.json --port 3000 --delay 2000
|
|
33
|
-
```
|
|
32
|
+
Run the CLI as `zero-mock`.
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
Simply open `index.html` in your browser.
|
|
34
|
+
### One-off with npx
|
|
37
35
|
|
|
36
|
+
```bash
|
|
37
|
+
npx @xirconsss/zero-mock
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
### 1. Interactive mode (recommended)
|
|
45
|
+
|
|
46
|
+
Run the command without arguments to launch the setup wizard:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
zero-mock
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### 2. Manual CLI
|
|
53
|
+
|
|
54
|
+
Pass flags to skip the wizard:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
zero-mock -f ./data.json -p 8080 -w -d 200
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
| Flag | Description |
|
|
61
|
+
| ---- | ----------- |
|
|
62
|
+
| `-f`, `--file` | Path to the source JSON file. |
|
|
63
|
+
| `-p`, `--port` | HTTP port (default `3000`, must be 1024–65535). |
|
|
64
|
+
| `-d`, `--delay` | Delay every request by X milliseconds (default `0`). |
|
|
65
|
+
| `-w`, `--watch` | Enable hot-reloading on manual file changes. |
|
|
66
|
+
| `--cors-origin` | Comma-separated allowed origins (default `*`). |
|
|
67
|
+
| `--reset` | Clear saved wizard configuration and exit. |
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Auto-generated API
|
|
72
|
+
|
|
73
|
+
The tool generates full CRUD endpoints for every top-level key in your JSON (e.g., `users`, `posts`).
|
|
74
|
+
|
|
75
|
+
| Method | Path | Description |
|
|
76
|
+
| ------ | ---- | ----------- |
|
|
77
|
+
| `GET` | `/{resource}` | List items (supports filtering, sorting, paging). |
|
|
78
|
+
| `POST` | `/{resource}` | Create item (auto-id + schema validation). |
|
|
79
|
+
| `GET` | `/{resource}/{id}` | Return a single item by `id`. |
|
|
80
|
+
| `PUT` | `/{resource}/{id}` | Replace item (schema validation). |
|
|
81
|
+
| `PATCH` | `/{resource}/{id}` | Partial update (schema validation). |
|
|
82
|
+
| `DELETE` | `/{resource}/{id}` | Remove item. |
|
|
83
|
+
|
|
84
|
+
### Advanced List Features
|
|
85
|
+
|
|
86
|
+
**Filtering**
|
|
87
|
+
|
|
88
|
+
- `GET /posts?category=tech` — exact match
|
|
89
|
+
- `GET /posts?views_gte=100` — greater than or equal
|
|
90
|
+
- `GET /posts?title_like=hello` — case-insensitive search
|
|
91
|
+
|
|
92
|
+
**Sorting**
|
|
93
|
+
|
|
94
|
+
- `GET /posts?_sort=createdAt&_order=desc`
|
|
95
|
+
|
|
96
|
+
**Pagination**
|
|
97
|
+
|
|
98
|
+
- `GET /posts?_page=1&_limit=10`
|
|
99
|
+
- Returns an `X-Total-Count` header and a wrapped `{ data, pagination }` object.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## JSON Structure
|
|
104
|
+
|
|
105
|
+
The root must be an object, and every resource must be an array.
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"users": [
|
|
110
|
+
{ "id": 1, "name": "Alice", "role": "admin" },
|
|
111
|
+
{ "id": 2, "name": "Bob" }
|
|
112
|
+
]
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Note: zero-mock automatically infers that `role` is an optional string based on the items above.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Development
|
|
121
|
+
|
|
122
|
+
1. Clone the repo.
|
|
123
|
+
2. `npm install`
|
|
124
|
+
3. `npm run dev` (runs with `ts-node`)
|
|
125
|
+
4. `npm run build` (compiles to `dist/`)
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Publishing (Maintainers)
|
|
130
|
+
|
|
131
|
+
This project uses `semantic-release` for fully automated versioning and npm publishing.
|
|
132
|
+
|
|
133
|
+
1. Ensure your commits follow the [Conventional Commits](https://www.conventionalcommits.org/) format (e.g., `feat:`, `fix:`, `chore:`).
|
|
134
|
+
2. Push or open a PR against the `main` branch.
|
|
135
|
+
3. GitHub Actions handles testing, version bumping, changelog generation, and publishing to npm.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
MIT — see [LICENSE](LICENSE).
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Links
|
|
146
|
+
|
|
147
|
+
- **Repository:** [github.com/xircons/zero-mock](https://github.com/xircons/zero-mock)
|
|
148
|
+
- **Issues:** [github.com/xircons/zero-mock/issues](https://github.com/xircons/zero-mock/issues)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.PANEL_W = void 0;
|
|
7
|
+
exports.stripAnsi = stripAnsi;
|
|
8
|
+
exports.centerPad = centerPad;
|
|
9
|
+
exports.sectionHeader = sectionHeader;
|
|
10
|
+
exports.renderConfigPanel = renderConfigPanel;
|
|
11
|
+
exports.runWizard = runWizard;
|
|
12
|
+
const prompts_1 = require("@inquirer/prompts");
|
|
13
|
+
const picocolors_1 = __importDefault(require("picocolors"));
|
|
14
|
+
const config_store_1 = require("./config-store");
|
|
15
|
+
exports.PANEL_W = 42;
|
|
16
|
+
function stripAnsi(s) {
|
|
17
|
+
return s.replace(/\x1B\[[0-9;]*[mGKHF]/g, '');
|
|
18
|
+
}
|
|
19
|
+
function centerPad(text, width = 42) {
|
|
20
|
+
const stripped = stripAnsi(text);
|
|
21
|
+
const padding = Math.max(0, Math.floor((width - stripped.length) / 2));
|
|
22
|
+
return ' '.repeat(padding) + text;
|
|
23
|
+
}
|
|
24
|
+
function sectionHeader(title) {
|
|
25
|
+
const header = `${picocolors_1.default.blue('◆')} ${picocolors_1.default.bold(picocolors_1.default.white(title))}`;
|
|
26
|
+
const rule = picocolors_1.default.dim('─'.repeat(exports.PANEL_W));
|
|
27
|
+
return `${header}\n${rule}`;
|
|
28
|
+
}
|
|
29
|
+
function renderConfigPanel(config, saved) {
|
|
30
|
+
let out = picocolors_1.default.dim('╭' + '─'.repeat(exports.PANEL_W) + '╮\n');
|
|
31
|
+
const rows = [
|
|
32
|
+
{ label: 'Target File', value: config.file },
|
|
33
|
+
{ label: 'Default Port', value: config.port },
|
|
34
|
+
{ label: 'Latency', value: `${config.delay}ms` },
|
|
35
|
+
{ label: 'Watch Mode', value: config.watch ? 'Active' : 'Inactive' },
|
|
36
|
+
];
|
|
37
|
+
for (const row of rows) {
|
|
38
|
+
const labelPadded = ` · ${row.label} `.padEnd(20, ' ');
|
|
39
|
+
const valRaw = row.value;
|
|
40
|
+
const spaceCount = Math.max(0, exports.PANEL_W - labelPadded.length - valRaw.length - 3);
|
|
41
|
+
const spaces = ' '.repeat(spaceCount);
|
|
42
|
+
out += picocolors_1.default.dim('│') + picocolors_1.default.dim(labelPadded) + picocolors_1.default.white(row.value) + spaces + ' ' + picocolors_1.default.green('✓') + ' ' + picocolors_1.default.dim('│') + '\n';
|
|
43
|
+
}
|
|
44
|
+
if (saved) {
|
|
45
|
+
out += picocolors_1.default.dim('├' + '─'.repeat(exports.PANEL_W) + '┤\n');
|
|
46
|
+
const footerText = `last used ${(0, config_store_1.formatSavedAt)(saved.savedAt)}`;
|
|
47
|
+
const footerPadded = ` · ${footerText} `.padEnd(exports.PANEL_W, ' ');
|
|
48
|
+
out += picocolors_1.default.dim('│') + picocolors_1.default.dim(footerPadded) + picocolors_1.default.dim('│') + '\n';
|
|
49
|
+
}
|
|
50
|
+
out += picocolors_1.default.dim('╰' + '─'.repeat(exports.PANEL_W) + '╯');
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
const hex = (hexCode, text) => {
|
|
54
|
+
const r = parseInt(hexCode.slice(1, 3), 16);
|
|
55
|
+
const g = parseInt(hexCode.slice(3, 5), 16);
|
|
56
|
+
const b = parseInt(hexCode.slice(5, 7), 16);
|
|
57
|
+
return `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`;
|
|
58
|
+
};
|
|
59
|
+
const FALLBACK = {
|
|
60
|
+
file: './example/db.json',
|
|
61
|
+
port: '8080',
|
|
62
|
+
delay: '200',
|
|
63
|
+
watch: true,
|
|
64
|
+
};
|
|
65
|
+
async function runWizard() {
|
|
66
|
+
console.clear();
|
|
67
|
+
const saved = (0, config_store_1.loadSavedConfig)();
|
|
68
|
+
const defaults = saved ?? FALLBACK;
|
|
69
|
+
const isReturning = saved !== null;
|
|
70
|
+
const logoTop = hex('#bfdbfe', ' ▗▄▄▄▄▖▗▄▄▄▖▗▄▄▖ ▗▄▖ ▗▖ ▗▖ ▗▄▖ ▗▄▄▖▗▖ ▗▖');
|
|
71
|
+
const logoMid1 = hex('#60a5fa', ' ▗▞▘▐▌ ▐▌ ▐▌▐▌ ▐▌▐▛▚▞▜▌▐▌ ▐▌▐▌ ▐▌▗▞▘');
|
|
72
|
+
const logoMid2 = hex('#2563eb', ' ▗▞▘ ▐▛▀▀▘▐▛▀▚▖▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▛▚▖ ');
|
|
73
|
+
const logoBottom = hex('#1e3a8a', ' ▐▙▄▄▄▖▐▙▄▄▖▐▌ ▐▌▝▚▄▞▘▐▌ ▐▌▝▚▄▞▘▝▚▄▄▖▐▌ ▐▌');
|
|
74
|
+
console.log(`\n${logoTop}\n${logoMid1}\n${logoMid2}\n${logoBottom}\n`);
|
|
75
|
+
console.log(centerPad(picocolors_1.default.bold(picocolors_1.default.white("Zero-Mock By Xircons"))));
|
|
76
|
+
console.log(centerPad(picocolors_1.default.dim("Zero-config REST API setup in seconds")));
|
|
77
|
+
if (isReturning && saved) {
|
|
78
|
+
console.log(centerPad(picocolors_1.default.dim(`last session ${(0, config_store_1.formatSavedAt)(saved.savedAt)}`)));
|
|
79
|
+
}
|
|
80
|
+
console.log('\n' + renderConfigPanel(defaults, saved) + '\n');
|
|
81
|
+
const promptTheme = { prefix: picocolors_1.default.blue('◆') };
|
|
82
|
+
const choice = await (0, prompts_1.select)({
|
|
83
|
+
message: 'What would you like to do?',
|
|
84
|
+
theme: promptTheme,
|
|
85
|
+
choices: [
|
|
86
|
+
{ name: `${picocolors_1.default.green('▶')} Continue`, value: 'continue', short: 'Continue' },
|
|
87
|
+
{ name: `${picocolors_1.default.yellow('◈')} Change configuration (pick manually)`, value: 'change', short: 'Change configuration' },
|
|
88
|
+
{ name: `${picocolors_1.default.dim('✕')} Cancel (exit wizard)`, value: 'cancel', short: 'Cancel' },
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
if (choice === 'cancel') {
|
|
92
|
+
console.clear();
|
|
93
|
+
console.log(picocolors_1.default.dim('Setup cancelled. zero-mock aborted.'));
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}
|
|
96
|
+
if (choice === 'continue') {
|
|
97
|
+
console.clear();
|
|
98
|
+
(0, config_store_1.saveConfig)(defaults, saved);
|
|
99
|
+
return defaults;
|
|
100
|
+
}
|
|
101
|
+
// Change configuration
|
|
102
|
+
console.clear();
|
|
103
|
+
console.log('\n' + sectionHeader('CUSTOM CONFIGURATION'));
|
|
104
|
+
if (isReturning) {
|
|
105
|
+
console.log(picocolors_1.default.dim('Pre-filled from last session — press Enter to keep\n'));
|
|
106
|
+
}
|
|
107
|
+
const file = await (0, prompts_1.input)({
|
|
108
|
+
message: 'Target File Path:',
|
|
109
|
+
default: defaults.file,
|
|
110
|
+
theme: promptTheme,
|
|
111
|
+
validate: (value) => {
|
|
112
|
+
if (!value.trim())
|
|
113
|
+
return "File path cannot be empty.";
|
|
114
|
+
if (!value.endsWith('.json'))
|
|
115
|
+
return "File must be a JSON file (.json).";
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
const port = await (0, prompts_1.input)({
|
|
120
|
+
message: 'Port Number:',
|
|
121
|
+
default: defaults.port,
|
|
122
|
+
theme: promptTheme,
|
|
123
|
+
validate: (value) => {
|
|
124
|
+
const p = parseInt(value, 10);
|
|
125
|
+
if (isNaN(p) || p < 1 || p > 65535)
|
|
126
|
+
return "Port must be a number between 1 and 65535.";
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
const delay = await (0, prompts_1.input)({
|
|
131
|
+
message: 'Simulated Latency (ms):',
|
|
132
|
+
default: defaults.delay,
|
|
133
|
+
theme: promptTheme,
|
|
134
|
+
validate: (value) => {
|
|
135
|
+
const d = parseInt(value, 10);
|
|
136
|
+
if (isNaN(d) || d < 0)
|
|
137
|
+
return "Latency must be a non-negative integer.";
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
const watch = await (0, prompts_1.confirm)({
|
|
142
|
+
message: 'Enable Watch Mode (auto-reload on save)?',
|
|
143
|
+
default: defaults.watch,
|
|
144
|
+
theme: promptTheme,
|
|
145
|
+
});
|
|
146
|
+
const newConfig = { file, port, delay, watch };
|
|
147
|
+
(0, config_store_1.saveConfig)(newConfig, saved);
|
|
148
|
+
console.log(`\n ${picocolors_1.default.green('✓')} Configuration updated.`);
|
|
149
|
+
console.clear();
|
|
150
|
+
return newConfig;
|
|
151
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.loadSavedConfig = loadSavedConfig;
|
|
7
|
+
exports.saveConfig = saveConfig;
|
|
8
|
+
exports.clearSavedConfig = clearSavedConfig;
|
|
9
|
+
exports.formatSavedAt = formatSavedAt;
|
|
10
|
+
const fs_1 = __importDefault(require("fs"));
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
const os_1 = __importDefault(require("os"));
|
|
13
|
+
const picocolors_1 = __importDefault(require("picocolors"));
|
|
14
|
+
const getConfigPath = () => path_1.default.join(os_1.default.homedir(), '.config', 'zero-mock', 'config.json');
|
|
15
|
+
function loadSavedConfig() {
|
|
16
|
+
try {
|
|
17
|
+
const configPath = getConfigPath();
|
|
18
|
+
if (!fs_1.default.existsSync(configPath)) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const raw = fs_1.default.readFileSync(configPath, 'utf8');
|
|
22
|
+
const parsed = JSON.parse(raw);
|
|
23
|
+
if (typeof parsed === 'object' &&
|
|
24
|
+
parsed !== null &&
|
|
25
|
+
typeof parsed.file === 'string' &&
|
|
26
|
+
typeof parsed.port === 'string' &&
|
|
27
|
+
typeof parsed.delay === 'string' &&
|
|
28
|
+
typeof parsed.watch === 'boolean') {
|
|
29
|
+
return parsed;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function saveConfig(config, prev) {
|
|
38
|
+
try {
|
|
39
|
+
const configPath = getConfigPath();
|
|
40
|
+
const configDir = path_1.default.dirname(configPath);
|
|
41
|
+
fs_1.default.mkdirSync(configDir, { recursive: true });
|
|
42
|
+
const savedConfig = {
|
|
43
|
+
...config,
|
|
44
|
+
savedAt: new Date().toISOString(),
|
|
45
|
+
useCount: Math.min((prev?.useCount || 0) + 1, 99999),
|
|
46
|
+
};
|
|
47
|
+
fs_1.default.writeFileSync(configPath, JSON.stringify(savedConfig, null, 2), 'utf8');
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Non-fatal, do nothing
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function clearSavedConfig() {
|
|
54
|
+
try {
|
|
55
|
+
const configPath = getConfigPath();
|
|
56
|
+
if (fs_1.default.existsSync(configPath)) {
|
|
57
|
+
fs_1.default.unlinkSync(configPath);
|
|
58
|
+
console.log(`${picocolors_1.default.green('✓')} Cleared saved configuration.`);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
console.log(`${picocolors_1.default.dim('✕')} No saved configuration found.`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
console.log(`${picocolors_1.default.red('✕')} Failed to clear config: ${err.message}`);
|
|
66
|
+
}
|
|
67
|
+
process.exit(0);
|
|
68
|
+
}
|
|
69
|
+
function formatSavedAt(iso) {
|
|
70
|
+
const date = new Date(iso);
|
|
71
|
+
const now = new Date();
|
|
72
|
+
const diffMs = now.getTime() - date.getTime();
|
|
73
|
+
const mins = Math.floor(diffMs / 60000);
|
|
74
|
+
if (mins < 2)
|
|
75
|
+
return "just now";
|
|
76
|
+
const hours = Math.floor(mins / 60);
|
|
77
|
+
if (hours < 1)
|
|
78
|
+
return `${mins}m ago`;
|
|
79
|
+
const days = Math.floor(hours / 24);
|
|
80
|
+
if (days < 1)
|
|
81
|
+
return `${hours}h ago`;
|
|
82
|
+
if (days < 7)
|
|
83
|
+
return `${days}d ago`;
|
|
84
|
+
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
85
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.printError = printError;
|
|
7
|
+
exports.printFatal = printFatal;
|
|
8
|
+
exports.validateConfig = validateConfig;
|
|
9
|
+
exports.acquireLock = acquireLock;
|
|
10
|
+
const picocolors_1 = __importDefault(require("picocolors"));
|
|
11
|
+
const fs_1 = __importDefault(require("fs"));
|
|
12
|
+
const path_1 = __importDefault(require("path"));
|
|
13
|
+
const ERROR_MESSAGES = {
|
|
14
|
+
FILE_NOT_FOUND: { msg: 'Target JSON file not found.', hint: 'Check if the file path is correct.' },
|
|
15
|
+
FILE_NOT_JSON: { msg: 'Target file is not a JSON file.', hint: 'Ensure the file ends with .json.' },
|
|
16
|
+
FILE_UNREADABLE: { msg: 'Cannot read the target file.', hint: 'Check file permissions.' },
|
|
17
|
+
JSON_INVALID: { msg: 'Invalid JSON format.', hint: 'Check the file for syntax errors.' },
|
|
18
|
+
JSON_NOT_OBJECT: { msg: 'JSON root is not an object.', hint: 'The JSON file must have an object at its root.' },
|
|
19
|
+
JSON_EMPTY: { msg: 'JSON file is empty.', hint: 'The API will start with no resources. Add arrays to the root object.' },
|
|
20
|
+
PORT_IN_USE: { msg: 'Port is already in use.', hint: 'Choose a different port or kill the process using it.' },
|
|
21
|
+
PORT_INVALID: { msg: 'Invalid port number.', hint: 'Port must be an integer between 1024 and 65535.' },
|
|
22
|
+
PORT_PRIVILEGED: { msg: 'Port requires root privileges.', hint: 'Use a port number >= 1024.' },
|
|
23
|
+
LOCK_STALE: { msg: 'Found a stale lock file.', hint: 'The lock file will be automatically removed.' },
|
|
24
|
+
LOCK_CONFLICT: { msg: 'Another instance is running.', hint: 'Stop the other zero-mock instance or use a different file.' },
|
|
25
|
+
LOCK_WRITE_FAILED: { msg: 'Failed to write lock file.', hint: 'Check directory permissions.' },
|
|
26
|
+
WATCH_FAILED: { msg: 'File watcher failed to start.', hint: 'Hot-reloading will not work. Check OS file watch limits.' },
|
|
27
|
+
WATCH_RELOAD_FAILED: { msg: 'Failed to reload JSON file.', hint: 'Fix the JSON syntax error. Serving last good snapshot.' },
|
|
28
|
+
SERVER_CRASH: { msg: 'Server crashed unexpectedly.', hint: 'Check the error details below.' },
|
|
29
|
+
UNKNOWN: { msg: 'An unknown error occurred.', hint: 'Please report this issue.' },
|
|
30
|
+
};
|
|
31
|
+
function formatErrorBlock(type, code, detail) {
|
|
32
|
+
const badge = type === 'ERROR'
|
|
33
|
+
? picocolors_1.default.red(picocolors_1.default.bold(picocolors_1.default.inverse(' ERROR ')))
|
|
34
|
+
: picocolors_1.default.yellow(picocolors_1.default.bold(picocolors_1.default.inverse(' WARN ')));
|
|
35
|
+
const { msg, hint } = ERROR_MESSAGES[code];
|
|
36
|
+
let out = `\n ${badge} ${picocolors_1.default.bold(picocolors_1.default.white(msg))}\n`;
|
|
37
|
+
if (detail) {
|
|
38
|
+
out += ` ${picocolors_1.default.dim('│')} ${picocolors_1.default.dim(detail)}\n`;
|
|
39
|
+
}
|
|
40
|
+
out += ` ${picocolors_1.default.dim('╰─')} ${picocolors_1.default.blue(hint)}\n`;
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
function printError(code, detail) {
|
|
44
|
+
console.error(formatErrorBlock('WARN', code, detail));
|
|
45
|
+
}
|
|
46
|
+
function printFatal(code, detail) {
|
|
47
|
+
console.error(formatErrorBlock('ERROR', code, detail));
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
function validateConfig(file, port) {
|
|
51
|
+
if (path_1.default.extname(file) !== '.json') {
|
|
52
|
+
printFatal('FILE_NOT_JSON', `Path: ${file}`);
|
|
53
|
+
}
|
|
54
|
+
if (!fs_1.default.existsSync(file)) {
|
|
55
|
+
printFatal('FILE_NOT_FOUND', `Path: ${file}`);
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
fs_1.default.accessSync(file, fs_1.default.constants.R_OK);
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
printFatal('FILE_UNREADABLE', e.message);
|
|
62
|
+
}
|
|
63
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
64
|
+
printFatal('PORT_INVALID', `Given: ${port}`);
|
|
65
|
+
}
|
|
66
|
+
if (port < 1024) {
|
|
67
|
+
printFatal('PORT_PRIVILEGED', `Given: ${port}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function acquireLock(file) {
|
|
71
|
+
const lockFilePath = `${file}.zero-mock.lock`;
|
|
72
|
+
if (fs_1.default.existsSync(lockFilePath)) {
|
|
73
|
+
try {
|
|
74
|
+
const lockData = fs_1.default.readFileSync(lockFilePath, 'utf8');
|
|
75
|
+
const { pid, startedAt } = JSON.parse(lockData);
|
|
76
|
+
try {
|
|
77
|
+
process.kill(pid, 0); // test liveness
|
|
78
|
+
// Alive
|
|
79
|
+
printFatal('LOCK_CONFLICT', `PID ${pid} since ${startedAt}`);
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
if (e.code === 'ESRCH') {
|
|
83
|
+
// Stale
|
|
84
|
+
printError('LOCK_STALE', `PID ${pid} is dead.`);
|
|
85
|
+
fs_1.default.unlinkSync(lockFilePath);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
// No permission or other error
|
|
89
|
+
printFatal('LOCK_CONFLICT', `Unable to check PID ${pid}.`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
// Unreadable or invalid lock file format
|
|
95
|
+
printError('LOCK_STALE', `Invalid lock file format.`);
|
|
96
|
+
fs_1.default.unlinkSync(lockFilePath);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
fs_1.default.writeFileSync(lockFilePath, JSON.stringify({
|
|
101
|
+
pid: process.pid,
|
|
102
|
+
startedAt: new Date().toISOString()
|
|
103
|
+
}), { flag: 'wx' });
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
if (e.code === 'EEXIST') {
|
|
107
|
+
printFatal('LOCK_CONFLICT', 'Lock file appeared during race condition.');
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
printFatal('LOCK_WRITE_FAILED', e.message);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const cleanup = () => {
|
|
114
|
+
try {
|
|
115
|
+
if (fs_1.default.existsSync(lockFilePath)) {
|
|
116
|
+
fs_1.default.unlinkSync(lockFilePath);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (_) { /* ignore cleanup errors */ }
|
|
120
|
+
};
|
|
121
|
+
process.on('exit', cleanup);
|
|
122
|
+
process.on('SIGINT', () => { cleanup(); process.exit(130); });
|
|
123
|
+
process.on('SIGTERM', () => { cleanup(); process.exit(143); });
|
|
124
|
+
process.on('uncaughtException', (err) => {
|
|
125
|
+
cleanup();
|
|
126
|
+
printFatal('SERVER_CRASH', err.message);
|
|
127
|
+
});
|
|
128
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,50 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
4
|
+
if (k2 === undefined) k2 = k;
|
|
5
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
6
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
7
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
8
|
+
}
|
|
9
|
+
Object.defineProperty(o, k2, desc);
|
|
10
|
+
}) : (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
o[k2] = m[k];
|
|
13
|
+
}));
|
|
14
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
15
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
16
|
+
}) : function(o, v) {
|
|
17
|
+
o["default"] = v;
|
|
18
|
+
});
|
|
19
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
20
|
+
var ownKeys = function(o) {
|
|
21
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
22
|
+
var ar = [];
|
|
23
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
24
|
+
return ar;
|
|
25
|
+
};
|
|
26
|
+
return ownKeys(o);
|
|
27
|
+
};
|
|
28
|
+
return function (mod) {
|
|
29
|
+
if (mod && mod.__esModule) return mod;
|
|
30
|
+
var result = {};
|
|
31
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
32
|
+
__setModuleDefault(result, mod);
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
})();
|
|
36
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
37
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
38
|
+
};
|
|
3
39
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
-
const
|
|
40
|
+
const chokidar = __importStar(require("chokidar"));
|
|
41
|
+
const picocolors_1 = __importDefault(require("picocolors"));
|
|
5
42
|
const commander_1 = require("commander");
|
|
6
43
|
const jsonStore_1 = require("./store/jsonStore");
|
|
7
44
|
const bootstrap_1 = require("./server/bootstrap");
|
|
45
|
+
const cli_wizard_1 = require("./cli-wizard");
|
|
46
|
+
const errors_1 = require("./errors");
|
|
47
|
+
const config_store_1 = require("./config-store");
|
|
8
48
|
function parseDelayMs(raw) {
|
|
9
49
|
const trimmed = raw.trim();
|
|
10
50
|
if (!/^\d+$/.test(trimmed)) {
|
|
@@ -12,77 +52,114 @@ function parseDelayMs(raw) {
|
|
|
12
52
|
}
|
|
13
53
|
return Number.parseInt(trimmed, 10);
|
|
14
54
|
}
|
|
55
|
+
const program = new commander_1.Command();
|
|
56
|
+
let watcher = null;
|
|
15
57
|
function startFileWatcher(filePath) {
|
|
16
|
-
let
|
|
58
|
+
let reloadTimeout = null;
|
|
59
|
+
if (watcher) {
|
|
60
|
+
watcher.close().catch(() => { });
|
|
61
|
+
watcher = null;
|
|
62
|
+
}
|
|
17
63
|
const reload = async () => {
|
|
18
|
-
if (reloadInFlight) {
|
|
19
|
-
return;
|
|
20
|
-
}
|
|
21
|
-
reloadInFlight = true;
|
|
22
64
|
try {
|
|
23
|
-
await jsonStore_1.JsonStore.load(filePath);
|
|
65
|
+
await jsonStore_1.JsonStore.load(filePath, true);
|
|
66
|
+
console.log(`\n ${picocolors_1.default.dim('│')} ${picocolors_1.default.green('✓')} ${picocolors_1.default.dim('Hot reloaded data from')} ${picocolors_1.default.white(filePath)}`);
|
|
24
67
|
}
|
|
25
68
|
catch (err) {
|
|
26
|
-
|
|
27
|
-
console.error(`[watch] Could not reload "${filePath}": ${message}`);
|
|
28
|
-
}
|
|
29
|
-
finally {
|
|
30
|
-
reloadInFlight = false;
|
|
69
|
+
(0, errors_1.printError)('WATCH_RELOAD_FAILED', err.message);
|
|
31
70
|
}
|
|
32
71
|
};
|
|
33
72
|
try {
|
|
34
|
-
|
|
35
|
-
|
|
73
|
+
watcher = chokidar.watch(filePath, { persistent: true, ignoreInitial: true });
|
|
74
|
+
watcher
|
|
75
|
+
.on("change", () => {
|
|
76
|
+
if (reloadTimeout)
|
|
77
|
+
clearTimeout(reloadTimeout);
|
|
78
|
+
reloadTimeout = setTimeout(() => void reload(), 100);
|
|
79
|
+
})
|
|
80
|
+
.on("error", (err) => {
|
|
81
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
82
|
+
(0, errors_1.printError)('WATCH_FAILED', msg);
|
|
36
83
|
});
|
|
37
|
-
console.log(`[watch] Watching "${filePath}" for changes.`);
|
|
38
84
|
}
|
|
39
85
|
catch (err) {
|
|
40
|
-
|
|
41
|
-
console.error(`[watch] Failed to watch "${filePath}": ${message}`);
|
|
86
|
+
(0, errors_1.printError)('WATCH_FAILED', err.message);
|
|
42
87
|
}
|
|
43
88
|
}
|
|
44
|
-
const program = new commander_1.Command();
|
|
45
89
|
program
|
|
46
90
|
.name("zero-mock")
|
|
47
91
|
.description("Generate a REST API from a JSON file")
|
|
48
|
-
.
|
|
49
|
-
.option("-p, --port <number>", "HTTP port"
|
|
50
|
-
.option("-d, --delay <ms>", "delay each request by this many ms (0 = off)"
|
|
51
|
-
.option("-w, --watch", "reload JSON from disk when the file changes"
|
|
92
|
+
.option("-f, --file <path>", "path to the source JSON file")
|
|
93
|
+
.option("-p, --port <number>", "HTTP port")
|
|
94
|
+
.option("-d, --delay <ms>", "delay each request by this many ms (0 = off)")
|
|
95
|
+
.option("-w, --watch", "reload JSON from disk when the file changes")
|
|
96
|
+
.option("--cors-origin <origins>", "comma-separated list of allowed origins (e.g., http://localhost:3000)", "*")
|
|
97
|
+
.option("--cors-methods <methods>", "comma-separated list of allowed HTTP methods", "GET,HEAD,PUT,PATCH,POST,DELETE")
|
|
98
|
+
.option("--cors-credentials", "enable CORS credentials (cookies, authorization headers)", false)
|
|
99
|
+
.option("--reset", "clear saved wizard configuration")
|
|
52
100
|
.action(async (opts) => {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
process.exit(1);
|
|
57
|
-
return;
|
|
101
|
+
if (process.argv.includes('--reset')) {
|
|
102
|
+
(0, config_store_1.clearSavedConfig)();
|
|
103
|
+
process.exit(0);
|
|
58
104
|
}
|
|
59
|
-
|
|
105
|
+
let { file, port: portRaw, delay: delayRaw, watch } = opts;
|
|
106
|
+
if (!file && !portRaw && !delayRaw && watch === undefined) {
|
|
107
|
+
const wizardConfig = await (0, cli_wizard_1.runWizard)();
|
|
108
|
+
file = wizardConfig.file;
|
|
109
|
+
portRaw = wizardConfig.port;
|
|
110
|
+
delayRaw = wizardConfig.delay;
|
|
111
|
+
watch = wizardConfig.watch;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
if (!file) {
|
|
115
|
+
console.error("error: required option '-f, --file <path>' not specified");
|
|
116
|
+
process.exit(1);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
portRaw = portRaw ?? "3000";
|
|
120
|
+
delayRaw = delayRaw ?? "0";
|
|
121
|
+
watch = watch ?? false;
|
|
122
|
+
}
|
|
123
|
+
const port = Number.parseInt(portRaw, 10);
|
|
124
|
+
const delayMs = parseDelayMs(delayRaw);
|
|
60
125
|
if (delayMs === null) {
|
|
61
126
|
console.error("error: --delay must be a non-negative integer");
|
|
62
127
|
process.exit(1);
|
|
63
128
|
return;
|
|
64
129
|
}
|
|
65
|
-
const filePath =
|
|
130
|
+
const filePath = file;
|
|
131
|
+
// Ordered Validation Step 1 & 2
|
|
132
|
+
(0, errors_1.validateConfig)(filePath, port);
|
|
133
|
+
(0, errors_1.acquireLock)(filePath);
|
|
66
134
|
try {
|
|
67
135
|
await jsonStore_1.JsonStore.load(filePath);
|
|
68
136
|
}
|
|
69
137
|
catch (err) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return;
|
|
138
|
+
// JsonStore.load handles its own printFatal internally,
|
|
139
|
+
// but we catch to ensure we don't crash without formatting.
|
|
140
|
+
(0, errors_1.printFatal)('UNKNOWN', err.message);
|
|
74
141
|
}
|
|
75
142
|
try {
|
|
76
|
-
await (0, bootstrap_1.bootstrap)(port,
|
|
143
|
+
await (0, bootstrap_1.bootstrap)(port, filePath, watch, {
|
|
144
|
+
delayMs,
|
|
145
|
+
corsOrigin: opts.corsOrigin,
|
|
146
|
+
corsMethods: opts.corsMethods,
|
|
147
|
+
corsCredentials: opts.corsCredentials,
|
|
148
|
+
});
|
|
77
149
|
}
|
|
78
150
|
catch (err) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
process.exit(1);
|
|
82
|
-
return;
|
|
151
|
+
// Bootstrap will handle EADDRINUSE internally, catching just in case.
|
|
152
|
+
(0, errors_1.printFatal)('UNKNOWN', err.message);
|
|
83
153
|
}
|
|
84
|
-
if (
|
|
154
|
+
if (watch) {
|
|
85
155
|
startFileWatcher(filePath);
|
|
86
156
|
}
|
|
157
|
+
const cleanup = () => {
|
|
158
|
+
if (watcher) {
|
|
159
|
+
watcher.close().catch(() => { });
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
process.on('SIGINT', cleanup);
|
|
163
|
+
process.on('SIGTERM', cleanup);
|
|
87
164
|
});
|
|
88
165
|
program.parse(process.argv);
|
package/dist/server/app.js
CHANGED
|
@@ -7,19 +7,14 @@ exports.createApp = createApp;
|
|
|
7
7
|
const cors_1 = __importDefault(require("cors"));
|
|
8
8
|
const express_1 = __importDefault(require("express"));
|
|
9
9
|
const dynamicRouter_1 = require("./routes/dynamicRouter");
|
|
10
|
+
const logger_1 = require("./logger");
|
|
10
11
|
const errorHandler = (err, _req, res, _next) => {
|
|
11
12
|
if (res.headersSent) {
|
|
12
13
|
return;
|
|
13
14
|
}
|
|
14
15
|
const message = err instanceof Error ? err.message : String(err);
|
|
15
|
-
res.status(500).json({ error: message });
|
|
16
|
+
res.status(500).json({ error: "INTERNAL_SERVER_ERROR", message, statusCode: 500 });
|
|
16
17
|
};
|
|
17
|
-
function requestLoggingMiddleware(req, res, next) {
|
|
18
|
-
res.on("finish", () => {
|
|
19
|
-
console.log(`[${req.method}] ${req.path} - ${res.statusCode}`);
|
|
20
|
-
});
|
|
21
|
-
next();
|
|
22
|
-
}
|
|
23
18
|
function delayMiddleware(delayMs) {
|
|
24
19
|
return (_req, _res, next) => {
|
|
25
20
|
if (delayMs <= 0) {
|
|
@@ -31,9 +26,13 @@ function delayMiddleware(delayMs) {
|
|
|
31
26
|
}
|
|
32
27
|
function createApp(options) {
|
|
33
28
|
const app = (0, express_1.default)();
|
|
34
|
-
app.use((0, cors_1.default)(
|
|
29
|
+
app.use((0, cors_1.default)({
|
|
30
|
+
origin: options.corsOrigin && options.corsOrigin !== "*" ? options.corsOrigin.split(",") : "*",
|
|
31
|
+
methods: options.corsMethods || "GET,HEAD,PUT,PATCH,POST,DELETE",
|
|
32
|
+
credentials: options.corsCredentials || false,
|
|
33
|
+
}));
|
|
35
34
|
app.use(express_1.default.json());
|
|
36
|
-
app.use(requestLoggingMiddleware);
|
|
35
|
+
app.use(logger_1.requestLoggingMiddleware);
|
|
37
36
|
app.use(delayMiddleware(options.delayMs));
|
|
38
37
|
app.use("/", (0, dynamicRouter_1.buildDynamicRouter)());
|
|
39
38
|
app.use(errorHandler);
|
package/dist/server/bootstrap.js
CHANGED
|
@@ -3,20 +3,24 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.bootstrap = bootstrap;
|
|
4
4
|
const app_1 = require("./app");
|
|
5
5
|
const jsonStore_1 = require("../store/jsonStore");
|
|
6
|
-
|
|
6
|
+
const logger_1 = require("./logger");
|
|
7
|
+
const errors_1 = require("../errors");
|
|
8
|
+
async function bootstrap(port, file, watch, appOptions) {
|
|
7
9
|
const app = (0, app_1.createApp)(appOptions);
|
|
8
10
|
await new Promise((resolve, reject) => {
|
|
9
11
|
const server = app.listen(port, () => {
|
|
10
12
|
resolve();
|
|
11
13
|
});
|
|
12
|
-
server.on("error",
|
|
14
|
+
server.on("error", (e) => {
|
|
15
|
+
if (e.code === 'EADDRINUSE') {
|
|
16
|
+
(0, errors_1.printFatal)('PORT_IN_USE', `Port ${port} — ${e.message}`);
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
(0, errors_1.printFatal)('UNKNOWN', e.message);
|
|
20
|
+
}
|
|
21
|
+
reject(e);
|
|
22
|
+
});
|
|
13
23
|
});
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
console.log("Read/write API: GET, POST, PUT, PATCH, DELETE are active for each resource below.\n");
|
|
17
|
-
for (const resource of Object.keys(jsonStore_1.JsonStore.getData())) {
|
|
18
|
-
console.log(` ${resource}`);
|
|
19
|
-
console.log(` GET POST ${base}/${resource}`);
|
|
20
|
-
console.log(` GET PUT PATCH DELETE ${base}/${resource}/:id`);
|
|
21
|
-
}
|
|
24
|
+
const resources = Object.keys(jsonStore_1.JsonStore.getData());
|
|
25
|
+
(0, logger_1.printStartupBanner)(file, port, watch, appOptions.delayMs, resources);
|
|
22
26
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.getMethodBadge = getMethodBadge;
|
|
7
|
+
exports.requestLoggingMiddleware = requestLoggingMiddleware;
|
|
8
|
+
exports.getMonochromeBadge = getMonochromeBadge;
|
|
9
|
+
exports.printStartupBanner = printStartupBanner;
|
|
10
|
+
const picocolors_1 = __importDefault(require("picocolors"));
|
|
11
|
+
const cli_wizard_1 = require("../cli-wizard");
|
|
12
|
+
function getMethodBadge(method) {
|
|
13
|
+
const m = method.toUpperCase();
|
|
14
|
+
const padded = m.padEnd(6, ' ');
|
|
15
|
+
const inner = `[${padded}]`;
|
|
16
|
+
switch (m) {
|
|
17
|
+
case 'GET': return picocolors_1.default.bold(picocolors_1.default.cyan(inner));
|
|
18
|
+
case 'POST': return picocolors_1.default.bold(picocolors_1.default.green(inner));
|
|
19
|
+
case 'PUT': return picocolors_1.default.bold(picocolors_1.default.yellow(inner));
|
|
20
|
+
case 'PATCH': return picocolors_1.default.bold(picocolors_1.default.magenta(inner));
|
|
21
|
+
case 'DELETE': return picocolors_1.default.bold(picocolors_1.default.red(inner));
|
|
22
|
+
default: return picocolors_1.default.bold(picocolors_1.default.white(inner));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function requestLoggingMiddleware(req, res, next) {
|
|
26
|
+
const startMs = Date.now();
|
|
27
|
+
const start = new Date();
|
|
28
|
+
res.on("finish", () => {
|
|
29
|
+
const timeStr = start.toLocaleTimeString('en-US', { hour12: true });
|
|
30
|
+
const duration = Date.now() - startMs;
|
|
31
|
+
const badge = getMethodBadge(req.method);
|
|
32
|
+
let statusStr = String(res.statusCode);
|
|
33
|
+
if (res.statusCode >= 200 && res.statusCode < 300)
|
|
34
|
+
statusStr = picocolors_1.default.green(statusStr);
|
|
35
|
+
else if (res.statusCode >= 300 && res.statusCode < 400)
|
|
36
|
+
statusStr = picocolors_1.default.cyan(statusStr);
|
|
37
|
+
else if (res.statusCode >= 400 && res.statusCode < 500)
|
|
38
|
+
statusStr = picocolors_1.default.yellow(statusStr);
|
|
39
|
+
else if (res.statusCode >= 500)
|
|
40
|
+
statusStr = picocolors_1.default.red(statusStr);
|
|
41
|
+
const pathPadded = req.path.length > 20 ? req.path : req.path.padEnd(20, ' ');
|
|
42
|
+
console.log(`${picocolors_1.default.dim(`[${timeStr}]`)} ${badge} ${picocolors_1.default.white(pathPadded)} ${statusStr} ${picocolors_1.default.dim(`${duration}ms`)}`);
|
|
43
|
+
});
|
|
44
|
+
next();
|
|
45
|
+
}
|
|
46
|
+
function getMonochromeBadge(method) {
|
|
47
|
+
const m = method.toUpperCase();
|
|
48
|
+
const padded = m.padEnd(6, ' ');
|
|
49
|
+
return `[${padded}]`;
|
|
50
|
+
}
|
|
51
|
+
function printStartupBanner(file, port, isWatch, delayMs, resources) {
|
|
52
|
+
console.log('\n' + (0, cli_wizard_1.sectionHeader)('SERVER READY'));
|
|
53
|
+
console.log((0, cli_wizard_1.renderConfigPanel)({
|
|
54
|
+
file,
|
|
55
|
+
port: String(port),
|
|
56
|
+
delay: String(delayMs),
|
|
57
|
+
watch: isWatch
|
|
58
|
+
}) + '\n');
|
|
59
|
+
console.log((0, cli_wizard_1.sectionHeader)('AVAILABLE RESOURCES'));
|
|
60
|
+
const base = `http://localhost:${port}`;
|
|
61
|
+
const get = getMonochromeBadge('GET');
|
|
62
|
+
const post = getMonochromeBadge('POST');
|
|
63
|
+
const put = getMonochromeBadge('PUT');
|
|
64
|
+
const patch = getMonochromeBadge('PATCH');
|
|
65
|
+
const del = getMonochromeBadge('DELETE');
|
|
66
|
+
for (const resource of resources) {
|
|
67
|
+
console.log(picocolors_1.default.bold(picocolors_1.default.white(`■ /${resource}`)));
|
|
68
|
+
const methodsLeft1 = ` ${get} ${post}`;
|
|
69
|
+
const methodsLeft2 = ` ${get} ${put} ${patch} ${del}`;
|
|
70
|
+
// Align URLs at column 45
|
|
71
|
+
const pad1 = ' '.repeat(Math.max(1, 45 - (0, cli_wizard_1.stripAnsi)(methodsLeft1).length));
|
|
72
|
+
const pad2 = ' '.repeat(Math.max(1, 45 - (0, cli_wizard_1.stripAnsi)(methodsLeft2).length));
|
|
73
|
+
console.log(`${methodsLeft1}${pad1}${picocolors_1.default.white(`${base}/${resource}`)}`);
|
|
74
|
+
console.log(`${methodsLeft2}${pad2}${picocolors_1.default.white(`${base}/${resource}/:id`)}\n`);
|
|
75
|
+
}
|
|
76
|
+
console.log((0, cli_wizard_1.sectionHeader)('REQUEST LOGS'));
|
|
77
|
+
console.log(`${picocolors_1.default.green('✓')} ${picocolors_1.default.dim('Ready on')} ${picocolors_1.default.white(`${base}`)}\n`);
|
|
78
|
+
}
|
|
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.buildDynamicRouter = buildDynamicRouter;
|
|
7
7
|
const crypto_1 = require("crypto");
|
|
8
8
|
const express_1 = __importDefault(require("express"));
|
|
9
|
+
const zod_1 = require("zod");
|
|
9
10
|
const jsonStore_1 = require("../../store/jsonStore");
|
|
10
11
|
function isPlainObject(value) {
|
|
11
12
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -44,27 +45,74 @@ function parsePagination(query) {
|
|
|
44
45
|
return { page, limit };
|
|
45
46
|
}
|
|
46
47
|
function filterCollection(items, query) {
|
|
47
|
-
|
|
48
|
+
let result = items;
|
|
49
|
+
// Sorting
|
|
50
|
+
const sort = firstQueryValue(query["_sort"]);
|
|
51
|
+
const order = firstQueryValue(query["_order"])?.toLowerCase() === "desc" ? -1 : 1;
|
|
52
|
+
if (sort) {
|
|
53
|
+
result = [...result].sort((a, b) => {
|
|
54
|
+
if (!isPlainObject(a) || !isPlainObject(b))
|
|
55
|
+
return 0;
|
|
56
|
+
const valA = a[sort];
|
|
57
|
+
const valB = b[sort];
|
|
58
|
+
if (valA === valB)
|
|
59
|
+
return 0;
|
|
60
|
+
if (valA === undefined)
|
|
61
|
+
return order;
|
|
62
|
+
if (valB === undefined)
|
|
63
|
+
return -order;
|
|
64
|
+
if (typeof valA === "number" && typeof valB === "number")
|
|
65
|
+
return (valA - valB) * order;
|
|
66
|
+
return String(valA).localeCompare(String(valB)) * order;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
const filterEntries = Object.entries(query).filter(([k]) => !["_page", "_limit", "_sort", "_order"].includes(k));
|
|
48
70
|
if (filterEntries.length === 0) {
|
|
49
|
-
return
|
|
71
|
+
return result;
|
|
50
72
|
}
|
|
51
|
-
return
|
|
73
|
+
return result.filter((item) => {
|
|
52
74
|
if (!isPlainObject(item)) {
|
|
53
75
|
return false;
|
|
54
76
|
}
|
|
55
77
|
const rec = item;
|
|
56
78
|
for (const [key, qVal] of filterEntries) {
|
|
57
79
|
const want = firstQueryValue(qVal);
|
|
58
|
-
if (want === undefined)
|
|
80
|
+
if (want === undefined)
|
|
59
81
|
continue;
|
|
82
|
+
let field = key;
|
|
83
|
+
let op = "eq";
|
|
84
|
+
if (key.endsWith("_gte")) {
|
|
85
|
+
field = key.slice(0, -4);
|
|
86
|
+
op = "gte";
|
|
60
87
|
}
|
|
61
|
-
if (
|
|
62
|
-
|
|
88
|
+
else if (key.endsWith("_lte")) {
|
|
89
|
+
field = key.slice(0, -4);
|
|
90
|
+
op = "lte";
|
|
63
91
|
}
|
|
64
|
-
if (
|
|
65
|
-
|
|
92
|
+
else if (key.endsWith("_like")) {
|
|
93
|
+
field = key.slice(0, -5);
|
|
94
|
+
op = "like";
|
|
95
|
+
}
|
|
96
|
+
const actual = rec[field];
|
|
97
|
+
if (op === "eq") {
|
|
98
|
+
if (actual != want)
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
else if (op === "gte") {
|
|
102
|
+
if (Number(actual) < Number(want))
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
else if (op === "lte") {
|
|
106
|
+
if (Number(actual) > Number(want))
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
else if (op === "like") {
|
|
110
|
+
if (actual === undefined || actual === null)
|
|
111
|
+
return false;
|
|
112
|
+
const escaped = want.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
113
|
+
if (!new RegExp(escaped, 'i').test(String(actual)))
|
|
114
|
+
return false;
|
|
66
115
|
}
|
|
67
|
-
return false;
|
|
68
116
|
}
|
|
69
117
|
return true;
|
|
70
118
|
});
|
|
@@ -98,6 +146,43 @@ function itemNotFound(res, resource, id) {
|
|
|
98
146
|
function badBody(res) {
|
|
99
147
|
res.status(400).json({ error: "Request body must be a JSON object." });
|
|
100
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Infer a zod schema from the first item in a collection.
|
|
151
|
+
* Returns null if the collection is empty (skip validation).
|
|
152
|
+
* The schema is "partial" so that POST/PATCH with missing optional fields still pass —
|
|
153
|
+
* only fields that ARE present are type-checked.
|
|
154
|
+
*/
|
|
155
|
+
function inferSchema(collection) {
|
|
156
|
+
const first = collection.find(isPlainObject);
|
|
157
|
+
if (!first)
|
|
158
|
+
return null;
|
|
159
|
+
const shape = {};
|
|
160
|
+
for (const [key, value] of Object.entries(first)) {
|
|
161
|
+
if (key === "id")
|
|
162
|
+
continue;
|
|
163
|
+
if (typeof value === "string")
|
|
164
|
+
shape[key] = zod_1.z.string();
|
|
165
|
+
else if (typeof value === "number")
|
|
166
|
+
shape[key] = zod_1.z.number();
|
|
167
|
+
else if (typeof value === "boolean")
|
|
168
|
+
shape[key] = zod_1.z.boolean();
|
|
169
|
+
else
|
|
170
|
+
shape[key] = zod_1.z.unknown();
|
|
171
|
+
}
|
|
172
|
+
return zod_1.z.object(shape).partial();
|
|
173
|
+
}
|
|
174
|
+
function validateBody(res, collection, body) {
|
|
175
|
+
const schema = inferSchema(collection);
|
|
176
|
+
if (!schema)
|
|
177
|
+
return true; // empty collection → skip
|
|
178
|
+
const result = schema.safeParse(body);
|
|
179
|
+
if (!result.success) {
|
|
180
|
+
const messages = result.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`);
|
|
181
|
+
res.status(400).json({ error: "Validation failed.", details: messages });
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
101
186
|
function numericIdValue(id) {
|
|
102
187
|
if (typeof id === "number" && Number.isFinite(id) && Number.isInteger(id)) {
|
|
103
188
|
return id;
|
|
@@ -151,10 +236,23 @@ function buildDynamicRouter() {
|
|
|
151
236
|
const collection = jsonStore_1.JsonStore.getData()[resource];
|
|
152
237
|
const query = req.query;
|
|
153
238
|
let rows = filterCollection(collection, query);
|
|
239
|
+
const total = rows.length;
|
|
240
|
+
res.setHeader("X-Total-Count", total);
|
|
154
241
|
const pageInfo = parsePagination(query);
|
|
155
242
|
if (pageInfo !== null) {
|
|
156
243
|
const start = (pageInfo.page - 1) * pageInfo.limit;
|
|
157
244
|
rows = rows.slice(start, start + pageInfo.limit);
|
|
245
|
+
const totalPages = Math.ceil(total / pageInfo.limit);
|
|
246
|
+
res.json({
|
|
247
|
+
data: rows,
|
|
248
|
+
pagination: {
|
|
249
|
+
total,
|
|
250
|
+
page: pageInfo.page,
|
|
251
|
+
limit: pageInfo.limit,
|
|
252
|
+
totalPages
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
return;
|
|
158
256
|
}
|
|
159
257
|
res.json(rows);
|
|
160
258
|
});
|
|
@@ -164,6 +262,8 @@ function buildDynamicRouter() {
|
|
|
164
262
|
return;
|
|
165
263
|
}
|
|
166
264
|
const collection = jsonStore_1.JsonStore.getData()[resource];
|
|
265
|
+
if (!validateBody(res, collection, req.body))
|
|
266
|
+
return;
|
|
167
267
|
const newId = nextIdForCollection(collection);
|
|
168
268
|
const rawBody = req.body;
|
|
169
269
|
const rest = { ...rawBody };
|
|
@@ -180,6 +280,8 @@ function buildDynamicRouter() {
|
|
|
180
280
|
}
|
|
181
281
|
const { id: idParam } = req.params;
|
|
182
282
|
const collection = jsonStore_1.JsonStore.getData()[resource];
|
|
283
|
+
if (!validateBody(res, collection, req.body))
|
|
284
|
+
return;
|
|
183
285
|
const index = findIndexById(collection, idParam);
|
|
184
286
|
if (index === -1) {
|
|
185
287
|
itemNotFound(res, resource, idParam);
|
|
@@ -202,6 +304,8 @@ function buildDynamicRouter() {
|
|
|
202
304
|
}
|
|
203
305
|
const { id: idParam } = req.params;
|
|
204
306
|
const collection = jsonStore_1.JsonStore.getData()[resource];
|
|
307
|
+
if (!validateBody(res, collection, req.body))
|
|
308
|
+
return;
|
|
205
309
|
const index = findIndexById(collection, idParam);
|
|
206
310
|
if (index === -1) {
|
|
207
311
|
itemNotFound(res, resource, idParam);
|
package/dist/store/jsonStore.js
CHANGED
|
@@ -1,51 +1,62 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.JsonStore = void 0;
|
|
4
7
|
const crypto_1 = require("crypto");
|
|
5
8
|
const path_1 = require("path");
|
|
9
|
+
const fs_1 = __importDefault(require("fs"));
|
|
6
10
|
const promises_1 = require("fs/promises");
|
|
11
|
+
const errors_1 = require("../errors");
|
|
7
12
|
function isPlainObject(value) {
|
|
8
13
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
14
|
}
|
|
10
|
-
function parseJsonStorePayload(raw, filePath) {
|
|
11
|
-
let parsed;
|
|
12
|
-
try {
|
|
13
|
-
parsed = JSON.parse(raw);
|
|
14
|
-
}
|
|
15
|
-
catch (err) {
|
|
16
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
17
|
-
throw new Error(`Invalid JSON in "${filePath}": ${message}`);
|
|
18
|
-
}
|
|
19
|
-
if (!isPlainObject(parsed)) {
|
|
20
|
-
throw new Error(`JSON root in "${filePath}" must be a non-null object (got ${parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed}).`);
|
|
21
|
-
}
|
|
22
|
-
const data = {};
|
|
23
|
-
for (const [key, value] of Object.entries(parsed)) {
|
|
24
|
-
if (!Array.isArray(value)) {
|
|
25
|
-
throw new Error(`Invalid mock database shape in "${filePath}": property "${key}" must be an array.`);
|
|
26
|
-
}
|
|
27
|
-
data[key] = value;
|
|
28
|
-
}
|
|
29
|
-
return data;
|
|
30
|
-
}
|
|
31
15
|
class JsonStoreImpl {
|
|
32
16
|
data = {};
|
|
33
17
|
backingPath = null;
|
|
34
18
|
/** Serializes concurrent save() calls so writes are not interleaved. */
|
|
35
19
|
saveChain = Promise.resolve();
|
|
36
|
-
async load(filePath) {
|
|
20
|
+
async load(filePath, isReload = false) {
|
|
37
21
|
let raw;
|
|
38
22
|
try {
|
|
39
23
|
raw = await (0, promises_1.readFile)(filePath, "utf8");
|
|
40
24
|
}
|
|
41
25
|
catch (err) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
26
|
+
if (isReload)
|
|
27
|
+
throw new Error(`Unreadable: ${err.message}`);
|
|
28
|
+
(0, errors_1.printFatal)('FILE_UNREADABLE', err.message);
|
|
29
|
+
}
|
|
30
|
+
let parsed;
|
|
31
|
+
try {
|
|
32
|
+
parsed = JSON.parse(raw);
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
if (isReload)
|
|
36
|
+
throw new Error(`Parse error: ${err.message}`);
|
|
37
|
+
(0, errors_1.printFatal)('JSON_INVALID', err.message);
|
|
38
|
+
}
|
|
39
|
+
if (!isPlainObject(parsed)) {
|
|
40
|
+
const msg = `got ${parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed}`;
|
|
41
|
+
if (isReload)
|
|
42
|
+
throw new Error(`Root not an object (${msg})`);
|
|
43
|
+
(0, errors_1.printFatal)('JSON_NOT_OBJECT', msg);
|
|
44
|
+
}
|
|
45
|
+
const keys = Object.keys(parsed);
|
|
46
|
+
if (keys.length === 0) {
|
|
47
|
+
(0, errors_1.printError)('JSON_EMPTY');
|
|
48
|
+
}
|
|
49
|
+
const data = {};
|
|
50
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
51
|
+
if (!Array.isArray(value)) {
|
|
52
|
+
const msg = `property "${key}" must be an array.`;
|
|
53
|
+
if (isReload)
|
|
54
|
+
throw new Error(msg);
|
|
55
|
+
(0, errors_1.printFatal)('JSON_INVALID', msg);
|
|
56
|
+
}
|
|
57
|
+
data[key] = value;
|
|
47
58
|
}
|
|
48
|
-
this.data =
|
|
59
|
+
this.data = data;
|
|
49
60
|
this.backingPath = filePath;
|
|
50
61
|
}
|
|
51
62
|
getData() {
|
|
@@ -66,10 +77,22 @@ class JsonStoreImpl {
|
|
|
66
77
|
const payload = `${JSON.stringify(this.data, null, 2)}\n`;
|
|
67
78
|
try {
|
|
68
79
|
await (0, promises_1.writeFile)(tmp, payload, "utf8");
|
|
69
|
-
|
|
80
|
+
try {
|
|
81
|
+
fs_1.default.renameSync(tmp, target);
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
if (e.code === 'EXDEV') {
|
|
85
|
+
fs_1.default.copyFileSync(tmp, target);
|
|
86
|
+
fs_1.default.unlinkSync(tmp);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
throw e;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
70
92
|
}
|
|
71
93
|
catch (err) {
|
|
72
|
-
|
|
94
|
+
if (fs_1.default.existsSync(tmp))
|
|
95
|
+
fs_1.default.unlinkSync(tmp);
|
|
73
96
|
const message = err instanceof Error ? err.message : String(err);
|
|
74
97
|
throw new Error(`Could not write JSON file "${target}": ${message}`);
|
|
75
98
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xirconsss/zero-mock",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Zero-config CLI that generates REST APIs from JSON files",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -47,9 +47,14 @@
|
|
|
47
47
|
},
|
|
48
48
|
"homepage": "https://github.com/xircons/zero-mock#readme",
|
|
49
49
|
"dependencies": {
|
|
50
|
+
"@inquirer/prompts": "^8.5.2",
|
|
51
|
+
"chokidar": "^4.0.3",
|
|
50
52
|
"commander": "^13.1.0",
|
|
51
53
|
"cors": "^2.8.5",
|
|
52
|
-
"express": "^4.21.2"
|
|
54
|
+
"express": "^4.21.2",
|
|
55
|
+
"gradient-string": "^3.0.0",
|
|
56
|
+
"picocolors": "^1.1.1",
|
|
57
|
+
"zod": "^4.4.3"
|
|
53
58
|
},
|
|
54
59
|
"devDependencies": {
|
|
55
60
|
"@commitlint/cli": "^21.0.2",
|
|
@@ -62,6 +67,7 @@
|
|
|
62
67
|
"@semantic-release/release-notes-generator": "^14.1.1",
|
|
63
68
|
"@types/cors": "^2.8.17",
|
|
64
69
|
"@types/express": "^4.17.21",
|
|
70
|
+
"@types/gradient-string": "^1.1.6",
|
|
65
71
|
"@types/node": "^22.14.0",
|
|
66
72
|
"@vitest/coverage-v8": "^2.1.9",
|
|
67
73
|
"husky": "^9.1.7",
|
package/example/db.json
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"users": [
|
|
3
|
-
{
|
|
4
|
-
"id": 1,
|
|
5
|
-
"name": "Ada Lovelace",
|
|
6
|
-
"email": "ada@example.com",
|
|
7
|
-
"role": "admin"
|
|
8
|
-
},
|
|
9
|
-
{
|
|
10
|
-
"id": 2,
|
|
11
|
-
"name": "Grace Hopper",
|
|
12
|
-
"email": "grace@example.com",
|
|
13
|
-
"role": "editor"
|
|
14
|
-
},
|
|
15
|
-
{
|
|
16
|
-
"id": 3,
|
|
17
|
-
"name": "Margaret Hamilton",
|
|
18
|
-
"email": "margaret@example.com",
|
|
19
|
-
"role": "viewer"
|
|
20
|
-
}
|
|
21
|
-
],
|
|
22
|
-
"posts": [
|
|
23
|
-
{
|
|
24
|
-
"id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
|
|
25
|
-
"title": "Shipping a mock API in minutes",
|
|
26
|
-
"body": "Point zero-mock at a JSON file and iterate on your UI without waiting on backend tickets.",
|
|
27
|
-
"userId": 1,
|
|
28
|
-
"published": true
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
"id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e",
|
|
32
|
-
"title": "Why atomic writes matter",
|
|
33
|
-
"body": "Your JSON file stays the source of truth—even when multiple tabs hit the API at once.",
|
|
34
|
-
"userId": 2,
|
|
35
|
-
"published": false
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"id": "c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f",
|
|
39
|
-
"title": "Frontend-first workflows",
|
|
40
|
-
"body": "Prototype with realistic CRUD, then swap the mock for a production API when you are ready.",
|
|
41
|
-
"userId": 1,
|
|
42
|
-
"published": true
|
|
43
|
-
}
|
|
44
|
-
]
|
|
45
|
-
}
|