@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 CHANGED
@@ -10,7 +10,7 @@
10
10
  [![CI](https://github.com/aggiovato/yRest/actions/workflows/ci.yml/badge.svg)](https://github.com/aggiovato/yRest/actions)
11
11
  [![Node](https://img.shields.io/node/v/@yrest/cli)](https://www.npmjs.com/package/@yrest/cli)
12
12
  [![TypeScript](https://img.shields.io/badge/TypeScript-ready-blue)](https://www.typescriptlang.org/)
13
- [![Socket](https://badge.socket.dev/npm/package/@yrest/cli/0.11.1)](https://socket.dev/npm/package/@yrest/cli)
13
+ [![Socket](https://badge.socket.dev/npm/package/@yrest/cli/0.12.0)](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: marks field as required
271
+ name: required # shorthand → { _required: true }
254
272
  email:
255
- required: true
256
- format: email
273
+ _required: true
274
+ _type: string
275
+ _format: email
276
+ _minLength: 5
277
+ _maxLength: 100
257
278
  age:
258
- type: integer
279
+ _type: integer
280
+ _minimum: 0
281
+ _maximum: 150
282
+ _nullable: true
259
283
  role:
260
- type: string
261
- enum: [admin, editor, viewer]
262
- default: viewer
263
- description: User role
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
- | Key | Type | Description |
274
- | ------------- | --------- | ----------------------------------------------------------------------------------------- |
275
- | `required` | `boolean` | Marks the field as required in the OpenAPI schema |
276
- | `type` | `string` | Overrides the inferred type (`string`, `integer`, `number`, `boolean`, `array`, `object`) |
277
- | `format` | `string` | OpenAPI format hint (`email`, `date`, `date-time`, `uuid`, `uri`, …) |
278
- | `enum` | `array` | Restricts the field to a fixed set of values |
279
- | `description` | `string` | Field description included in the OpenAPI schema |
280
- | `default` | `any` | Default value shown in the OpenAPI schema |
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 | Example | Resolves to |
728
- | ----------- | ------------------- | -------------------------- |
729
- | `body.X` | `body.email` | `req.body.email` |
730
- | `params.X` | `params.id` | `req.params.id` |
731
- | `query.X` | `query.page` | `req.query.page` |
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" && ["string", "integer", "number", "boolean", "object", "array"].includes(v["_type"]))
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 data = Object.fromEntries(
806
- Object.entries(raw).filter(([key]) => !RESERVED.has(key))
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
- Object.assign(payload, data);
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 = Object.fromEntries(
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&ensp;${tags.join("&ensp;")}`;
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 merged = {
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);