c8y-nitro 0.4.2 → 0.6.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/dist/utils.mjs CHANGED
@@ -3,11 +3,14 @@ import process from "node:process";
3
3
  import { useLogger } from "evlog/nitro/v3";
4
4
  import { BasicAuth, Client, MicroserviceClientRequestAuth } from "@c8y/client";
5
5
  import { defineCachedFunction } from "nitro/cache";
6
- import { createHash, randomBytes } from "node:crypto";
6
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
7
7
  import { HTTPError, defineHandler } from "nitro/h3";
8
- import { useStorage } from "nitro/storage";
9
8
  import { useRuntimeConfig } from "nitro/runtime-config";
9
+ import { useNitroHooks } from "nitro/app";
10
+ import { c8yManifest } from "c8y-nitro/runtime";
10
11
  import { createError, createLogger } from "evlog";
12
+ import { ms } from "itty-time";
13
+ import { tasks } from "#nitro/virtual/tasks";
11
14
  //#region src/utils/internal/common.ts
12
15
  /**
13
16
  * Converts undici Request headers to the format expected by MicroserviceClientRequestAuth.\
@@ -82,6 +85,14 @@ const getCurrentUserTenantId = defineCachedFunction(async (requestOrEvent) => {
82
85
  });
83
86
  //#endregion
84
87
  //#region src/utils/credentials.ts
88
+ let prevCredentials = null;
89
+ function shouldEmitTenantCredentialsUpdated(prev, next) {
90
+ if (!prev) return true;
91
+ const prevTenantIds = Object.keys(prev);
92
+ const nextTenantIds = Object.keys(next);
93
+ if (prevTenantIds.length !== nextTenantIds.length) return true;
94
+ return new Set(nextTenantIds).symmetricDifference(new Set(prevTenantIds)).size > 0;
95
+ }
85
96
  /**
86
97
  * Fetches credentials for all tenants subscribed to this microservice.\
87
98
  * Uses bootstrap credentials from runtime config to query the microservice subscriptions API.\
@@ -89,7 +100,7 @@ const getCurrentUserTenantId = defineCachedFunction(async (requestOrEvent) => {
89
100
  * @returns Object mapping tenant IDs to their respective credentials
90
101
  * @config Cache TTL can be configured via:
91
102
  * - `c8y.cache.credentialsTTL` in the Nitro config (value in seconds)
92
- * - `NITRO_C8Y_CACHE_CREDENTIALS_TTL` environment variable
103
+ * - `NITRO_C8Y_CREDENTIALS_CACHE_TTL` environment variable
93
104
  * @example
94
105
  * // Get all subscribed tenant credentials:
95
106
  * const credentials = await useSubscribedTenantCredentials()
@@ -104,8 +115,8 @@ const getCurrentUserTenantId = defineCachedFunction(async (requestOrEvent) => {
104
115
  * // Force refresh:
105
116
  * const freshCreds = await useSubscribedTenantCredentials.refresh()
106
117
  */
