@yrest/cli 0.11.1 → 0.12.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 +170 -24
- package/dist/cli/index.js +230 -16
- package/dist/cli/index.mjs +230 -16
- package/dist/index.d.mts +110 -3
- package/dist/index.d.ts +110 -3
- package/dist/index.js +243 -11
- package/dist/index.mjs +243 -11
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
[](https://github.com/aggiovato/yRest/actions)
|
|
11
11
|
[](https://www.npmjs.com/package/@yrest/cli)
|
|
12
12
|
[](https://www.typescriptlang.org/)
|
|
13
|
-
[](https://socket.dev/npm/package/@yrest/cli)
|
|
14
14
|
|
|
15
15
|
YAML-powered json-server alternative. Zero-config REST API mock server with full CRUD, relations, filters and snapshots from a `db.yml` file.
|
|
16
16
|
|
|
@@ -75,6 +75,7 @@ A YAML-first alternative to json-server for frontend development.
|
|
|
75
75
|
| OpenAPI 3.0 spec (`GET /_openapi`, `yrest openapi`) | ✅ | ❌ |
|
|
76
76
|
| Field annotations (`_schema`) | ✅ | ❌ |
|
|
77
77
|
| Compact relation DSL (`m2o:target@fk[card]+nested`) | ✅ | ❌ |
|
|
78
|
+
| SSE stream routes (`_method: SSE`) | ✅ | ❌ |
|
|
78
79
|
|
|
79
80
|
---
|
|
80
81
|
|
|
@@ -241,26 +242,59 @@ posts:
|
|
|
241
242
|
|
|
242
243
|
Each top-level key becomes a resource with full CRUD endpoints.
|
|
243
244
|
|
|
245
|
+
Alternatively, collections can be grouped under a `_data` block:
|
|
246
|
+
|
|
247
|
+
```yaml
|
|
248
|
+
_data:
|
|
249
|
+
users:
|
|
250
|
+
- id: 1
|
|
251
|
+
name: Ana
|
|
252
|
+
posts:
|
|
253
|
+
- id: 1
|
|
254
|
+
title: First post
|
|
255
|
+
userId: 1
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Both formats are equivalent. You can also mix them — if the same collection name appears both inside `_data` and at the root level, the root-level entry wins. The file is persisted in whichever format it was originally written.
|
|
259
|
+
|
|
244
260
|
---
|
|
245
261
|
|
|
246
262
|
## Field annotations (`_schema`)
|
|
247
263
|
|
|
248
264
|
Add a `_schema` block to `db.yml` to declare field-level metadata per collection. Used by the OpenAPI generator to produce accurate schemas — has no effect on runtime CRUD behavior.
|
|
249
265
|
|
|
266
|
+
All keys inside `_schema` field objects use the `_` prefix convention. Two shorthand strings are also accepted at the field level:
|
|
267
|
+
|
|
250
268
|
```yaml
|
|
251
269
|
_schema:
|
|
252
270
|
users:
|
|
253
|
-
name: required # shorthand
|
|
271
|
+
name: required # shorthand → { _required: true }
|
|
254
272
|
email:
|
|
255
|
-
|
|
256
|
-
|
|
273
|
+
_required: true
|
|
274
|
+
_type: string
|
|
275
|
+
_format: email
|
|
276
|
+
_minLength: 5
|
|
277
|
+
_maxLength: 100
|
|
257
278
|
age:
|
|
258
|
-
|
|
279
|
+
_type: integer
|
|
280
|
+
_minimum: 0
|
|
281
|
+
_maximum: 150
|
|
282
|
+
_nullable: true
|
|
259
283
|
role:
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
284
|
+
_type: string
|
|
285
|
+
_enum: [admin, editor, viewer]
|
|
286
|
+
_default: viewer
|
|
287
|
+
_description: User role
|
|
288
|
+
tags:
|
|
289
|
+
_type: array
|
|
290
|
+
_items:
|
|
291
|
+
_type: string
|
|
292
|
+
_minItems: 1
|
|
293
|
+
_uniqueItems: true
|
|
294
|
+
createdAt:
|
|
295
|
+
_type: string
|
|
296
|
+
_format: date-time
|
|
297
|
+
_readOnly: true
|
|
264
298
|
|
|
265
299
|
users:
|
|
266
300
|
- id: 1
|
|
@@ -270,14 +304,53 @@ users:
|
|
|
270
304
|
role: admin
|
|
271
305
|
```
|
|
272
306
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
|
276
|
-
|
|
|
277
|
-
| `
|
|
278
|
-
| `
|
|
279
|
-
| `
|
|
280
|
-
| `
|
|
307
|
+
**Core**
|
|
308
|
+
|
|
309
|
+
| Key | Type | Description |
|
|
310
|
+
| -------------- | --------- | ----------------------------------------------------------------------------------------- |
|
|
311
|
+
| `_required` | `boolean` | Marks the field as required in the OpenAPI schema |
|
|
312
|
+
| `_type` | `string` | Overrides the inferred type (`string`, `integer`, `number`, `boolean`, `array`, `object`) |
|
|
313
|
+
| `_format` | `string` | OpenAPI format hint (`email`, `date`, `date-time`, `uuid`, `uri`, …) |
|
|
314
|
+
| `_enum` | `array` | Restricts the field to a fixed set of values |
|
|
315
|
+
| `_description` | `string` | Field description included in the OpenAPI schema |
|
|
316
|
+
| `_default` | `any` | Default value shown in the OpenAPI schema |
|
|
317
|
+
| `_example` | `any` | Example value shown in the OpenAPI schema |
|
|
318
|
+
| `_nullable` | `boolean` | Allows the field to be `null` |
|
|
319
|
+
|
|
320
|
+
**String constraints**
|
|
321
|
+
|
|
322
|
+
| Key | Type | Description |
|
|
323
|
+
| ------------ | -------- | ------------------------------------ |
|
|
324
|
+
| `_minLength` | `number` | Minimum string length |
|
|
325
|
+
| `_maxLength` | `number` | Maximum string length |
|
|
326
|
+
| `_pattern` | `string` | Regex pattern the value must satisfy |
|
|
327
|
+
|
|
328
|
+
**Number / integer constraints**
|
|
329
|
+
|
|
330
|
+
| Key | Type | Description |
|
|
331
|
+
| ------------------- | -------- | ----------------------------- |
|
|
332
|
+
| `_minimum` | `number` | Inclusive lower bound |
|
|
333
|
+
| `_maximum` | `number` | Inclusive upper bound |
|
|
334
|
+
| `_exclusiveMinimum` | `number` | Exclusive lower bound |
|
|
335
|
+
| `_exclusiveMaximum` | `number` | Exclusive upper bound |
|
|
336
|
+
| `_multipleOf` | `number` | Value must be a multiple of N |
|
|
337
|
+
|
|
338
|
+
**Array constraints**
|
|
339
|
+
|
|
340
|
+
| Key | Type | Description |
|
|
341
|
+
| -------------- | --------- | ------------------------------------------------------------ |
|
|
342
|
+
| `_minItems` | `number` | Minimum number of array items |
|
|
343
|
+
| `_maxItems` | `number` | Maximum number of array items |
|
|
344
|
+
| `_uniqueItems` | `boolean` | All items must be unique |
|
|
345
|
+
| `_items` | `object` | Schema for array items — accepts `_type`, `_format`, `_enum` |
|
|
346
|
+
|
|
347
|
+
**OpenAPI meta**
|
|
348
|
+
|
|
349
|
+
| Key | Type | Description |
|
|
350
|
+
| ------------- | --------- | --------------------------------------- |
|
|
351
|
+
| `_deprecated` | `boolean` | Marks the field as deprecated |
|
|
352
|
+
| `_readOnly` | `boolean` | Field is read-only (excluded from POST) |
|
|
353
|
+
| `_writeOnly` | `boolean` | Field is write-only (excluded from GET) |
|
|
281
354
|
|
|
282
355
|
Fields not listed in `_schema` are inferred from the first items in the collection and treated as optional. `_schema` itself is excluded from the generated REST resources.
|
|
283
356
|
|
|
@@ -722,14 +795,14 @@ when:
|
|
|
722
795
|
- body.role: superadmin
|
|
723
796
|
```
|
|
724
797
|
|
|
725
|
-
Condition keys use dot-notation to address request data:
|
|
798
|
+
Condition keys use dot-notation to address request data. Both bare and `_`-prefixed forms are accepted:
|
|
726
799
|
|
|
727
|
-
| Prefix
|
|
728
|
-
|
|
|
729
|
-
| `body.X`
|
|
730
|
-
| `params.X`
|
|
731
|
-
| `query.X`
|
|
732
|
-
| `headers.X` | `headers.x-api-key` | `req.headers["x-api-key"]` |
|
|
800
|
+
| Prefix | Example | Resolves to |
|
|
801
|
+
| -------------------------- | ------------------- | -------------------------- |
|
|
802
|
+
| `body.X` / `_body.X` | `body.email` | `req.body.email` |
|
|
803
|
+
| `params.X` / `_params.X` | `params.id` | `req.params.id` |
|
|
804
|
+
| `query.X` / `_query.X` | `query.page` | `req.query.page` |
|
|
805
|
+
| `headers.X` / `_headers.X` | `headers.x-api-key` | `req.headers["x-api-key"]` |
|
|
733
806
|
|
|
734
807
|
Field operator suffixes (`_ne`, `_like`, `_start`, `_regex`, `_gte`, `_lte`) work on condition keys exactly as they do on query params:
|
|
735
808
|
|
|
@@ -863,6 +936,76 @@ type Handler = (req: HandlerRequest) => HandlerResponse | Promise<HandlerRespons
|
|
|
863
936
|
|
|
864
937
|
If a named handler is not found in the file, the server returns `501`. If the handler throws, it returns `500`. If a `response:` block is defined alongside `handler:`, it is used as fallback only when the handler itself throws.
|
|
865
938
|
|
|
939
|
+
### SSE streams
|
|
940
|
+
|
|
941
|
+
Mock Server-Sent Events streams by adding `_method: SSE` and an `_sse` block to a `_routes` entry. Clients connect with a regular `GET` request and receive a continuous stream of events until the connection is closed.
|
|
942
|
+
|
|
943
|
+
```yaml
|
|
944
|
+
_routes:
|
|
945
|
+
- _method: SSE
|
|
946
|
+
_path: /events/orders
|
|
947
|
+
_sse:
|
|
948
|
+
_interval: 1500 # ms between frames (default: 1000)
|
|
949
|
+
_loop: true # restart after last event (default: true)
|
|
950
|
+
_repeat: 3 # stop after N full cycles (omit = infinite)
|
|
951
|
+
_events:
|
|
952
|
+
- _event: update
|
|
953
|
+
_data:
|
|
954
|
+
orderId: 1
|
|
955
|
+
status: processing
|
|
956
|
+
- _event: update
|
|
957
|
+
_data:
|
|
958
|
+
orderId: 1
|
|
959
|
+
status: shipped
|
|
960
|
+
- _event: done
|
|
961
|
+
_data:
|
|
962
|
+
orderId: 1
|
|
963
|
+
status: delivered
|
|
964
|
+
```
|
|
965
|
+
|
|
966
|
+
**`_sse` options:**
|
|
967
|
+
|
|
968
|
+
| Key | Default | Description |
|
|
969
|
+
| ----------- | ------- | ---------------------------------------------------------------------------------------------------- |
|
|
970
|
+
| `_interval` | `1000` | Milliseconds between frames |
|
|
971
|
+
| `_loop` | `true` | Restart the sequence after the last frame. Set to `false` to close after one cycle |
|
|
972
|
+
| `_repeat` | — | Stop after N complete cycles and close the stream. Omit for infinite |
|
|
973
|
+
| `_events` | — | Ordered list of frames. Each entry needs `_data` (required) and optionally `_event` (SSE event name) |
|
|
974
|
+
|
|
975
|
+
**Template variables** — resolved per frame at emit time, so each event gets a fresh value:
|
|
976
|
+
|
|
977
|
+
```yaml
|
|
978
|
+
_events:
|
|
979
|
+
- _event: tick
|
|
980
|
+
_data:
|
|
981
|
+
ts: "{{now}}" # ISO timestamp, unique per frame
|
|
982
|
+
id: "{{uuid}}" # random UUID v4
|
|
983
|
+
channel: "{{params.channel}}" # from URL params
|
|
984
|
+
filter: "{{query.filter}}" # from query string
|
|
985
|
+
```
|
|
986
|
+
|
|
987
|
+
**Consuming from the browser:**
|
|
988
|
+
|
|
989
|
+
```js
|
|
990
|
+
const es = new EventSource("http://localhost:3070/events/orders");
|
|
991
|
+
|
|
992
|
+
es.addEventListener("update", (e) => console.log(JSON.parse(e.data)));
|
|
993
|
+
es.addEventListener("done", (e) => {
|
|
994
|
+
console.log(JSON.parse(e.data));
|
|
995
|
+
es.close();
|
|
996
|
+
});
|
|
997
|
+
```
|
|
998
|
+
|
|
999
|
+
**Consuming from the terminal:**
|
|
1000
|
+
|
|
1001
|
+
```bash
|
|
1002
|
+
curl -N http://localhost:3070/events/orders
|
|
1003
|
+
```
|
|
1004
|
+
|
|
1005
|
+
The `-N` flag disables curl's output buffering, which is required to see SSE frames as they arrive.
|
|
1006
|
+
|
|
1007
|
+
A keep-alive comment (`: ping`) is sent every 15 s to prevent proxy timeouts. SSE routes are shown in `/_about` under their own **SSE streams** accordion with a sky-blue `SSE` badge.
|
|
1008
|
+
|
|
866
1009
|
---
|
|
867
1010
|
|
|
868
1011
|
## Server modes
|
|
@@ -1180,6 +1323,9 @@ const server = createYrestServer({
|
|
|
1180
1323
|
| Explicit relation types (one2one, many2many) | ✅ |
|
|
1181
1324
|
| Bidirectional many2many nested routes | ✅ |
|
|
1182
1325
|
| Auto-embedding (`nested: true` in `_rel`) | ✅ |
|
|
1326
|
+
| SSE stream routes (`_method: SSE`) | ✅ |
|
|
1327
|
+
| WebSocket mock channels (`_method: WS`) | 🔜 |
|
|
1328
|
+
| TypeScript handlers + typed collections | 🔜 |
|
|
1183
1329
|
|
|
1184
1330
|
---
|
|
1185
1331
|
|
package/dist/cli/index.js
CHANGED
|
@@ -664,6 +664,8 @@ function normaliseRoute(raw) {
|
|
|
664
664
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
665
665
|
const v = raw;
|
|
666
666
|
const method = v["_method"];
|
|
667
|
+
if (typeof method === "string" && method.toUpperCase() === "SSE") return null;
|
|
668
|
+
if ("_sse" in v) return null;
|
|
667
669
|
const path = v["_path"];
|
|
668
670
|
if (typeof method !== "string" || typeof path !== "string") return null;
|
|
669
671
|
const route = { method, path };
|
|
@@ -703,6 +705,43 @@ function normaliseScenario(raw) {
|
|
|
703
705
|
return { when, response };
|
|
704
706
|
}
|
|
705
707
|
|
|
708
|
+
// src/storage/parseSseRoutes.ts
|
|
709
|
+
function parseSseRoutes(raw) {
|
|
710
|
+
if (!Array.isArray(raw)) return [];
|
|
711
|
+
return raw.map(normaliseSseRoute).filter((r) => r !== null);
|
|
712
|
+
}
|
|
713
|
+
function normaliseSseRoute(raw) {
|
|
714
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
715
|
+
const v = raw;
|
|
716
|
+
const method = typeof v["_method"] === "string" ? v["_method"].toUpperCase() : null;
|
|
717
|
+
const path = v["_path"];
|
|
718
|
+
const sse = v["_sse"];
|
|
719
|
+
if (method !== "SSE") return null;
|
|
720
|
+
if (typeof path !== "string" || !sse || typeof sse !== "object" || Array.isArray(sse)) {
|
|
721
|
+
return null;
|
|
722
|
+
}
|
|
723
|
+
const s = sse;
|
|
724
|
+
const rawEvents = s["_events"];
|
|
725
|
+
if (!Array.isArray(rawEvents)) return null;
|
|
726
|
+
const events = rawEvents.map(normaliseEvent).filter((e) => e !== null);
|
|
727
|
+
if (!events.length) return null;
|
|
728
|
+
return {
|
|
729
|
+
path,
|
|
730
|
+
interval: typeof s["_interval"] === "number" ? s["_interval"] : 1e3,
|
|
731
|
+
loop: s["_loop"] !== false,
|
|
732
|
+
repeat: typeof s["_repeat"] === "number" ? s["_repeat"] : void 0,
|
|
733
|
+
events
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
function normaliseEvent(raw) {
|
|
737
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
738
|
+
const v = raw;
|
|
739
|
+
if (!("_data" in v)) return null;
|
|
740
|
+
const event = { data: v["_data"] };
|
|
741
|
+
if (typeof v["_event"] === "string") event.event = v["_event"];
|
|
742
|
+
return event;
|
|
743
|
+
}
|
|
744
|
+
|
|
706
745
|
// src/storage/parseSchema.ts
|
|
707
746
|
function parseSchema(raw) {
|
|
708
747
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
@@ -717,6 +756,7 @@ function parseSchema(raw) {
|
|
|
717
756
|
}
|
|
718
757
|
return result;
|
|
719
758
|
}
|
|
759
|
+
var VALID_TYPES = /* @__PURE__ */ new Set(["string", "integer", "number", "boolean", "object", "array"]);
|
|
720
760
|
function normaliseFieldDef(value) {
|
|
721
761
|
if (value === "required") return { required: true };
|
|
722
762
|
if (value === "optional") return { required: false };
|
|
@@ -724,15 +764,60 @@ function normaliseFieldDef(value) {
|
|
|
724
764
|
const v = value;
|
|
725
765
|
const def = {};
|
|
726
766
|
if (v["_required"] === true || v["_required"] === false) def.required = v["_required"];
|
|
727
|
-
if (typeof v["_type"] === "string" &&
|
|
767
|
+
if (typeof v["_type"] === "string" && VALID_TYPES.has(v["_type"]))
|
|
728
768
|
def.type = v["_type"];
|
|
729
769
|
if (typeof v["_format"] === "string") def.format = v["_format"];
|
|
730
770
|
if (Array.isArray(v["_enum"])) def.enum = v["_enum"];
|
|
731
771
|
if (typeof v["_description"] === "string") def.description = v["_description"];
|
|
732
772
|
if (v["_default"] !== void 0) def.default = v["_default"];
|
|
773
|
+
if (v["_example"] !== void 0) def.example = v["_example"];
|
|
774
|
+
if (v["_nullable"] === true || v["_nullable"] === false) def.nullable = v["_nullable"];
|
|
775
|
+
if (typeof v["_minLength"] === "number") def.minLength = v["_minLength"];
|
|
776
|
+
if (typeof v["_maxLength"] === "number") def.maxLength = v["_maxLength"];
|
|
777
|
+
if (typeof v["_pattern"] === "string") def.pattern = v["_pattern"];
|
|
778
|
+
if (typeof v["_minimum"] === "number") def.minimum = v["_minimum"];
|
|
779
|
+
if (typeof v["_maximum"] === "number") def.maximum = v["_maximum"];
|
|
780
|
+
if (typeof v["_exclusiveMinimum"] === "number") def.exclusiveMinimum = v["_exclusiveMinimum"];
|
|
781
|
+
if (typeof v["_exclusiveMaximum"] === "number") def.exclusiveMaximum = v["_exclusiveMaximum"];
|
|
782
|
+
if (typeof v["_multipleOf"] === "number") def.multipleOf = v["_multipleOf"];
|
|
783
|
+
if (typeof v["_minItems"] === "number") def.minItems = v["_minItems"];
|
|
784
|
+
if (typeof v["_maxItems"] === "number") def.maxItems = v["_maxItems"];
|
|
785
|
+
if (v["_uniqueItems"] === true || v["_uniqueItems"] === false)
|
|
786
|
+
def.uniqueItems = v["_uniqueItems"];
|
|
787
|
+
if (v["_items"] && typeof v["_items"] === "object" && !Array.isArray(v["_items"])) {
|
|
788
|
+
const items = v["_items"];
|
|
789
|
+
const itemsDef = {};
|
|
790
|
+
if (typeof items["_type"] === "string" && VALID_TYPES.has(items["_type"]))
|
|
791
|
+
itemsDef.type = items["_type"];
|
|
792
|
+
if (typeof items["_format"] === "string") itemsDef.format = items["_format"];
|
|
793
|
+
if (Array.isArray(items["_enum"])) itemsDef.enum = items["_enum"];
|
|
794
|
+
def.items = itemsDef;
|
|
795
|
+
}
|
|
796
|
+
if (v["_deprecated"] === true || v["_deprecated"] === false)
|
|
797
|
+
def.deprecated = v["_deprecated"];
|
|
798
|
+
if (v["_readOnly"] === true || v["_readOnly"] === false) def.readOnly = v["_readOnly"];
|
|
799
|
+
if (v["_writeOnly"] === true || v["_writeOnly"] === false)
|
|
800
|
+
def.writeOnly = v["_writeOnly"];
|
|
733
801
|
return def;
|
|
734
802
|
}
|
|
735
803
|
|
|
804
|
+
// src/storage/parseData.ts
|
|
805
|
+
var RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema", "_data"]);
|
|
806
|
+
function parseData(raw) {
|
|
807
|
+
const block = raw["_data"];
|
|
808
|
+
const fromBlock = block && typeof block === "object" && !Array.isArray(block) ? Object.fromEntries(
|
|
809
|
+
Object.entries(block).filter(([, v]) => Array.isArray(v))
|
|
810
|
+
) : {};
|
|
811
|
+
const fromRoot = Object.fromEntries(
|
|
812
|
+
Object.entries(raw).filter(([key, val]) => !RESERVED.has(key) && Array.isArray(val))
|
|
813
|
+
);
|
|
814
|
+
return { ...fromBlock, ...fromRoot };
|
|
815
|
+
}
|
|
816
|
+
function hasDataBlock(raw) {
|
|
817
|
+
const d = raw["_data"];
|
|
818
|
+
return d !== null && d !== void 0 && typeof d === "object" && !Array.isArray(d);
|
|
819
|
+
}
|
|
820
|
+
|
|
736
821
|
// src/utils/deepCopy.ts
|
|
737
822
|
function deepCopyData(source) {
|
|
738
823
|
return Object.fromEntries(
|
|
@@ -798,13 +883,12 @@ function serializeScenario(s) {
|
|
|
798
883
|
function createYrestStorage(filePath) {
|
|
799
884
|
const absPath = (0, import_node_path2.resolve)(filePath);
|
|
800
885
|
const raw = (0, import_yaml.parse)((0, import_node_fs2.readFileSync)(absPath, "utf8")) ?? {};
|
|
801
|
-
const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
|
|
802
886
|
const relations = parseRelations(raw["_rel"]);
|
|
803
887
|
const routes = parseRoutes(raw["_routes"]);
|
|
888
|
+
const sseRoutes = parseSseRoutes(raw["_routes"]);
|
|
804
889
|
const schema = parseSchema(raw["_schema"]);
|
|
805
|
-
const
|
|
806
|
-
|
|
807
|
-
);
|
|
890
|
+
const dataBlock = hasDataBlock(raw);
|
|
891
|
+
const data = parseData(raw);
|
|
808
892
|
let snapshot = {
|
|
809
893
|
data: deepCopyData(data),
|
|
810
894
|
relations: { ...relations },
|
|
@@ -823,6 +907,9 @@ function createYrestStorage(filePath) {
|
|
|
823
907
|
getRoutes() {
|
|
824
908
|
return routes;
|
|
825
909
|
},
|
|
910
|
+
getSseRoutes() {
|
|
911
|
+
return sseRoutes;
|
|
912
|
+
},
|
|
826
913
|
getCollection(name) {
|
|
827
914
|
return data[name];
|
|
828
915
|
},
|
|
@@ -833,7 +920,11 @@ function createYrestStorage(filePath) {
|
|
|
833
920
|
const payload = {};
|
|
834
921
|
if (Object.keys(relations).length > 0) payload._rel = serializeRelations(relations);
|
|
835
922
|
if (routes.length > 0) payload._routes = serializeRoutes(routes);
|
|
836
|
-
|
|
923
|
+
if (dataBlock) {
|
|
924
|
+
payload._data = data;
|
|
925
|
+
} else {
|
|
926
|
+
Object.assign(payload, data);
|
|
927
|
+
}
|
|
837
928
|
const tmp = (0, import_node_path2.resolve)((0, import_node_path2.dirname)(absPath), `.yrest-${(0, import_node_crypto.randomUUID)()}.tmp`);
|
|
838
929
|
(0, import_node_fs2.writeFileSync)(tmp, (0, import_yaml.stringify)(payload), "utf8");
|
|
839
930
|
(0, import_node_fs2.renameSync)(tmp, absPath);
|
|
@@ -841,9 +932,7 @@ function createYrestStorage(filePath) {
|
|
|
841
932
|
reload() {
|
|
842
933
|
const fresh = (0, import_yaml.parse)((0, import_node_fs2.readFileSync)(absPath, "utf8")) ?? {};
|
|
843
934
|
const freshRelations = parseRelations(fresh["_rel"]);
|
|
844
|
-
const freshData =
|
|
845
|
-
Object.entries(fresh).filter(([key]) => !RESERVED.has(key))
|
|
846
|
-
);
|
|
935
|
+
const freshData = parseData(fresh);
|
|
847
936
|
for (const key of Object.keys(data)) delete data[key];
|
|
848
937
|
Object.assign(data, freshData);
|
|
849
938
|
for (const key of Object.keys(relations)) delete relations[key];
|
|
@@ -932,6 +1021,8 @@ var METHOD_COLOR = {
|
|
|
932
1021
|
PUT: "#d29922",
|
|
933
1022
|
PATCH: "#a371f7",
|
|
934
1023
|
DELETE: "#f85149",
|
|
1024
|
+
SSE: "#0ea5e9",
|
|
1025
|
+
WS: "#8b5cf6",
|
|
935
1026
|
fn: "#f0883e"
|
|
936
1027
|
};
|
|
937
1028
|
function escapeHtml(str) {
|
|
@@ -1123,6 +1214,30 @@ function handlersAccordion(handlers, routes, base) {
|
|
|
1123
1214
|
</tbody></table>
|
|
1124
1215
|
</details>`;
|
|
1125
1216
|
}
|
|
1217
|
+
function sseRoutesAccordion(routes, base) {
|
|
1218
|
+
if (!routes.length) return "";
|
|
1219
|
+
const rows = routes.map((r) => {
|
|
1220
|
+
const tags = [
|
|
1221
|
+
`<span style="color:#0ea5e9;font-size:11px">interval\xB7${r.interval}ms</span>`,
|
|
1222
|
+
`<span style="color:var(--text-muted);font-size:11px">${r.events.length} event${r.events.length !== 1 ? "s" : ""}</span>`
|
|
1223
|
+
];
|
|
1224
|
+
if (!r.loop) tags.push(`<span style="color:var(--text-muted);font-size:11px">no\xB7loop</span>`);
|
|
1225
|
+
if (r.repeat !== void 0)
|
|
1226
|
+
tags.push(`<span style="color:var(--text-muted);font-size:11px">repeat\xB7${r.repeat}\xD7</span>`);
|
|
1227
|
+
const desc = `SSE stream ${tags.join(" ")}`;
|
|
1228
|
+
return endpointRow("SSE", `${base}${r.path}`, desc);
|
|
1229
|
+
});
|
|
1230
|
+
return `
|
|
1231
|
+
<details class="resource-card nested-card">
|
|
1232
|
+
<summary>
|
|
1233
|
+
<span class="resource-name">SSE streams</span>
|
|
1234
|
+
<span class="route-count">${routes.length} stream${routes.length !== 1 ? "s" : ""}</span>
|
|
1235
|
+
</summary>
|
|
1236
|
+
<table><tbody>
|
|
1237
|
+
${rows.join("")}
|
|
1238
|
+
</tbody></table>
|
|
1239
|
+
</details>`;
|
|
1240
|
+
}
|
|
1126
1241
|
function examplesBlock(collections, relations, base, host, options, firstCustomRoute) {
|
|
1127
1242
|
const examples = [];
|
|
1128
1243
|
const firstCol = collections[0];
|
|
@@ -1793,6 +1908,7 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
1793
1908
|
const collections = Object.keys(storage.getData());
|
|
1794
1909
|
const relations = storage.getRelations();
|
|
1795
1910
|
const customRoutes = storage.getRoutes();
|
|
1911
|
+
const sseRoutes = storage.getSseRoutes();
|
|
1796
1912
|
const base = options.base;
|
|
1797
1913
|
const host = `http://${options.host}:${options.port}`;
|
|
1798
1914
|
const swaggerUrl = `https://petstore.swagger.io/?url=${encodeURIComponent(`${host}/_openapi.json`)}`;
|
|
@@ -1976,6 +2092,7 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
1976
2092
|
${accordions}
|
|
1977
2093
|
${nestedRoutesAccordion(relations, base)}
|
|
1978
2094
|
${options.snapshot ? snapshotAccordion() : ""}
|
|
2095
|
+
${sseRoutesAccordion(sseRoutes, base)}
|
|
1979
2096
|
${customRoutesAccordion(customRoutes, base, handlers)}
|
|
1980
2097
|
${handlersAccordion(handlers, customRoutes, base)}
|
|
1981
2098
|
</div>
|
|
@@ -2403,15 +2520,19 @@ function resolveRequestPath(dotPath, req) {
|
|
|
2403
2520
|
const [root, ...rest] = dotPath.split(".");
|
|
2404
2521
|
let value;
|
|
2405
2522
|
switch (root) {
|
|
2523
|
+
case "_body":
|
|
2406
2524
|
case "body":
|
|
2407
2525
|
value = req.body;
|
|
2408
2526
|
break;
|
|
2527
|
+
case "_params":
|
|
2409
2528
|
case "params":
|
|
2410
2529
|
value = req.params;
|
|
2411
2530
|
break;
|
|
2531
|
+
case "_query":
|
|
2412
2532
|
case "query":
|
|
2413
2533
|
value = req.query;
|
|
2414
2534
|
break;
|
|
2535
|
+
case "_headers":
|
|
2415
2536
|
case "headers":
|
|
2416
2537
|
value = req.headers;
|
|
2417
2538
|
break;
|
|
@@ -2703,6 +2824,23 @@ function buildCollectionSchema(items, fieldDefs = {}) {
|
|
|
2703
2824
|
if (def?.description) prop.description = def.description;
|
|
2704
2825
|
if (def?.enum) prop.enum = def.enum;
|
|
2705
2826
|
if (def?.default !== void 0) prop.default = def.default;
|
|
2827
|
+
if (def?.example !== void 0) prop.example = def.example;
|
|
2828
|
+
if (def?.nullable) prop.nullable = def.nullable;
|
|
2829
|
+
if (def?.minLength !== void 0) prop.minLength = def.minLength;
|
|
2830
|
+
if (def?.maxLength !== void 0) prop.maxLength = def.maxLength;
|
|
2831
|
+
if (def?.pattern) prop.pattern = def.pattern;
|
|
2832
|
+
if (def?.minimum !== void 0) prop.minimum = def.minimum;
|
|
2833
|
+
if (def?.maximum !== void 0) prop.maximum = def.maximum;
|
|
2834
|
+
if (def?.exclusiveMinimum !== void 0) prop.exclusiveMinimum = def.exclusiveMinimum;
|
|
2835
|
+
if (def?.exclusiveMaximum !== void 0) prop.exclusiveMaximum = def.exclusiveMaximum;
|
|
2836
|
+
if (def?.multipleOf !== void 0) prop.multipleOf = def.multipleOf;
|
|
2837
|
+
if (def?.items) prop.items = def.items;
|
|
2838
|
+
if (def?.minItems !== void 0) prop.minItems = def.minItems;
|
|
2839
|
+
if (def?.maxItems !== void 0) prop.maxItems = def.maxItems;
|
|
2840
|
+
if (def?.uniqueItems !== void 0) prop.uniqueItems = def.uniqueItems;
|
|
2841
|
+
if (def?.deprecated) prop.deprecated = def.deprecated;
|
|
2842
|
+
if (def?.readOnly) prop.readOnly = def.readOnly;
|
|
2843
|
+
if (def?.writeOnly) prop.writeOnly = def.writeOnly;
|
|
2706
2844
|
properties[field] = prop;
|
|
2707
2845
|
if (def?.required === true) required.push(field);
|
|
2708
2846
|
}
|
|
@@ -3085,6 +3223,79 @@ var SnapshotRouteCommand = class {
|
|
|
3085
3223
|
}
|
|
3086
3224
|
};
|
|
3087
3225
|
|
|
3226
|
+
// src/router/routes/sse.routes.ts
|
|
3227
|
+
var SSERouteCommand = class {
|
|
3228
|
+
constructor(storage, base) {
|
|
3229
|
+
this.storage = storage;
|
|
3230
|
+
this.base = base;
|
|
3231
|
+
}
|
|
3232
|
+
storage;
|
|
3233
|
+
base;
|
|
3234
|
+
register(server) {
|
|
3235
|
+
for (const route of this.storage.getSseRoutes()) {
|
|
3236
|
+
const url = `${this.base}${route.path}`;
|
|
3237
|
+
server.route({
|
|
3238
|
+
method: "GET",
|
|
3239
|
+
url,
|
|
3240
|
+
handler: async (req, reply) => {
|
|
3241
|
+
reply.hijack();
|
|
3242
|
+
const raw = reply.raw;
|
|
3243
|
+
raw.setHeader("Content-Type", "text/event-stream");
|
|
3244
|
+
raw.setHeader("Cache-Control", "no-cache");
|
|
3245
|
+
raw.setHeader("Connection", "keep-alive");
|
|
3246
|
+
raw.setHeader("Access-Control-Allow-Origin", "*");
|
|
3247
|
+
raw.flushHeaders();
|
|
3248
|
+
let alive = true;
|
|
3249
|
+
req.raw.on("close", () => {
|
|
3250
|
+
alive = false;
|
|
3251
|
+
});
|
|
3252
|
+
const keepAlive = setInterval(() => {
|
|
3253
|
+
if (alive) raw.write(": ping\n\n");
|
|
3254
|
+
}, 15e3);
|
|
3255
|
+
let frameIndex = 0;
|
|
3256
|
+
let cycleCount = 0;
|
|
3257
|
+
function sendNext() {
|
|
3258
|
+
if (!alive) {
|
|
3259
|
+
clearInterval(keepAlive);
|
|
3260
|
+
return;
|
|
3261
|
+
}
|
|
3262
|
+
const ev = route.events[frameIndex];
|
|
3263
|
+
const ctx = {
|
|
3264
|
+
params: req.params,
|
|
3265
|
+
query: req.query,
|
|
3266
|
+
body: null,
|
|
3267
|
+
headers: req.headers
|
|
3268
|
+
};
|
|
3269
|
+
const data = hasTemplates(ev.data) ? interpolate(ev.data, ctx) : ev.data;
|
|
3270
|
+
let chunk = "";
|
|
3271
|
+
if (ev.event) chunk += `event: ${ev.event}
|
|
3272
|
+
`;
|
|
3273
|
+
chunk += `data: ${JSON.stringify(data)}
|
|
3274
|
+
|
|
3275
|
+
`;
|
|
3276
|
+
raw.write(chunk);
|
|
3277
|
+
frameIndex++;
|
|
3278
|
+
if (frameIndex >= route.events.length) {
|
|
3279
|
+
frameIndex = 0;
|
|
3280
|
+
cycleCount++;
|
|
3281
|
+
const done = !route.loop || route.repeat !== void 0 && cycleCount >= route.repeat;
|
|
3282
|
+
if (done) {
|
|
3283
|
+
clearInterval(keepAlive);
|
|
3284
|
+
raw.end();
|
|
3285
|
+
return;
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
if (alive) {
|
|
3289
|
+
setTimeout(sendNext, route.interval);
|
|
3290
|
+
}
|
|
3291
|
+
}
|
|
3292
|
+
sendNext();
|
|
3293
|
+
}
|
|
3294
|
+
});
|
|
3295
|
+
}
|
|
3296
|
+
}
|
|
3297
|
+
};
|
|
3298
|
+
|
|
3088
3299
|
// src/router/resource.router.ts
|
|
3089
3300
|
function buildResourceRouteCommands(storage, options) {
|
|
3090
3301
|
const commands = [];
|
|
@@ -3126,6 +3337,7 @@ async function createServer(storage, options, handlers = /* @__PURE__ */ new Map
|
|
|
3126
3337
|
new AboutRouteCommand(storage, options, handlers),
|
|
3127
3338
|
new OpenApiRouteCommand(storage, options),
|
|
3128
3339
|
...options.snapshot ? [new SnapshotRouteCommand(storage)] : [],
|
|
3340
|
+
new SSERouteCommand(storage, options.base),
|
|
3129
3341
|
new CustomRouteCommand(storage, options.base, handlers),
|
|
3130
3342
|
...buildResourceRouteCommands(storage, options)
|
|
3131
3343
|
];
|
|
@@ -3245,6 +3457,14 @@ async function loadHandlers(filePath) {
|
|
|
3245
3457
|
}
|
|
3246
3458
|
|
|
3247
3459
|
// src/cli/commands/serve.ts
|
|
3460
|
+
function buildServeOptions(file, fileConfig, cliOverrides, fileArgIsExplicit = false) {
|
|
3461
|
+
return yrestOptionsSchema.parse({
|
|
3462
|
+
file,
|
|
3463
|
+
...fileConfig,
|
|
3464
|
+
...fileArgIsExplicit ? { file } : {},
|
|
3465
|
+
...cliOverrides
|
|
3466
|
+
});
|
|
3467
|
+
}
|
|
3248
3468
|
function registerServe(program2) {
|
|
3249
3469
|
program2.command("serve").description("Start the mock server using a YAML file as database").argument("[file]", "Path to the YAML database file", "db.yml").option("-p, --port <number>", "Port to listen on", "3070").option("-H, --host <host>", "Host to bind", "localhost").option("-b, --base <path>", "Base path prefix for all routes", "").option("-w, --watch", "Reload db.yml automatically when it changes on disk").option("-r, --readonly", "Reject all write operations (POST, PUT, PATCH, DELETE) with 405").option(
|
|
3250
3470
|
"-d, --delay <ms>",
|
|
@@ -3268,13 +3488,7 @@ function registerServe(program2) {
|
|
|
3268
3488
|
const cliOverrides = Object.fromEntries(
|
|
3269
3489
|
Object.entries(flags).filter(([key]) => cmd.getOptionValueSource(key) === "cli")
|
|
3270
3490
|
);
|
|
3271
|
-
const
|
|
3272
|
-
file,
|
|
3273
|
-
...fileConfig,
|
|
3274
|
-
...cmd.args.length > 0 ? { file } : {},
|
|
3275
|
-
...cliOverrides
|
|
3276
|
-
};
|
|
3277
|
-
const options = yrestOptionsSchema.parse(merged);
|
|
3491
|
+
const options = buildServeOptions(file, fileConfig, cliOverrides, cmd.args.length > 0);
|
|
3278
3492
|
let storage;
|
|
3279
3493
|
try {
|
|
3280
3494
|
storage = createYrestStorage(options.file);
|