@xirconsss/zero-mock 1.0.0 → 1.1.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 CHANGED
@@ -1,37 +1,146 @@
1
- # Frontend Consumer Test
1
+ # zero-mock
2
2
 
3
- This branch tests `zero-mock` as an end-user using the published npm package.
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
4
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.8-blue?logo=typescript&logoColor=white)](tsconfig.json)
5
+ [![npm version](https://img.shields.io/npm/v/@xirconsss/zero-mock.svg)](https://www.npmjs.com/package/@xirconsss/zero-mock)
4
6
 
5
- ## How to run
7
+ **zero-mock** is a zero-config Node.js CLI that turns a JSON file into a local REST API. Point it at a file whose top-level keys are **collection names** and whose values are **arrays of records**—it serves full CRUD routes and writes changes back to disk. Built for **frontend developers** who need a quick, realistic backend for prototypes, demos, and integration tests without standing up a database or bespoke server.
6
8
 
7
- 1. **Install the CLI globally (or run via npx):**
8
- ```bash
9
- npm install -g @xirconsss/zero-mock
10
- ```
9
+ ## Demo
11
10
 
12
- 2. **Start the mock server:**
13
- ```bash
14
- npx @xirconsss/zero-mock -- --file ./data.json --port 3000
15
- ```
11
+ ![Terminal demo](docs/demo.gif)
16
12
 
17
- With simulated delay (2s) and watch, use **long flags** so `npx`/`npm` never treats `-d` as its own debug flag:
13
+ ## Installation
18
14
 
19
- ```bash
20
- npx @xirconsss/zero-mock -- --file ./data.json --port 3000 --delay 2000 --watch
21
- ```
15
+ ### Global
22
16
 
23
- If you still see `required option '-f, --file' not specified`, use **`npm exec`** instead:
17
+ ```bash
18
+ npm install -g @xirconsss/zero-mock
19
+ ```
24
20
 
25
- ```bash
26
- npm exec -- @xirconsss/zero-mock -- --file ./data.json --port 3000 --delay 2000
27
- ```
21
+ Run the CLI as **`zero-mock`** (see [Usage](#usage)).
28
22
 
29
- Or install globally and avoid `npx` parsing entirely:
23
+ ### One-off with npx
30
24
 
31
- ```bash
32
- zero-mock --file ./data.json --port 3000 --delay 2000
33
- ```
25
+ ```bash
26
+ npx @xirconsss/zero-mock -f ./example/db.json -p 3000
27
+ ```
34
28
 
35
- 3. **Run the Frontend:**
36
- Simply open `index.html` in your browser.
29
+ If your shell or npm version forwards extra flags to npm instead of the CLI, insert **`--`** before the first CLI flag (e.g. `npx @xirconsss/zero-mock -- -f ./data.json -p 3000 -w`).
37
30
 
31
+ ## Usage
32
+
33
+ | Flag | Description |
34
+ | ---- | ----------- |
35
+ | **`-f` / `--file`** | Required. Path to the JSON file. |
36
+ | **`-p` / `--port`** | HTTP port (default `3000`, must be 1–65535). |
37
+ | **`-d` / `--delay`** | Optional. Delay every request by this many milliseconds. Must be a non-negative integer using digits only (default `0`). |
38
+ | **`-w` / `--watch`** | Optional. Watch the JSON file and reload the in-memory data when it changes. If a save produces invalid JSON, the server prints `[watch] Could not reload "<path>": ...` to stderr and keeps the last good data until the file is valid again. Only one reload runs at a time. When watch starts, you also get `[watch] Watching "<path>" for changes.` on stdout. |
39
+
40
+ Examples:
41
+
42
+ ```bash
43
+ zero-mock -f ./data.json -p 3000 -d 200
44
+ zero-mock -f ./data.json -w
45
+ ```
46
+
47
+ ## Quick start
48
+
49
+ From the repo root (or any directory containing the example file):
50
+
51
+ ```bash
52
+ npx @xirconsss/zero-mock -f ./example/db.json -p 3000
53
+ ```
54
+
55
+ Optional:
56
+
57
+ ```bash
58
+ npx @xirconsss/zero-mock -f ./example/db.json -p 3000 -d 200 -w
59
+ ```
60
+
61
+ In another terminal:
62
+
63
+ ```bash
64
+ curl http://localhost:3000/users
65
+ curl http://localhost:3000/users/1
66
+ ```
67
+
68
+ The server logs the exact URLs for each collection when it starts.
69
+
70
+ ## Development
71
+
72
+ From a clone: `npm install`, then `npm run build` (or `npm run dev` with `ts-node`). Run the built CLI with:
73
+
74
+ ```bash
75
+ node dist/index.js -f ./example/db.json -p 3000
76
+ ```
77
+
78
+ Add `-d` / `-w` the same way as the published CLI.
79
+
80
+ ## Features
81
+
82
+ - **Zero-config** — one JSON file defines your API surface; no schemas or generators to run.
83
+ - **Full CRUD REST API** — `GET`, `POST`, `PUT`, `PATCH`, and `DELETE` per collection, with CORS and JSON bodies enabled.
84
+ - **Atomic file persistence** — writes go through a temp file and rename, with serialized saves so concurrent requests do not corrupt the file.
85
+ - **Smart ID generation** — new rows get the next **numeric** id when existing ids are integers or all-digit strings; otherwise new ids use a **UUID**.
86
+ - **Request logging** — each finished request logs as `[METHOD] <path> - <status>` (path is Express `req.path`, no query string).
87
+ - **Optional delay** — `-d` adds a fixed pause before route handling (after JSON body parsing).
88
+ - **List filtering and pagination** — see [List GET](#list-get) on `GET /{resource}`.
89
+ - **Watch mode** — `-w` reloads data from disk on file change without restarting the process (see [Usage](#usage)).
90
+
91
+ ## Auto-generated API
92
+
93
+ Each **top-level key** in your JSON (e.g. `users`, `posts`) becomes a **resource** name. Replace `{resource}` with that key and `{id}` with a row’s `id` (string params match numeric ids loosely).
94
+
95
+ | Method | Path | Description |
96
+ | -------- | -------------------- | ----------- |
97
+ | `GET` | `/{resource}` | List items (optionally [filtered and paginated](#list-get)). |
98
+ | `POST` | `/{resource}` | Create an item; server assigns `id` and returns `201` with the new body. |
99
+ | `GET` | `/{resource}/{id}` | Return one item by `id`; `404` if missing. |
100
+ | `PUT` | `/{resource}/{id}` | Replace the item; `id` in the URL wins; `404` if missing. |
101
+ | `PATCH` | `/{resource}/{id}` | Shallow-merge fields into the item; `404` if missing. |
102
+ | `DELETE` | `/{resource}/{id}` | Remove the item; `204` on success; `404` if missing. |
103
+
104
+ Invalid JSON bodies (non-objects for write routes) receive **`400`** with a JSON error message. Persistence failures surface as **`500`**.
105
+
106
+ ### List GET
107
+
108
+ **Filtering:** Every query parameter except `_page` and `_limit` is a filter. Only plain-object rows are kept. For each filter key, the row must have that property, and the value must match the query value with loose equality (`==`). Query values are strings (first value wins if repeated). Rows missing a filter key are dropped.
109
+
110
+ **Pagination:** If **both** `_page` and `_limit` are present and are positive integers (digit strings only), the list is sliced after filtering. `_page` is 1-based. If either is missing or invalid, the full filtered list is returned (no error).
111
+
112
+ Examples using [example/db.json](example/db.json):
113
+
114
+ ```bash
115
+ curl 'http://localhost:3000/users?role=admin'
116
+ curl 'http://localhost:3000/users?_page=1&_limit=2'
117
+ ```
118
+
119
+ ## JSON file shape
120
+
121
+ The root must be a JSON **object**. Each property must be an **array** (your “tables”). Each item you want to address by URL should include an **`id`** field (number or string).
122
+
123
+ ## Publishing to npm (maintainers)
124
+
125
+ **Release flow (recommended):** Automated via `semantic-release`. Commits following the conventional commit format (e.g., `feat:`, `fix:`) pushed to `main` will automatically trigger a version bump, changelog generation, and NPM publish via the [`.github/workflows/publish-npm.yml`](.github/workflows/publish-npm.yml) workflow. Avoid running `npm publish` on your machine.
126
+
127
+ 1. Use an [npmjs.com](https://www.npmjs.com/) account with **2FA** enabled and permission to publish the **`@xirconsss`** scope (user or org on npm).
128
+ 2. **GitHub:** repo → **Settings** → **Environments** → **`NPM_TOKEN`** → add secret **`NPM_TOKEN`** (see token steps below). The workflow uses that environment on each run.
129
+ 3. Push conventional commits to **`main`**, wait for **Publish to npm** to finish. Check with `npm view @xirconsss/zero-mock version`.
130
+
131
+ **Token on npm (required for CI):**
132
+
133
+ 1. Create a classic **[Automation](https://docs.npmjs.com/creating-and-viewing-access-tokens#creating-classic-tokens)** token, **or** a **[granular access token](https://docs.npmjs.com/creating-and-viewing-access-tokens#creating-granular-access-tokens)** with **read and write** on **`@xirconsss/zero-mock`** (and org **`xirconsss`** if npm asks).
134
+ 2. For granular tokens: turn **Bypass two-factor authentication (2FA)** **on** so CI does not hit **`EOTP`**. Do **not** use **`NPM_OTP`** secrets (codes expire in ~30 seconds).
135
+ 3. Paste the token into the **`NPM_TOKEN`** environment secret on GitHub.
136
+
137
+ **Optional — OIDC trusted publishing:** You can later move to [npm trusted publishing](https://docs.npmjs.com/trusted-publishers) and drop the secret; if you see **`E404`** on `PUT` with OIDC, the Trusted Publisher settings on npm (repo, workflow filename, environment name) do not match this workflow—token auth avoids that until it is configured correctly.
138
+
139
+ ## License
140
+
141
+ MIT - see [LICENSE](LICENSE).
142
+
143
+ ## Links
144
+
145
+ - **Repository:** [github.com/xircons/zero-mock](https://github.com/xircons/zero-mock)
146
+ - **Issues:** [github.com/xircons/zero-mock/issues](https://github.com/xircons/zero-mock/issues)
package/dist/index.js CHANGED
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
3
6
  Object.defineProperty(exports, "__esModule", { value: true });
4
- const fs_1 = require("fs");
7
+ const chokidar_1 = __importDefault(require("chokidar"));
5
8
  const commander_1 = require("commander");
6
9
  const jsonStore_1 = require("./store/jsonStore");
7
10
  const bootstrap_1 = require("./server/bootstrap");
@@ -13,33 +16,29 @@ function parseDelayMs(raw) {
13
16
  return Number.parseInt(trimmed, 10);
14
17
  }
15
18
  function startFileWatcher(filePath) {
16
- let reloadInFlight = false;
19
+ let reloadTimeout = null;
17
20
  const reload = async () => {
18
- if (reloadInFlight) {
19
- return;
20
- }
21
- reloadInFlight = true;
22
21
  try {
23
22
  await jsonStore_1.JsonStore.load(filePath);
23
+ console.log(`[watch] Reloaded "${filePath}".`);
24
24
  }
25
25
  catch (err) {
26
26
  const message = err instanceof Error ? err.message : String(err);
27
27
  console.error(`[watch] Could not reload "${filePath}": ${message}`);
28
28
  }
29
- finally {
30
- reloadInFlight = false;
31
- }
32
29
  };
33
- try {
34
- (0, fs_1.watch)(filePath, { persistent: true }, () => {
35
- void reload();
36
- });
37
- console.log(`[watch] Watching "${filePath}" for changes.`);
38
- }
39
- catch (err) {
40
- const message = err instanceof Error ? err.message : String(err);
41
- console.error(`[watch] Failed to watch "${filePath}": ${message}`);
42
- }
30
+ chokidar_1.default
31
+ .watch(filePath, { persistent: true, ignoreInitial: true })
32
+ .on("change", () => {
33
+ if (reloadTimeout)
34
+ clearTimeout(reloadTimeout);
35
+ reloadTimeout = setTimeout(() => void reload(), 100);
36
+ })
37
+ .on("error", (err) => {
38
+ const msg = err instanceof Error ? err.message : String(err);
39
+ console.error(`[watch] Watcher error: ${msg}`);
40
+ });
41
+ console.log(`[watch] Watching "${filePath}" for changes.`);
43
42
  }
44
43
  const program = new commander_1.Command();
45
44
  program
@@ -49,6 +48,9 @@ program
49
48
  .option("-p, --port <number>", "HTTP port", "3000")
50
49
  .option("-d, --delay <ms>", "delay each request by this many ms (0 = off)", "0")
51
50
  .option("-w, --watch", "reload JSON from disk when the file changes", false)
51
+ .option("--cors-origin <origins>", "comma-separated list of allowed origins (e.g., http://localhost:3000)", "*")
52
+ .option("--cors-methods <methods>", "comma-separated list of allowed HTTP methods", "GET,HEAD,PUT,PATCH,POST,DELETE")
53
+ .option("--cors-credentials", "enable CORS credentials (cookies, authorization headers)", false)
52
54
  .action(async (opts) => {
53
55
  const port = Number.parseInt(opts.port, 10);
54
56
  if (Number.isNaN(port) || port < 1 || port > 65535) {
@@ -73,7 +75,12 @@ program
73
75
  return;
74
76
  }
75
77
  try {
76
- await (0, bootstrap_1.bootstrap)(port, { delayMs });
78
+ await (0, bootstrap_1.bootstrap)(port, {
79
+ delayMs,
80
+ corsOrigin: opts.corsOrigin,
81
+ corsMethods: opts.corsMethods,
82
+ corsCredentials: opts.corsCredentials,
83
+ });
77
84
  }
78
85
  catch (err) {
79
86
  const message = err instanceof Error ? err.message : String(err);
@@ -12,7 +12,7 @@ const errorHandler = (err, _req, res, _next) => {
12
12
  return;
13
13
  }
14
14
  const message = err instanceof Error ? err.message : String(err);
15
- res.status(500).json({ error: message });
15
+ res.status(500).json({ error: "INTERNAL_SERVER_ERROR", message, statusCode: 500 });
16
16
  };
17
17
  function requestLoggingMiddleware(req, res, next) {
18
18
  res.on("finish", () => {
@@ -31,7 +31,11 @@ function delayMiddleware(delayMs) {
31
31
  }
32
32
  function createApp(options) {
33
33
  const app = (0, express_1.default)();
34
- app.use((0, cors_1.default)());
34
+ app.use((0, cors_1.default)({
35
+ origin: options.corsOrigin && options.corsOrigin !== "*" ? options.corsOrigin.split(",") : "*",
36
+ methods: options.corsMethods || "GET,HEAD,PUT,PATCH,POST,DELETE",
37
+ credentials: options.corsCredentials || false,
38
+ }));
35
39
  app.use(express_1.default.json());
36
40
  app.use(requestLoggingMiddleware);
37
41
  app.use(delayMiddleware(options.delayMs));
@@ -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,73 @@ function parsePagination(query) {
44
45
  return { page, limit };
45
46
  }
46
47
  function filterCollection(items, query) {
47
- const filterEntries = Object.entries(query).filter(([k]) => k !== "_page" && k !== "_limit");
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 items;
71
+ return result;
50
72
  }
51
- return items.filter((item) => {
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 (!Object.prototype.hasOwnProperty.call(rec, key)) {
62
- return false;
88
+ else if (key.endsWith("_lte")) {
89
+ field = key.slice(0, -4);
90
+ op = "lte";
63
91
  }
64
- if (rec[key] == want) {
65
- continue;
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
+ if (!String(actual).toLowerCase().includes(String(want).toLowerCase()))
113
+ return false;
66
114
  }
67
- return false;
68
115
  }
69
116
  return true;
70
117
  });
@@ -98,6 +145,43 @@ function itemNotFound(res, resource, id) {
98
145
  function badBody(res) {
99
146
  res.status(400).json({ error: "Request body must be a JSON object." });
100
147
  }
148
+ /**
149
+ * Infer a zod schema from the first item in a collection.
150
+ * Returns null if the collection is empty (skip validation).
151
+ * The schema is "partial" so that POST/PATCH with missing optional fields still pass —
152
+ * only fields that ARE present are type-checked.
153
+ */
154
+ function inferSchema(collection) {
155
+ const first = collection.find(isPlainObject);
156
+ if (!first)
157
+ return null;
158
+ const shape = {};
159
+ for (const [key, value] of Object.entries(first)) {
160
+ if (key === "id")
161
+ continue;
162
+ if (typeof value === "string")
163
+ shape[key] = zod_1.z.string();
164
+ else if (typeof value === "number")
165
+ shape[key] = zod_1.z.number();
166
+ else if (typeof value === "boolean")
167
+ shape[key] = zod_1.z.boolean();
168
+ else
169
+ shape[key] = zod_1.z.unknown();
170
+ }
171
+ return zod_1.z.object(shape).partial();
172
+ }
173
+ function validateBody(res, collection, body) {
174
+ const schema = inferSchema(collection);
175
+ if (!schema)
176
+ return true; // empty collection → skip
177
+ const result = schema.safeParse(body);
178
+ if (!result.success) {
179
+ const messages = result.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`);
180
+ res.status(400).json({ error: "Validation failed.", details: messages });
181
+ return false;
182
+ }
183
+ return true;
184
+ }
101
185
  function numericIdValue(id) {
102
186
  if (typeof id === "number" && Number.isFinite(id) && Number.isInteger(id)) {
103
187
  return id;
@@ -164,6 +248,8 @@ function buildDynamicRouter() {
164
248
  return;
165
249
  }
166
250
  const collection = jsonStore_1.JsonStore.getData()[resource];
251
+ if (!validateBody(res, collection, req.body))
252
+ return;
167
253
  const newId = nextIdForCollection(collection);
168
254
  const rawBody = req.body;
169
255
  const rest = { ...rawBody };
@@ -180,6 +266,8 @@ function buildDynamicRouter() {
180
266
  }
181
267
  const { id: idParam } = req.params;
182
268
  const collection = jsonStore_1.JsonStore.getData()[resource];
269
+ if (!validateBody(res, collection, req.body))
270
+ return;
183
271
  const index = findIndexById(collection, idParam);
184
272
  if (index === -1) {
185
273
  itemNotFound(res, resource, idParam);
@@ -202,6 +290,8 @@ function buildDynamicRouter() {
202
290
  }
203
291
  const { id: idParam } = req.params;
204
292
  const collection = jsonStore_1.JsonStore.getData()[resource];
293
+ if (!validateBody(res, collection, req.body))
294
+ return;
205
295
  const index = findIndexById(collection, idParam);
206
296
  if (index === -1) {
207
297
  itemNotFound(res, resource, idParam);
@@ -31,9 +31,22 @@ function parseJsonStorePayload(raw, filePath) {
31
31
  class JsonStoreImpl {
32
32
  data = {};
33
33
  backingPath = null;
34
+ lockPath = null;
34
35
  /** Serializes concurrent save() calls so writes are not interleaved. */
35
36
  saveChain = Promise.resolve();
36
37
  async load(filePath) {
38
+ const lockPath = filePath + ".zero-mock.lock";
39
+ try {
40
+ await (0, promises_1.access)(lockPath);
41
+ console.warn(`[warn] Lock file found: "${lockPath}". ` +
42
+ `Another zero-mock process may be running on this file. ` +
43
+ `If not, delete the lock file and retry.`);
44
+ }
45
+ catch {
46
+ await (0, promises_1.writeFile)(lockPath, String(process.pid), "utf8");
47
+ this.lockPath = lockPath;
48
+ this._registerLockCleanup();
49
+ }
37
50
  let raw;
38
51
  try {
39
52
  raw = await (0, promises_1.readFile)(filePath, "utf8");
@@ -48,6 +61,19 @@ class JsonStoreImpl {
48
61
  this.data = parseJsonStorePayload(raw, filePath);
49
62
  this.backingPath = filePath;
50
63
  }
64
+ _registerLockCleanup() {
65
+ const cleanup = () => {
66
+ if (this.lockPath) {
67
+ try {
68
+ require("fs").unlinkSync(this.lockPath);
69
+ }
70
+ catch (_) { }
71
+ }
72
+ };
73
+ process.once("exit", cleanup);
74
+ process.once("SIGINT", () => { cleanup(); process.exit(130); });
75
+ process.once("SIGTERM", () => { cleanup(); process.exit(143); });
76
+ }
51
77
  getData() {
52
78
  return this.data;
53
79
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xirconsss/zero-mock",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Zero-config CLI that generates REST APIs from JSON files",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -47,9 +47,11 @@
47
47
  },
48
48
  "homepage": "https://github.com/xircons/zero-mock#readme",
49
49
  "dependencies": {
50
+ "chokidar": "^4.0.3",
50
51
  "commander": "^13.1.0",
51
52
  "cors": "^2.8.5",
52
- "express": "^4.21.2"
53
+ "express": "^4.21.2",
54
+ "zod": "^4.4.3"
53
55
  },
54
56
  "devDependencies": {
55
57
  "@commitlint/cli": "^21.0.2",