diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a303155 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,26 @@ +# 1.0.0 (2026-06-12) + + +### Bug Fixes + +* **ci:** bump to 0.1.1, simplify npm publish, refresh Actions ([56dbd71](https://github.com/xircons/zero-mock/commit/56dbd71568cfe9a495482c82235c9e5f80589bbe)) +* force publish to npm after 2fa issue ([20dd17a](https://github.com/xircons/zero-mock/commit/20dd17a15eb1d133ca8269cf468ea4b8979e4500)) + + +### Features + +* add chokidar file watcher with debounce for live reload ([e38b7c1](https://github.com/xircons/zero-mock/commit/e38b7c1659d123efd79be430bfed1260ac2d2a82)) +* delay, logging, list filters/pagination, watch mode; docs: demo GIF + README ([7a53693](https://github.com/xircons/zero-mock/commit/7a536930abc7b0491d25e2c4ce18e3b3b441a6de)) + +# 1.0.0 (2026-06-12) + + +### Bug Fixes + +* **ci:** bump to 0.1.1, simplify npm publish, refresh Actions ([56dbd71](https://github.com/xircons/zero-mock/commit/56dbd71568cfe9a495482c82235c9e5f80589bbe)) + + +### Features + +* add chokidar file watcher with debounce for live reload ([e38b7c1](https://github.com/xircons/zero-mock/commit/e38b7c1659d123efd79be430bfed1260ac2d2a82)) +* delay, logging, list filters/pagination, watch mode; docs: demo GIF + README ([7a53693](https://github.com/xircons/zero-mock/commit/7a536930abc7b0491d25e2c4ce18e3b3b441a6de)) diff --git a/README.md b/README.md index 04c8e7d..21f4a1c 100644 --- a/README.md +++ b/README.md @@ -122,11 +122,11 @@ The root must be a JSON **object**. Each property must be an **array** (your “ ## Publishing to npm (maintainers) -**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”. +**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. 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). 2. **GitHub:** repo → **Settings** → **Environments** → **`NPM_TOKEN`** → add secret **`NPM_TOKEN`** (see token steps below). The workflow uses that environment on each run. -3. Bump **`version`**, push to **`main`**, wait for **Publish to npm** to finish. Check with `npm view @xirconsss/zero-mock version`. +3. Push conventional commits to **`main`**, wait for **Publish to npm** to finish. Check with `npm view @xirconsss/zero-mock version`. **Token on npm (required for CI):** diff --git a/package-lock.json b/package-lock.json index 90300dd..e2006c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,19 @@ { "name": "@xirconsss/zero-mock", - "version": "0.2.3", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@xirconsss/zero-mock", - "version": "0.2.3", + "version": "1.0.0", "license": "MIT", "dependencies": { + "chokidar": "^4.0.3", "commander": "^13.1.0", "cors": "^2.8.5", - "express": "^4.21.2" + "express": "^4.21.2", + "zod": "^4.4.3" }, "bin": { "zero-mock": "dist/index.js" @@ -3086,6 +3088,21 @@ "node": ">= 16" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -8835,6 +8852,19 @@ "dev": true, "license": "MIT" }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/registry-auth-token": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.1.tgz", @@ -8955,6 +8985,7 @@ "integrity": "sha512-mn61SUJwtM8ThrWn2WmgLVpwVJeG/hPSupua1psdMoufmwRIPyvRLkRkL0JDXkP67OntlLWUYnBnfVc8EDO3/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -12275,6 +12306,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -12728,6 +12760,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index fd426ec..3ee06ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xirconsss/zero-mock", - "version": "0.2.3", + "version": "1.0.0", "description": "Zero-config CLI that generates REST APIs from JSON files", "license": "MIT", "keywords": [ @@ -47,9 +47,11 @@ }, "homepage": "https://github.com/xircons/zero-mock#readme", "dependencies": { + "chokidar": "^4.0.3", "commander": "^13.1.0", "cors": "^2.8.5", - "express": "^4.21.2" + "express": "^4.21.2", + "zod": "^4.4.3" }, "devDependencies": { "@commitlint/cli": "^21.0.2", diff --git a/src/index.ts b/src/index.ts index cd2a23b..4a21c96 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { watch } from "fs"; +import chokidar from "chokidar"; import { Command } from "commander"; import { JsonStore } from "./store/jsonStore"; import { bootstrap } from "./server/bootstrap"; @@ -9,6 +9,9 @@ type CliOpts = { port: string; delay: string; watch: boolean; + corsOrigin?: string; + corsMethods?: string; + corsCredentials?: boolean; }; function parseDelayMs(raw: string): number | null { @@ -20,31 +23,30 @@ function parseDelayMs(raw: string): number | null { } function startFileWatcher(filePath: string): void { - let reloadInFlight = false; + let reloadTimeout: ReturnType | null = null; + const reload = async (): Promise => { - if (reloadInFlight) { - return; - } - reloadInFlight = true; try { await JsonStore.load(filePath); + console.log(`[watch] Reloaded "${filePath}".`); } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error(`[watch] Could not reload "${filePath}": ${message}`); - } finally { - reloadInFlight = false; } }; - try { - watch(filePath, { persistent: true }, () => { - void reload(); + chokidar + .watch(filePath, { persistent: true, ignoreInitial: true }) + .on("change", () => { + if (reloadTimeout) clearTimeout(reloadTimeout); + reloadTimeout = setTimeout(() => void reload(), 100); + }) + .on("error", (err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[watch] Watcher error: ${msg}`); }); - console.log(`[watch] Watching "${filePath}" for changes.`); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.error(`[watch] Failed to watch "${filePath}": ${message}`); - } + + console.log(`[watch] Watching "${filePath}" for changes.`); } const program = new Command(); @@ -56,6 +58,9 @@ program .option("-p, --port ", "HTTP port", "3000") .option("-d, --delay ", "delay each request by this many ms (0 = off)", "0") .option("-w, --watch", "reload JSON from disk when the file changes", false) + .option("--cors-origin ", "comma-separated list of allowed origins (e.g., http://localhost:3000)", "*") + .option("--cors-methods ", "comma-separated list of allowed HTTP methods", "GET,HEAD,PUT,PATCH,POST,DELETE") + .option("--cors-credentials", "enable CORS credentials (cookies, authorization headers)", false) .action(async (opts: CliOpts) => { const port = Number.parseInt(opts.port, 10); if (Number.isNaN(port) || port < 1 || port > 65535) { @@ -83,7 +88,12 @@ program } try { - await bootstrap(port, { delayMs }); + await bootstrap(port, { + delayMs, + corsOrigin: opts.corsOrigin, + corsMethods: opts.corsMethods, + corsCredentials: opts.corsCredentials, + }); } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error(`Failed to start server: ${message}`); @@ -96,4 +106,4 @@ program } }); -program.parse(process.argv); +program.parse(process.argv); \ No newline at end of file diff --git a/src/server/app.ts b/src/server/app.ts index d38d55c..52cd7c9 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -8,11 +8,14 @@ const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => { return; } const message = err instanceof Error ? err.message : String(err); - res.status(500).json({ error: message }); + res.status(500).json({ error: "INTERNAL_SERVER_ERROR", message, statusCode: 500 }); }; export type CreateAppOptions = { delayMs: number; + corsOrigin?: string; + corsMethods?: string; + corsCredentials?: boolean; }; function requestLoggingMiddleware(req: Request, res: Response, next: NextFunction): void { @@ -34,7 +37,13 @@ function delayMiddleware(delayMs: number) { export function createApp(options: CreateAppOptions): Application { const app = express(); - app.use(cors()); + + app.use(cors({ + origin: options.corsOrigin && options.corsOrigin !== "*" ? options.corsOrigin.split(",") : "*", + methods: options.corsMethods || "GET,HEAD,PUT,PATCH,POST,DELETE", + credentials: options.corsCredentials || false, + })); + app.use(express.json()); app.use(requestLoggingMiddleware); app.use(delayMiddleware(options.delayMs)); diff --git a/src/server/routes/dynamicRouter.ts b/src/server/routes/dynamicRouter.ts index 474e577..0f63fbe 100644 --- a/src/server/routes/dynamicRouter.ts +++ b/src/server/routes/dynamicRouter.ts @@ -1,6 +1,7 @@ import { randomUUID } from "crypto"; import type { NextFunction, Request, RequestHandler, Response, Router } from "express"; import express from "express"; +import { z } from "zod"; import { JsonStore } from "../../store/jsonStore"; function isPlainObject(value: unknown): value is Record { @@ -43,27 +44,58 @@ function parsePagination(query: Record): { page: number; limit: } function filterCollection(items: unknown[], query: Record): unknown[] { - const filterEntries = Object.entries(query).filter(([k]) => k !== "_page" && k !== "_limit"); + let result = items; + + // Sorting + const sort = firstQueryValue(query["_sort"]); + const order = firstQueryValue(query["_order"])?.toLowerCase() === "desc" ? -1 : 1; + + if (sort) { + result = [...result].sort((a, b) => { + if (!isPlainObject(a) || !isPlainObject(b)) return 0; + const valA = a[sort]; + const valB = b[sort]; + if (valA === valB) return 0; + if (valA === undefined) return order; + if (valB === undefined) return -order; + if (typeof valA === "number" && typeof valB === "number") return (valA - valB) * order; + return String(valA).localeCompare(String(valB)) * order; + }); + } + + const filterEntries = Object.entries(query).filter(([k]) => !["_page", "_limit", "_sort", "_order"].includes(k)); if (filterEntries.length === 0) { - return items; + return result; } - return items.filter((item) => { + + return result.filter((item) => { if (!isPlainObject(item)) { return false; } const rec = item; for (const [key, qVal] of filterEntries) { const want = firstQueryValue(qVal); - if (want === undefined) { - continue; - } - if (!Object.prototype.hasOwnProperty.call(rec, key)) { - return false; - } - if (rec[key] == want) { - continue; + if (want === undefined) continue; + + let field = key; + let op = "eq"; + + if (key.endsWith("_gte")) { field = key.slice(0, -4); op = "gte"; } + else if (key.endsWith("_lte")) { field = key.slice(0, -4); op = "lte"; } + else if (key.endsWith("_like")) { field = key.slice(0, -5); op = "like"; } + + const actual = rec[field]; + + if (op === "eq") { + if (actual != want) return false; + } else if (op === "gte") { + if (Number(actual) < Number(want)) return false; + } else if (op === "lte") { + if (Number(actual) > Number(want)) return false; + } else if (op === "like") { + if (actual === undefined || actual === null) return false; + if (!String(actual).toLowerCase().includes(String(want).toLowerCase())) return false; } - return false; } return true; }); @@ -103,6 +135,38 @@ function badBody(res: Response): void { res.status(400).json({ error: "Request body must be a JSON object." }); } +/** + * Infer a zod schema from the first item in a collection. + * Returns null if the collection is empty (skip validation). + * The schema is "partial" so that POST/PATCH with missing optional fields still pass — + * only fields that ARE present are type-checked. + */ +function inferSchema(collection: unknown[]): z.ZodTypeAny | null { + const first = collection.find(isPlainObject); + if (!first) return null; + const shape: Record = {}; + for (const [key, value] of Object.entries(first)) { + if (key === "id") continue; + if (typeof value === "string") shape[key] = z.string(); + else if (typeof value === "number") shape[key] = z.number(); + else if (typeof value === "boolean") shape[key] = z.boolean(); + else shape[key] = z.unknown(); + } + return z.object(shape).partial(); +} + +function validateBody(res: Response, collection: unknown[], body: unknown): boolean { + const schema = inferSchema(collection); + if (!schema) return true; // empty collection → skip + const result = schema.safeParse(body); + if (!result.success) { + const messages = result.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`); + res.status(400).json({ error: "Validation failed.", details: messages }); + return false; + } + return true; +} + function numericIdValue(id: unknown): number | null { if (typeof id === "number" && Number.isFinite(id) && Number.isInteger(id)) { return id; @@ -182,6 +246,7 @@ export function buildDynamicRouter(): Router { return; } const collection = JsonStore.getData()[resource]; + if (!validateBody(res, collection, req.body)) return; const newId = nextIdForCollection(collection); const rawBody = req.body as Record; const rest: Record = { ...rawBody }; @@ -202,6 +267,7 @@ export function buildDynamicRouter(): Router { } const { id: idParam } = req.params; const collection = JsonStore.getData()[resource]; + if (!validateBody(res, collection, req.body)) return; const index = findIndexById(collection, idParam); if (index === -1) { itemNotFound(res, resource, idParam); @@ -228,6 +294,7 @@ export function buildDynamicRouter(): Router { } const { id: idParam } = req.params; const collection = JsonStore.getData()[resource]; + if (!validateBody(res, collection, req.body)) return; const index = findIndexById(collection, idParam); if (index === -1) { itemNotFound(res, resource, idParam); @@ -267,4 +334,4 @@ export function buildDynamicRouter(): Router { } return router; -} +} \ No newline at end of file diff --git a/src/store/jsonStore.ts b/src/store/jsonStore.ts index bc81c54..7d23561 100644 --- a/src/store/jsonStore.ts +++ b/src/store/jsonStore.ts @@ -1,6 +1,6 @@ import { randomBytes } from "crypto"; import { dirname, join } from "path"; -import { readFile, rename, unlink, writeFile } from "fs/promises"; +import { access, readFile, rename, unlink, writeFile } from "fs/promises"; import type { JsonData } from "../types"; function isPlainObject(value: unknown): value is Record { @@ -37,10 +37,25 @@ function parseJsonStorePayload(raw: string, filePath: string): JsonData { class JsonStoreImpl { private data: JsonData = {}; private backingPath: string | null = null; + private lockPath: string | null = null; /** Serializes concurrent save() calls so writes are not interleaved. */ private saveChain: Promise = Promise.resolve(); async load(filePath: string): Promise { + const lockPath = filePath + ".zero-mock.lock"; + try { + await access(lockPath); + console.warn( + `[warn] Lock file found: "${lockPath}". ` + + `Another zero-mock process may be running on this file. ` + + `If not, delete the lock file and retry.`, + ); + } catch { + await writeFile(lockPath, String(process.pid), "utf8"); + this.lockPath = lockPath; + this._registerLockCleanup(); + } + let raw: string; try { raw = await readFile(filePath, "utf8"); @@ -58,6 +73,19 @@ class JsonStoreImpl { this.backingPath = filePath; } + private _registerLockCleanup(): void { + const cleanup = (): void => { + if (this.lockPath) { + try { + require("fs").unlinkSync(this.lockPath); + } catch (_) {} + } + }; + process.once("exit", cleanup); + process.once("SIGINT", () => { cleanup(); process.exit(130); }); + process.once("SIGTERM", () => { cleanup(); process.exit(143); }); + } + getData(): JsonData { return this.data; } @@ -93,4 +121,4 @@ class JsonStoreImpl { } /** Singleton store for the backing JSON file (system of record). */ -export const JsonStore = new JsonStoreImpl(); +export const JsonStore = new JsonStoreImpl(); \ No newline at end of file