@xirconsss/zero-mock 0.2.3 → 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
@@ -122,11 +122,11 @@ The root must be a JSON **object**. Each property must be an **array** (your “
122
122
 
123
123
  ## Publishing to npm (maintainers)
124
124
 
125
- **Release flow (recommended):** bump **`version`** in `package.json` and `package-lock.json`, commit, and **push to `main`**. [`.github/workflows/publish-npm.yml`](.github/workflows/publish-npm.yml) runs automatically, builds, and runs **`npm publish`**. If that version is already on the registry, the job skips publish and succeeds with a notice (no E403). You can still trigger a run manually from the **Actions** tab (**workflow_dispatch**). Avoid **`npm publish` on your machine** for the same version CI will publish, or you will block CI with “already published”.
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
126
 
127
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
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. Bump **`version`**, push to **`main`**, wait for **Publish to npm** to finish. Check with `npm view @xirconsss/zero-mock version`.
129
+ 3. Push conventional commits to **`main`**, wait for **Publish to npm** to finish. Check with `npm view @xirconsss/zero-mock version`.
130
130
 
131
131
  **Token on npm (required for CI):**
132
132
 
@@ -143,4 +143,4 @@ MIT - see [LICENSE](LICENSE).
143
143
  ## Links
144
144
 
145
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)
146
+ - **Issues:** [github.com/xircons/zero-mock/issues](https://github.com/xircons/zero-mock/issues)
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const vitest_1 = require("vitest");
4
+ (0, vitest_1.describe)('Environment Check', () => {
5
+ (0, vitest_1.it)('should pass a basic truthy test', () => {
6
+ (0, vitest_1.expect)(true).toBe(true);
7
+ });
8
+ });
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": "0.2.3",
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": [
@@ -29,10 +29,14 @@
29
29
  "build": "tsc",
30
30
  "start": "node dist/index.js",
31
31
  "dev": "ts-node src/index.ts",
32
- "prepublishOnly": "npm run build"
32
+ "test": "vitest run",
33
+ "test:coverage": "vitest run --coverage",
34
+ "prepublishOnly": "npm run build",
35
+ "prepare": "husky"
33
36
  },
34
37
  "publishConfig": {
35
- "access": "public"
38
+ "access": "public",
39
+ "provenance": true
36
40
  },
37
41
  "repository": {
38
42
  "type": "git",
@@ -43,15 +47,29 @@
43
47
  },
44
48
  "homepage": "https://github.com/xircons/zero-mock#readme",
45
49
  "dependencies": {
50
+ "chokidar": "^4.0.3",
46
51
  "commander": "^13.1.0",
47
52
  "cors": "^2.8.5",
48
- "express": "^4.21.2"
53
+ "express": "^4.21.2",
54
+ "zod": "^4.4.3"
49
55
  },
50
56
  "devDependencies": {
57
+ "@commitlint/cli": "^21.0.2",
58
+ "@commitlint/config-conventional": "^21.0.2",
59
+ "@semantic-release/changelog": "^6.0.3",
60
+ "@semantic-release/commit-analyzer": "^13.0.1",
61
+ "@semantic-release/git": "^10.0.1",
62
+ "@semantic-release/github": "^12.0.8",
63
+ "@semantic-release/npm": "^12.0.2",
64
+ "@semantic-release/release-notes-generator": "^14.1.1",
51
65
  "@types/cors": "^2.8.17",
52
66
  "@types/express": "^4.17.21",
53
67
  "@types/node": "^22.14.0",
68
+ "@vitest/coverage-v8": "^2.1.9",
69
+ "husky": "^9.1.7",
70
+ "semantic-release": "^25.0.5",
54
71
  "ts-node": "^10.9.2",
55
- "typescript": "^5.8.2"
72
+ "typescript": "^5.8.2",
73
+ "vitest": "^2.1.9"
56
74
  }
57
75
  }