@xirconsss/zero-mock 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Xircons
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # zero-mock
2
+
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/@xircons/zero-mock.svg)](https://www.npmjs.com/package/@xircons/zero-mock)
6
+
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.
8
+
9
+ ## Installation
10
+
11
+ ### Global
12
+
13
+ ```bash
14
+ npm install -g @xircons/zero-mock
15
+ ```
16
+
17
+ Run the CLI as **`zero-mock`** (see [Usage](#usage)).
18
+
19
+ ### One-off with npx
20
+
21
+ ```bash
22
+ npx @xircons/zero-mock -f ./data.json -p 3000
23
+ ```
24
+
25
+ Flags: **`-f`** / **`--file`** (required JSON path), **`-p`** / **`--port`** (optional, default `3000`). If your shell or npm version forwards extra flags to npm instead of the CLI, insert **`--`** before **`-f`** (e.g. `npx @xircons/zero-mock -- -f ./data.json`).
26
+
27
+ ## Quick start
28
+
29
+ From the repo root (or any directory containing the example file):
30
+
31
+ ```bash
32
+ npx @xircons/zero-mock -f ./example/db.json -p 3000
33
+ ```
34
+
35
+ In another terminal:
36
+
37
+ ```bash
38
+ curl http://localhost:3000/users
39
+ curl http://localhost:3000/users/1
40
+ ```
41
+
42
+ The server logs the exact URLs for each collection when it starts.
43
+
44
+ ## Features
45
+
46
+ - **Zero-config** — one JSON file defines your API surface; no schemas or generators to run.
47
+ - **Full CRUD REST API** — `GET`, `POST`, `PUT`, `PATCH`, and `DELETE` per collection, with CORS and JSON bodies enabled.
48
+ - **Atomic file persistence** — writes go through a temp file and rename, with serialized saves so concurrent requests do not corrupt the file.
49
+ - **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**.
50
+
51
+ ## Auto-generated API
52
+
53
+ 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).
54
+
55
+ | Method | Path | Description |
56
+ | -------- | -------------------- | ----------- |
57
+ | `GET` | `/{resource}` | List all items in the collection. |
58
+ | `POST` | `/{resource}` | Create an item; server assigns `id` and returns `201` with the new body. |
59
+ | `GET` | `/{resource}/{id}` | Return one item by `id`; `404` if missing. |
60
+ | `PUT` | `/{resource}/{id}` | Replace the item; `id` in the URL wins; `404` if missing. |
61
+ | `PATCH` | `/{resource}/{id}` | Shallow-merge fields into the item; `404` if missing. |
62
+ | `DELETE` | `/{resource}/{id}` | Remove the item; `204` on success; `404` if missing. |
63
+
64
+ Invalid JSON bodies (non-objects for write routes) receive **`400`** with a JSON error message. Persistence failures surface as **`500`**.
65
+
66
+ ## JSON file shape
67
+
68
+ 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).
69
+
70
+ ## License
71
+
72
+ MIT — see [LICENSE](LICENSE).
73
+
74
+ ## Links
75
+
76
+ - **Repository:** [github.com/xircons/zero-mock](https://github.com/xircons/zero-mock)
77
+ - **Issues:** [github.com/xircons/zero-mock/issues](https://github.com/xircons/zero-mock/issues)
package/dist/index.js ADDED
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const commander_1 = require("commander");
5
+ const jsonStore_1 = require("./store/jsonStore");
6
+ const bootstrap_1 = require("./server/bootstrap");
7
+ const program = new commander_1.Command();
8
+ program
9
+ .name("zero-mock")
10
+ .description("Generate a REST API from a JSON file")
11
+ .requiredOption("-f, --file <path>", "path to the source JSON file")
12
+ .option("-p, --port <number>", "HTTP port", "3000")
13
+ .action(async (opts) => {
14
+ const port = Number.parseInt(opts.port, 10);
15
+ if (Number.isNaN(port) || port < 1 || port > 65535) {
16
+ console.error("error: --port must be a number between 1 and 65535");
17
+ process.exit(1);
18
+ return;
19
+ }
20
+ const filePath = opts.file;
21
+ try {
22
+ await jsonStore_1.JsonStore.load(filePath);
23
+ }
24
+ catch (err) {
25
+ const message = err instanceof Error ? err.message : String(err);
26
+ console.error(message);
27
+ process.exit(1);
28
+ return;
29
+ }
30
+ try {
31
+ await (0, bootstrap_1.bootstrap)(port);
32
+ }
33
+ catch (err) {
34
+ const message = err instanceof Error ? err.message : String(err);
35
+ console.error(`Failed to start server: ${message}`);
36
+ process.exit(1);
37
+ }
38
+ });
39
+ program.parse(process.argv);
@@ -0,0 +1,24 @@
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.createApp = createApp;
7
+ const cors_1 = __importDefault(require("cors"));
8
+ const express_1 = __importDefault(require("express"));
9
+ const dynamicRouter_1 = require("./routes/dynamicRouter");
10
+ const errorHandler = (err, _req, res, _next) => {
11
+ if (res.headersSent) {
12
+ return;
13
+ }
14
+ const message = err instanceof Error ? err.message : String(err);
15
+ res.status(500).json({ error: message });
16
+ };
17
+ function createApp() {
18
+ const app = (0, express_1.default)();
19
+ app.use((0, cors_1.default)());
20
+ app.use(express_1.default.json());
21
+ app.use("/", (0, dynamicRouter_1.buildDynamicRouter)());
22
+ app.use(errorHandler);
23
+ return app;
24
+ }
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.bootstrap = bootstrap;
4
+ const app_1 = require("./app");
5
+ const jsonStore_1 = require("../store/jsonStore");
6
+ async function bootstrap(port) {
7
+ const app = (0, app_1.createApp)();
8
+ await new Promise((resolve, reject) => {
9
+ const server = app.listen(port, () => {
10
+ resolve();
11
+ });
12
+ server.on("error", reject);
13
+ });
14
+ const base = `http://localhost:${port}`;
15
+ console.log(`🚀 Server is running at ${base}`);
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
+ }
22
+ }
@@ -0,0 +1,171 @@
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.buildDynamicRouter = buildDynamicRouter;
7
+ const crypto_1 = require("crypto");
8
+ const express_1 = __importDefault(require("express"));
9
+ const jsonStore_1 = require("../../store/jsonStore");
10
+ function isPlainObject(value) {
11
+ return typeof value === "object" && value !== null && !Array.isArray(value);
12
+ }
13
+ function isRecordWithId(item) {
14
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
15
+ return false;
16
+ }
17
+ const rec = item;
18
+ return Object.prototype.hasOwnProperty.call(rec, "id");
19
+ }
20
+ function findItemById(items, idParam) {
21
+ return items.find((item) => {
22
+ if (!isRecordWithId(item)) {
23
+ return false;
24
+ }
25
+ return item.id == idParam;
26
+ });
27
+ }
28
+ function findIndexById(items, idParam) {
29
+ return items.findIndex((item) => {
30
+ if (!isRecordWithId(item)) {
31
+ return false;
32
+ }
33
+ return item.id == idParam;
34
+ });
35
+ }
36
+ function itemNotFound(res, resource, id) {
37
+ res.status(404).json({ error: `No item with id "${id}" in "${resource}".` });
38
+ }
39
+ function badBody(res) {
40
+ res.status(400).json({ error: "Request body must be a JSON object." });
41
+ }
42
+ function numericIdValue(id) {
43
+ if (typeof id === "number" && Number.isFinite(id) && Number.isInteger(id)) {
44
+ return id;
45
+ }
46
+ if (typeof id === "string" && /^\d+$/.test(id)) {
47
+ const n = Number.parseInt(id, 10);
48
+ return Number.isFinite(n) ? n : null;
49
+ }
50
+ return null;
51
+ }
52
+ /**
53
+ * Next id: max numeric id + 1 among items with integer or all-digit string ids;
54
+ * otherwise a new UUID string.
55
+ */
56
+ function nextIdForCollection(items) {
57
+ let max = Number.NEGATIVE_INFINITY;
58
+ for (const item of items) {
59
+ if (!isRecordWithId(item)) {
60
+ continue;
61
+ }
62
+ const n = numericIdValue(item.id);
63
+ if (n !== null && n > max) {
64
+ max = n;
65
+ }
66
+ }
67
+ if (max !== Number.NEGATIVE_INFINITY) {
68
+ return max + 1;
69
+ }
70
+ return (0, crypto_1.randomUUID)();
71
+ }
72
+ function asyncHandler(fn) {
73
+ return (req, res, next) => {
74
+ void fn(req, res, next).catch(next);
75
+ };
76
+ }
77
+ function buildDynamicRouter() {
78
+ const router = express_1.default.Router();
79
+ const resources = Object.keys(jsonStore_1.JsonStore.getData());
80
+ for (const resource of resources) {
81
+ router.get(`/${resource}/:id`, asyncHandler(async (req, res) => {
82
+ const { id } = req.params;
83
+ const collection = jsonStore_1.JsonStore.getData()[resource];
84
+ const item = findItemById(collection, id);
85
+ if (item === undefined) {
86
+ itemNotFound(res, resource, id);
87
+ return;
88
+ }
89
+ res.json(item);
90
+ }));
91
+ router.get(`/${resource}`, (_req, res) => {
92
+ res.json(jsonStore_1.JsonStore.getData()[resource]);
93
+ });
94
+ router.post(`/${resource}`, asyncHandler(async (req, res) => {
95
+ if (!isPlainObject(req.body)) {
96
+ badBody(res);
97
+ return;
98
+ }
99
+ const collection = jsonStore_1.JsonStore.getData()[resource];
100
+ const newId = nextIdForCollection(collection);
101
+ const rawBody = req.body;
102
+ const rest = { ...rawBody };
103
+ delete rest["id"];
104
+ const newItem = { ...rest, id: newId };
105
+ collection.push(newItem);
106
+ await jsonStore_1.JsonStore.save();
107
+ res.status(201).json(newItem);
108
+ }));
109
+ router.put(`/${resource}/:id`, asyncHandler(async (req, res) => {
110
+ if (!isPlainObject(req.body)) {
111
+ badBody(res);
112
+ return;
113
+ }
114
+ const { id: idParam } = req.params;
115
+ const collection = jsonStore_1.JsonStore.getData()[resource];
116
+ const index = findIndexById(collection, idParam);
117
+ if (index === -1) {
118
+ itemNotFound(res, resource, idParam);
119
+ return;
120
+ }
121
+ const previous = collection[index];
122
+ const preservedId = isRecordWithId(previous) ? previous.id : idParam;
123
+ const rawBody = req.body;
124
+ const rest = { ...rawBody };
125
+ delete rest["id"];
126
+ const updated = { ...rest, id: preservedId };
127
+ collection[index] = updated;
128
+ await jsonStore_1.JsonStore.save();
129
+ res.json(updated);
130
+ }));
131
+ router.patch(`/${resource}/:id`, asyncHandler(async (req, res) => {
132
+ if (!isPlainObject(req.body)) {
133
+ badBody(res);
134
+ return;
135
+ }
136
+ const { id: idParam } = req.params;
137
+ const collection = jsonStore_1.JsonStore.getData()[resource];
138
+ const index = findIndexById(collection, idParam);
139
+ if (index === -1) {
140
+ itemNotFound(res, resource, idParam);
141
+ return;
142
+ }
143
+ const current = collection[index];
144
+ if (!isPlainObject(current)) {
145
+ res.status(400).json({ error: "Existing item must be an object to PATCH." });
146
+ return;
147
+ }
148
+ const preservedId = isRecordWithId(current) ? current.id : idParam;
149
+ const rawBody = req.body;
150
+ const patch = { ...rawBody };
151
+ delete patch["id"];
152
+ const updated = { ...current, ...patch, id: preservedId };
153
+ collection[index] = updated;
154
+ await jsonStore_1.JsonStore.save();
155
+ res.json(updated);
156
+ }));
157
+ router.delete(`/${resource}/:id`, asyncHandler(async (req, res) => {
158
+ const { id: idParam } = req.params;
159
+ const collection = jsonStore_1.JsonStore.getData()[resource];
160
+ const index = findIndexById(collection, idParam);
161
+ if (index === -1) {
162
+ itemNotFound(res, resource, idParam);
163
+ return;
164
+ }
165
+ collection.splice(index, 1);
166
+ await jsonStore_1.JsonStore.save();
167
+ res.status(204).send();
168
+ }));
169
+ }
170
+ return router;
171
+ }
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.JsonStore = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const path_1 = require("path");
6
+ const promises_1 = require("fs/promises");
7
+ function isPlainObject(value) {
8
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9
+ }
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
+ class JsonStoreImpl {
32
+ data = {};
33
+ backingPath = null;
34
+ /** Serializes concurrent save() calls so writes are not interleaved. */
35
+ saveChain = Promise.resolve();
36
+ async load(filePath) {
37
+ let raw;
38
+ try {
39
+ raw = await (0, promises_1.readFile)(filePath, "utf8");
40
+ }
41
+ catch (err) {
42
+ const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
43
+ const message = err instanceof Error ? err.message : String(err);
44
+ throw new Error(code
45
+ ? `Could not read JSON file "${filePath}" (${code}): ${message}`
46
+ : `Could not read JSON file "${filePath}": ${message}`);
47
+ }
48
+ this.data = parseJsonStorePayload(raw, filePath);
49
+ this.backingPath = filePath;
50
+ }
51
+ getData() {
52
+ return this.data;
53
+ }
54
+ /**
55
+ * Writes the current in-memory state to the backing file using a temp file + rename
56
+ * for an atomic-style replace on POSIX; serializes overlapping saves.
57
+ */
58
+ async save() {
59
+ if (this.backingPath === null) {
60
+ throw new Error("JsonStore.save() called before load(); no file path is set.");
61
+ }
62
+ const run = async () => {
63
+ const target = this.backingPath;
64
+ const dir = (0, path_1.dirname)(target);
65
+ const tmp = (0, path_1.join)(dir, `.zero-mock-${(0, crypto_1.randomBytes)(8).toString("hex")}.tmp`);
66
+ const payload = `${JSON.stringify(this.data, null, 2)}\n`;
67
+ try {
68
+ await (0, promises_1.writeFile)(tmp, payload, "utf8");
69
+ await (0, promises_1.rename)(tmp, target);
70
+ }
71
+ catch (err) {
72
+ await (0, promises_1.unlink)(tmp).catch(() => { });
73
+ const message = err instanceof Error ? err.message : String(err);
74
+ throw new Error(`Could not write JSON file "${target}": ${message}`);
75
+ }
76
+ };
77
+ const next = this.saveChain.then(run, run);
78
+ this.saveChain = next.catch(() => { });
79
+ await next;
80
+ }
81
+ }
82
+ /** Singleton store for the backing JSON file (system of record). */
83
+ exports.JsonStore = new JsonStoreImpl();
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,45 @@
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
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@xirconsss/zero-mock",
3
+ "version": "0.1.0",
4
+ "description": "Zero-config CLI that generates REST APIs from JSON files",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "mock",
8
+ "api",
9
+ "server",
10
+ "cli",
11
+ "rest",
12
+ "json-database",
13
+ "frontend-tool",
14
+ "express",
15
+ "zero-config",
16
+ "json"
17
+ ],
18
+ "main": "dist/index.js",
19
+ "files": [
20
+ "dist",
21
+ "README.md",
22
+ "LICENSE",
23
+ "example"
24
+ ],
25
+ "bin": {
26
+ "zero-mock": "dist/index.js"
27
+ },
28
+ "scripts": {
29
+ "build": "tsc",
30
+ "start": "node dist/index.js",
31
+ "dev": "ts-node src/index.ts",
32
+ "prepublishOnly": "npm run build"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/xircons/zero-mock.git"
40
+ },
41
+ "bugs": {
42
+ "url": "https://github.com/xircons/zero-mock/issues"
43
+ },
44
+ "dependencies": {
45
+ "commander": "^13.1.0",
46
+ "cors": "^2.8.5",
47
+ "express": "^4.21.2"
48
+ },
49
+ "devDependencies": {
50
+ "@types/cors": "^2.8.17",
51
+ "@types/express": "^4.17.21",
52
+ "@types/node": "^22.14.0",
53
+ "ts-node": "^10.9.2",
54
+ "typescript": "^5.8.2"
55
+ }
56
+ }