107
- const useSubscribedTenantCredentials = Object.assign(defineCachedFunction(async () => {
108
- return (await Client.getMicroserviceSubscriptions({
118
+ const cachedSubscribedTenantCredentials = defineCachedFunction(async () => {
119
+ const newCredentials = (await Client.getMicroserviceSubscriptions({
109
120
  tenant: process.env.C8Y_BOOTSTRAP_TENANT,
110
121
  user: process.env.C8Y_BOOTSTRAP_USER,
111
122
  password: process.env.C8Y_BOOTSTRAP_PASSWORD
@@ -113,18 +124,26 @@ const useSubscribedTenantCredentials = Object.assign(defineCachedFunction(async
113
124
  if (cred.tenant) acc[cred.tenant] = cred;
114
125
  return acc;
115
126
  }, {});
127
+ if (shouldEmitTenantCredentialsUpdated(prevCredentials, newCredentials)) useNitroHooks().callHook("c8y:tenantCredentialsUpdated", prevCredentials, newCredentials);
128
+ prevCredentials = newCredentials;
129
+ return newCredentials;
116
130
  }, {
117
131
  maxAge: useRuntimeConfig().c8yCredentialsCacheTTL ?? 600,
118
132
  name: "_c8y_nitro_get_subscribed_tenant_credentials",
119
133
  group: "c8y_nitro",
120
134
  swr: false
121
- }), {
122
- invalidate: async () => {
123
- await useStorage("cache").removeItem(`c8y_nitro:functions:_c8y_nitro_get_subscribed_tenant_credentials.json`);
124
- },
135
+ });
136
+ const useSubscribedTenantCredentials = Object.assign(async () => {
137
+ const credentials = await cachedSubscribedTenantCredentials();
138
+ prevCredentials = credentials;
139
+ return credentials;
140
+ }, {
141
+ invalidate: cachedSubscribedTenantCredentials.invalidate,
125
142
  refresh: async () => {
126
- await useSubscribedTenantCredentials.invalidate();
127
- return await useSubscribedTenantCredentials();
143
+ await cachedSubscribedTenantCredentials.invalidate();
144
+ const credentials = await cachedSubscribedTenantCredentials();
145
+ prevCredentials = credentials;
146
+ return credentials;
128
147
  }
129
148
  });
130
149
  /**
@@ -157,7 +176,7 @@ const useDeployedTenantCredentials = Object.assign(async () => {
157
176
  }, {
158
177
  invalidate: useSubscribedTenantCredentials.invalidate,
159
178
  refresh: async () => {
160
- await useDeployedTenantCredentials.invalidate();
179
+ await useSubscribedTenantCredentials.refresh();
161
180
  return await useDeployedTenantCredentials();
162
181
  }
163
182
  });
@@ -318,8 +337,13 @@ async function useUserRoles(requestOrEvent) {
318
337
  }
319
338
  //#endregion
320
339
  //#region src/utils/middleware.ts
340
+ const probePaths = [c8yManifest.livenessProbe?.httpGet?.path, c8yManifest.readinessProbe?.httpGet?.path].filter((path) => Boolean(path));
341
+ function isProbeRequest(pathname) {
342
+ return probePaths.some((probePath) => pathname.startsWith(probePath));
343
+ }
321
344
  function hasUserRequiredRole(roleOrRoles) {
322
345
  return defineHandler(async (event) => {
346
+ if (isProbeRequest(event.url.pathname)) return;
323
347
  const requiredRoles = Array.isArray(roleOrRoles) ? roleOrRoles : [roleOrRoles];
324
348
  const userRoles = await useUserRoles(event);
325
349
  if (!requiredRoles.some((role) => userRoles.includes(role))) throw new HTTPError({
@@ -331,6 +355,7 @@ function hasUserRequiredRole(roleOrRoles) {
331
355
  }
332
356
  function isUserFromAllowedTenant(tenantIdOrIds) {
333
357
  return defineHandler(async (event) => {
358
+ if (isProbeRequest(event.url.pathname)) return;
334
359
  const allowedTenants = Array.isArray(tenantIdOrIds) ? tenantIdOrIds : [tenantIdOrIds];
335
360
  const userTenantId = await getCurrentUserTenantId(event);
336
361
  if (!allowedTenants.includes(userTenantId)) throw new HTTPError({
@@ -357,6 +382,7 @@ function isUserFromAllowedTenant(tenantIdOrIds) {
357
382
  */
358
383
  function isUserFromDeployedTenant() {
359
384
  return defineHandler(async (event) => {
385
+ if (isProbeRequest(event.url.pathname)) return;
360
386
  const userTenantId = await getCurrentUserTenantId(event);
361
387
  const deployedTenantId = process.env.C8Y_BOOTSTRAP_TENANT;
362
388
  if (!deployedTenantId) throw new HTTPError({
@@ -372,6 +398,12 @@ function isUserFromDeployedTenant() {
372
398
  });
373
399
  }
374
400
  //#endregion
401
+ //#region src/utils/internal/tenantOptionFetchers.ts
402
+ /**
403
+ * Internal storage for cached functions per key
404
+ */
405
+ const tenantOptionFetchers = {};
406
+ //#endregion
375
407
  //#region src/utils/tenantOptions.ts
376
408
  /**
377
409
  * Gets the cache TTL for a specific tenant option key.
@@ -383,22 +415,17 @@ function getTenantOptionCacheTTL(key) {
383
415
  return config.c8yTenantOptionsPerKeyTTL?.[key] ?? config.c8yDefaultTenantOptionsTTL ?? 600;
384
416
  }
385
417
  /**
386
- * Internal storage for cached functions per key
387
- */
388
- const tenantOptionFetchers = {};
389
- /**
390
418
  * Factory function that creates a cached fetcher for a specific tenant option key.
391
419
  * @param key - The tenant option key
392
420
  */
393
421
  function createCachedTenantOptionFetcher(key) {
394
422
  const cacheName = `_c8y_nitro_tenant_option_${key.replace(/\./g, "_")}`;
395
- const fetcher = defineCachedFunction(async () => {
423
+ const cachedFetcher = defineCachedFunction(async () => {
396
424
  const client = await useDeployedTenantClient();
397
425
  const category = useRuntimeConfig().c8ySettingsCategory;
398
- const apiKey = key.replace(/^credentials\./, "");
399
426
  try {
400
427
  return (await client.options.tenant.detail({
401
- key: apiKey,
428
+ key,
402
429
  category
403
430
  })).data.value;
404
431
  } catch (error) {
@@ -411,17 +438,10 @@ function createCachedTenantOptionFetcher(key) {
411
438
  group: "c8y_nitro",
412
439
  swr: false
413
440
  });
414
- return Object.assign(fetcher, {
415
- invalidate: async () => {
416
- const completeKey = `c8y_nitro:functions:${cacheName}.json`;
417
- await useStorage("cache").removeItem(completeKey);
418
- },
419
- refresh: async () => {
420
- const completeKey = `c8y_nitro:functions:${cacheName}.json`;
421
- await useStorage("cache").removeItem(completeKey);
422
- return await fetcher();
423
- }
424
- });
441
+ return Object.assign(cachedFetcher, { refresh: async () => {
442
+ await cachedFetcher.invalidate();
443
+ return await cachedFetcher();
444
+ } });
425
445
  }
426
446
  /**
427
447
  * Gets or creates a cached fetcher for a specific tenant option key.
@@ -448,11 +468,6 @@ function getOrCreateFetcher(key) {
448
468
  * - `c8y.cache.tenantOptions` — Per-key TTL overrides
449
469
  * - `NITRO_C8Y_DEFAULT_TENANT_OPTIONS_TTL` — Environment variable for default TTL
450
470
  *
451
- * @note For encrypted options (keys starting with `credentials.`), the value is automatically
452
- * decrypted by Cumulocity if this microservice is the owner of the option (category matches
453
- * the microservice's settingsCategory/contextPath/name). The `credentials.` prefix is
454
- * automatically stripped when calling the API.
455
- *
456
471
  * @example
457
472
  * // Fetch a tenant option:
458
473
  * const value = await useTenantOption('myOption')
@@ -475,16 +490,33 @@ function getOrCreateFetcher(key) {
475
490
  const useTenantOption = Object.assign(async (key) => {
476
491
  return await getOrCreateFetcher(key)();
477
492
  }, {
493
+ /**
494
+ * Invalidate the cache for a specific tenant option key.
495
+ * @param key - The tenant option key to invalidate
496
+ */
478
497
  invalidate: async (key) => {
479
498
  const fetcher = tenantOptionFetchers[key];
480
499
  if (fetcher) await fetcher.invalidate();
481
500
  },
501
+ /**
502
+ * Force refresh a specific tenant option key (invalidates and re-fetches).
503
+ * @param key - The tenant option key to refresh
504
+ */
482
505
  refresh: async (key) => {
483
506
  return await getOrCreateFetcher(key).refresh();
484
507
  },
508
+ /**
509
+ * Invalidate all tenant option caches that have been accessed.
510
+ * Only invalidates keys that have been fetched at least once.
511
+ */
485
512
  invalidateAll: async () => {
486
513
  await Promise.all(Object.values(tenantOptionFetchers).map((fetcher) => fetcher?.invalidate()));
487
514
  },
515
+ /**
516
+ * Refresh all tenant options that have been accessed.
517
+ * Only refreshes keys that have been fetched at least once.
518
+ * @returns Object mapping keys to their refreshed values
519
+ */
488
520
  refreshAll: async () => {
489
521
  const entries = Object.entries(tenantOptionFetchers);
490
522
  const values = await Promise.all(entries.map(([, fetcher]) => fetcher?.refresh()));
@@ -492,4 +524,168 @@ const useTenantOption = Object.assign(async (key) => {
492
524
  }
493
525
  });
494
526
  //#endregion
495
- export { createError, createLogger, hasUserRequiredRole, isUserFromAllowedTenant, isUserFromDeployedTenant, useDeployedTenantClient, useDeployedTenantCredentials, useLogger, useSubscribedTenantClients, useSubscribedTenantCredentials, useTenantOption, useUser, useUserClient, useUserRoles, useUserTenantClient, useUserTenantCredentials };
527
+ //#region src/utils/schedule.ts
528
+ const MAX_TIMEOUT_MS = 2147483647;
529
+ const SCHEDULE_LOOKAHEAD_MS = 3600 * 1e3;
530
+ const SCHEDULER_TICK_MS = 100;
531
+ const scheduledTasks = /* @__PURE__ */ new Map();
532
+ const scheduledTaskTimers = /* @__PURE__ */ new Map();
533
+ const schedulerReady = Promise.resolve();
534
+ let schedulerInterval;
535
+ function createScheduledTaskId() {
536
+ return randomUUID();
537
+ }
538
+ function resolveScheduleTime(schedule) {
539
+ if (schedule instanceof Date) {
540
+ const timestamp = schedule.getTime();
541
+ if (Number.isNaN(timestamp)) throw new TypeError("schedule date must be valid");
542
+ return timestamp;
543
+ }
544
+ if (typeof schedule === "number") {
545
+ if (!Number.isFinite(schedule) || schedule < 0) throw new TypeError("schedule number must be a non-negative number of seconds");
546
+ return Date.now() + schedule * 1e3;
547
+ }
548
+ const delay = ms(schedule);
549
+ if (!Number.isFinite(delay) || delay < 0) throw new TypeError("schedule string must be a valid non-negative duration");
550
+ return Date.now() + delay;
551
+ }
552
+ async function executeScheduledTask(id) {
553
+ const record = scheduledTasks.get(id);
554
+ if (!record) return;
555
+ if (record.timeoutId !== void 0) scheduledTaskTimers.delete(record.timeoutId);
556
+ scheduledTasks.delete(id);
557
+ stopSchedulerIntervalIfIdle();
558
+ const taskDef = tasks[record.taskName];
559
+ if (!taskDef) throw new Error(`Task "${record.taskName}" is not available!`);
560
+ if (!taskDef.resolve) throw new Error(`Task "${record.taskName}" is not implemented!`);
561
+ await (await taskDef.resolve()).run({
562
+ name: record.taskName,
563
+ payload: record.payload,
564
+ context: record.context
565
+ });
566
+ }
567
+ function armScheduledTaskTimeout(id) {
568
+ const record = scheduledTasks.get(id);
569
+ if (!record || record.timeoutId !== void 0) return;
570
+ const remainingMs = Math.max(record.runAt - Date.now(), 0);
571
+ if (remainingMs > SCHEDULE_LOOKAHEAD_MS) return;
572
+ const timeout = setTimeout(() => {
573
+ executeScheduledTask(id);
574
+ }, Math.min(remainingMs, MAX_TIMEOUT_MS));
575
+ record.timeoutId = Number(timeout);
576
+ scheduledTaskTimers.set(record.timeoutId, timeout);
577
+ }
578
+ function runSchedulerTick() {
579
+ for (const record of scheduledTasks.values()) armScheduledTaskTimeout(record.id);
580
+ }
581
+ function ensureSchedulerInterval() {
582
+ if (schedulerInterval) return;
583
+ schedulerInterval = setInterval(runSchedulerTick, SCHEDULER_TICK_MS);
584
+ }
585
+ function stopSchedulerIntervalIfIdle() {
586
+ if (!schedulerInterval || scheduledTasks.size > 0) return;
587
+ clearInterval(schedulerInterval);
588
+ schedulerInterval = void 0;
589
+ }
590
+ /**
591
+ * Schedules a Nitro task to run once in the future.\
592
+ * Resolves and calls the task handler directly from Nitro's virtual task registry\
593
+ * Numbers are treated as seconds, strings are parsed as human-readable durations, and dates are used as exact run times.
594
+ *
595
+ * @param taskName - The Nitro task name to run (from `tasks/*.ts`)
596
+ * @param options - Task payload, context, and the schedule time
597
+ * @returns Information about the scheduled task
598
+ *
599
+ * @example
600
+ * // Run a task in 30 seconds:
601
+ * const scheduled = await scheduleTask('emails:send', {
602
+ * payload: { messageId: 'abc123' },
603
+ * schedule: 30,
604
+ * })
605
+ *
606
+ * @example
607
+ * // Run a task using a human-readable duration:
608
+ * await scheduleTask('reports:generate', {
609
+ * payload: { reportId: 'report-1' },
610
+ * schedule: '1 hour',
611
+ * })
612
+ *
613
+ * @example
614
+ * // Run a task at an exact time:
615
+ * await scheduleTask('cleanup:tenant', {
616
+ * payload: { tenant: 't12345' },
617
+ * schedule: new Date('2026-05-01T12:00:00Z'),
618
+ * })
619
+ */
620
+ async function scheduleTask(taskName, options) {
621
+ if (!import.meta._tasks) throw new Error("scheduleTask() requires tasks to be enabled. Set `experimental: { tasks: true }` in your nitro.config.ts.");
622
+ if (!taskName) throw new TypeError("taskName is required");
623
+ const runAt = resolveScheduleTime(options.schedule);
624
+ const record = {
625
+ id: createScheduledTaskId(),
626
+ taskName,
627
+ payload: options.payload ?? {},
628
+ context: options.context ?? {},
629
+ runAt
630
+ };
631
+ scheduledTasks.set(record.id, record);
632
+ armScheduledTaskTimeout(record.id);
633
+ ensureSchedulerInterval();
634
+ return {
635
+ id: record.id,
636
+ task: record.taskName,
637
+ runAt: new Date(record.runAt).toISOString()
638
+ };
639
+ }
640
+ /**
641
+ * Lists all tasks that are currently scheduled and have not started yet.\
642
+ * The returned object is keyed by the scheduled task UUID for easy lookup and cancellation.
643
+ *
644
+ * @returns Object mapping scheduled task IDs to their public task information
645
+ *
646
+ * @example
647
+ * const tasks = await listScheduledTasks()
648
+ * for (const [id, task] of Object.entries(tasks)) {
649
+ * console.log(id, task.task, task.runAt)
650
+ * }
651
+ */
652
+ async function listScheduledTasks() {
653
+ await schedulerReady;
654
+ return Object.fromEntries([...scheduledTasks.values()].map((record) => [record.id, {
655
+ id: record.id,
656
+ task: record.taskName,
657
+ runAt: new Date(record.runAt).toISOString()
658
+ }]));
659
+ }
660
+ /**
661
+ * Cancels a scheduled task before it starts running.\
662
+ * Once the underlying Nitro task has started, it cannot be cancelled with this utility.
663
+ *
664
+ * @param id - The scheduled task UUID returned by `scheduleTask()` or `listScheduledTasks()`
665
+ * @returns `true` when a pending task was cancelled, otherwise `false`
666
+ *
667
+ * @example
668
+ * const scheduled = await scheduleTask('emails:send', {
669
+ * payload: { messageId: 'abc123' },
670
+ * schedule: '10 minutes',
671
+ * })
672
+ *
673
+ * const cancelled = await cancelScheduledTask(scheduled.id)
674
+ */
675
+ async function cancelScheduledTask(id) {
676
+ await schedulerReady;
677
+ const record = scheduledTasks.get(id);
678
+ if (!record) return false;
679
+ if (record.timeoutId !== void 0) {
680
+ const timeout = scheduledTaskTimers.get(record.timeoutId);
681
+ if (timeout) {
682
+ clearTimeout(timeout);
683
+ scheduledTaskTimers.delete(record.timeoutId);
684
+ }
685
+ }
686
+ const cancelled = scheduledTasks.delete(id);
687
+ stopSchedulerIntervalIfIdle();
688
+ return cancelled;
689
+ }
690
+ //#endregion
691
+ export { cancelScheduledTask, createError, createLogger, hasUserRequiredRole, isUserFromAllowedTenant, isUserFromDeployedTenant, listScheduledTasks, scheduleTask, useDeployedTenantClient, useDeployedTenantCredentials, useLogger, useSubscribedTenantClients, useSubscribedTenantCredentials, useTenantOption, useUser, useUserClient, useUserRoles, useUserTenantClient, useUserTenantCredentials };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8y-nitro",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Lightning fast Cumulocity IoT microservice development powered by Nitro",
6
6
  "keywords": [
@@ -23,22 +23,12 @@
23
23
  "url": "https://github.com/schplitt/c8y-nitro/issues"
24
24
  },
25
25
  "exports": {
26
- ".": {
27
- "types": "./dist/index.d.mts",
28
- "import": "./dist/index.mjs"
29
- },
30
- "./types": {
31
- "types": "./dist/types.d.mts",
32
- "import": "./dist/types.mjs"
33
- },
34
- "./utils": {
35
- "types": "./dist/utils.d.mts",
36
- "import": "./dist/utils.mjs"
37
- }
26
+ ".": "./dist/index.mjs",
27
+ "./cli": "./dist/cli/index.mjs",
28
+ "./types": "./dist/types.mjs",
29
+ "./utils": "./dist/utils.mjs",
30
+ "./package.json": "./package.json"
38
31
  },
39
- "main": "./dist/index.mjs",
40
- "module": "./dist/index.mjs",
41
- "types": "./dist/index.d.mts",
42
32
  "bin": {
43
33
  "c8y-nitro": "./dist/cli/index.mjs"
44
34
  },
@@ -48,30 +38,34 @@
48
38
  "dist"
49
39
  ],
50
40
  "dependencies": {
51
- "c12": "^4.0.0-beta.4",
52
- "citty": "^0.2.1",
41
+ "c12": "^4.0.0-beta.5",
42
+ "citty": "^0.2.2",
53
43
  "consola": "^3.4.2",
54
- "evlog": "^2.9.0",
44
+ "evlog": "^2.17.0",
45
+ "itty-time": "^2.0.2",
55
46
  "jszip": "^3.10.1",
56
47
  "pathe": "^2.0.3",
57
- "pkg-types": "^2.3.0",
48
+ "pkg-types": "^2.3.1",
58
49
  "spinnies": "^0.5.1",
59
- "tinyexec": "^1.0.4"
50
+ "tinyexec": "^1.1.2"
60
51
  },
61
52
  "devDependencies": {
62
- "@schplitt/eslint-config": "^1.3.1",
53
+ "@schplitt/eslint-config": "^1.5.0",
54
+ "@types/node": "^24.12.3",
63
55
  "@types/spinnies": "^0.5.3",
64
- "bumpp": "^11.0.1",
56
+ "bumpp": "^11.1.0",
65
57
  "changelogithub": "^14.0.0",
66
- "eslint": "^10.1.0",
67
- "memfs": "^4.57.1",
68
- "tsdown": "^0.21.4",
69
- "typescript": "^5.9.3",
70
- "vitest": "^4.1.0"
58
+ "eslint": "^10.3.0",
59
+ "memfs": "^4.57.2",
60
+ "tsdown": "^0.22.0",
61
+ "tsnapi": "^0.3.3",
62
+ "typescript": "^6.0.3",
63
+ "vitepress": "2.0.0-alpha.17",
64
+ "vitest": "^4.1.6"
71
65
  },
72
66
  "peerDependencies": {
73
67
  "@c8y/client": ">=1021",
74
- "nitro": "3.0.260311-beta"
68
+ "nitro": "3.0.260429-beta"
75
69
  },
76
70
  "engines": {
77
71
  "node": ">=24.0.0"
@@ -79,12 +73,16 @@
79
73
  "scripts": {
80
74
  "dev": "tsdown --watch",
81
75
  "build": "tsdown",
76
+ "build:update": "tsdown --update-snapshot",
82
77
  "lint": "eslint",
83
78
  "lint:fix": "eslint --fix",
84
79
  "typecheck": "tsc --noEmit",
85
80
  "prerelease": "eslint && tsc --noEmit && tsdown && vitest run",
86
81
  "release": "bumpp",
87
82
  "test": "vitest",
88
- "test:run": "vitest run"
83
+ "test:run": "vitest run",
84
+ "docs:dev": "vitepress dev docs",
85
+ "docs:build": "vitepress build docs",
86
+ "docs:preview": "vitepress preview docs"
89
87
  }
90
88
  }