c8y-nitro 0.5.0 → 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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # c8y-nitro
2
2
 
3
- Lightning fast Cumulocity IoT microservice development powered by [Nitro](https://v3.nitro.build).
3
+ Nitro-powered tooling for building Cumulocity IoT microservices without manually stitching together bootstrap, manifest generation, packaging, and tenant-aware runtime helpers.
4
4
 
5
5
  ## Features
6
6
 
@@ -14,534 +14,37 @@ Lightning fast Cumulocity IoT microservice development powered by [Nitro](https:
14
14
  - 🛠️ **TypeScript First** - Full type safety with excellent DX
15
15
  - 🔄 **Auto-Bootstrap** - Automatically registers and configures your microservice in development
16
16
 
17
- ## Quick Start
17
+ ## Quickstart
18
18
 
19
- The fastest way to get started is using the [c8y-nitro-starter](https://github.com/schplitt/c8y-nitro-starter) template:
19
+ Start with the [Quickstart docs](https://schplitt.github.io/c8y-nitro/quickstart).
20
20
 
21
- ```sh
22
- pnpm dlx giget@latest gh:schplitt/c8y-nitro-starter my-microservice
23
- cd my-microservice
24
- pnpm install
25
- ```
26
-
27
- Configure your development tenant in `.env`:
28
-
29
- ```sh
30
- C8Y_BASEURL=https://your-tenant.cumulocity.com
31
- C8Y_DEVELOPMENT_TENANT=t12345
32
- C8Y_DEVELOPMENT_USER=your-username
33
- C8Y_DEVELOPMENT_PASSWORD=your-password
34
- ```
35
-
36
- Then start developing:
37
-
38
- ```sh
39
- pnpm dev
40
- ```
41
-
42
- ## Installation
43
-
44
- ```sh
45
- pnpm add c8y-nitro nitro@latest
46
- ```
47
-
48
- ## Usage
49
-
50
- Configure your Cumulocity microservice in `nitro.config.ts`:
51
-
52
- ```ts
53
- import c8y from 'c8y-nitro'
54
-
55
- export default defineNitroConfig({
56
- preset: 'node-server', // or "node-cluster", Required!
57
- c8y: {
58
- // c8y-nitro configuration options go here
59
- },
60
- modules: [c8y()],
61
- })
62
- ```
63
-
64
- ### Prerequisites
65
-
66
- `c8y-nitro` requires:
67
-
68
- - `preset` - must be a node preset (`node-server` or `node-cluster`)
69
-
70
- **Recommended:**
71
-
72
- - `builder: 'rolldown'` - for faster build times
73
-
74
- **Optional:**
75
-
76
- - `experimental.asyncContext: true` - enables using utility functions without passing event/request parameters
77
-
78
- ## Getting Started
79
-
80
- Create a `.env` or `.env.local` file with your development tenant credentials:
81
-
82
- ```sh
83
- C8Y_BASEURL=https://your-tenant.cumulocity.com
84
- C8Y_DEVELOPMENT_TENANT=t12345
85
- C8Y_DEVELOPMENT_USER=your-username
86
- C8Y_DEVELOPMENT_PASSWORD=your-password
87
- ```
88
-
89
- Then simply run `pnpm dev` - that's it! The module will automatically:
90
-
91
- 1. Check if the microservice exists on the tenant
92
- 2. Create it if needed (or use existing one without overwriting)
93
- 3. Subscribe your tenant to the microservice
94
- 4. Retrieve and save bootstrap credentials to your env file
95
-
96
- After auto-bootstrap, your env file will contain:
97
-
98
- ```sh
99
- C8Y_BOOTSTRAP_TENANT=<bootstrap-tenant-id>
100
- C8Y_BOOTSTRAP_USER=<bootstrap-username>
101
- C8Y_BOOTSTRAP_PASSWORD=<generated-password>
102
- ```
103
-
104
- > **Manual Bootstrap**: For more control or troubleshooting, you can use the [CLI bootstrap command](#cli-commands) to manually register your microservice.
105
-
106
- > **Disable Auto-Bootstrap**: Set `skipBootstrap: true` in your c8y config to disable auto-bootstrap entirely. This is useful in CI/CD pipelines or when you want to manage bootstrap manually.
107
-
108
- ## Automatic Zip Creation
109
-
110
- `c8y-nitro` automatically generates a ready-to-deploy microservice zip package after each build. The process includes:
111
-
112
- 1. **Dockerfile Generation** - Creates an optimized Dockerfile using Node.js 22-slim
113
- 2. **Docker Image Build** - Builds and saves the Docker image to `image.tar`
114
- 3. **Manifest Generation** - Creates `cumulocity.json` from your package.json and configuration
115
- 4. **Zip Package** - Combines `image.tar` and `cumulocity.json` into a deployable zip file
116
-
117
- > **Note**: Docker must be installed and available in your PATH.
118
-
119
- The generated zip file (default: `<package-name>-<version>.zip` in root directory) is ready to upload directly to Cumulocity.
120
-
121
- ## Manifest Configuration
122
-
123
- The `cumulocity.json` manifest is automatically generated from your `package.json` and can be customized via the `manifest` option.
124
-
125
- **Auto-generated from package.json:**
126
-
127
- - `name` (scope stripped), `version` - from package fields
128
- - `provider.name` - from `author` field
129
- - `provider.domain` - from `author.url` or `homepage`
130
- - `provider.support` - from `bugs` or `author.email`
131
- - `contextPath` - defaults to package name
132
-
133
- For all available manifest options, see the [Cumulocity Microservice Manifest documentation](https://cumulocity.com/docs/microservice-sdk/general-aspects/#microservice-manifest).
134
-
135
- > **Note**: Custom roles defined in the manifest are automatically available as TypeScript types for use in middleware and runtime code during development.
136
-
137
- > **Note**: Health probe endpoints (`/_c8y_nitro/liveness` and `/_c8y_nitro/readiness`) are automatically injected if not manually defined.
138
-
139
- ## Cache Configuration
140
-
141
- Credential caching can be configured to optimize performance. By default, subscribed tenant credentials are cached for 10 minutes.
142
-
143
- ```ts
144
- export default defineNitroConfig({
145
- c8y: {
146
- enableTenantOptionsInvalidationRoute: false,
147
- cache: {
148
- credentialsTTL: 300, // Cache credentials for 5 minutes (in seconds)
149
- defaultTenantOptionsTTL: 600, // Default cache for tenant options (in seconds)
150
- tenantOptions: {
151
- 'myOption': 300, // Per-key override: 5 minutes
152
- 'credentials.secret': 60, // Per-key override: 1 minute
153
- },
154
- }
155
- },
156
- modules: [c8y()],
157
- })
158
- ```
159
-
160
- You can also override these at runtime using environment variables:
161
-
162
- ```sh
163
- NITRO_C8Y_CREDENTIALS_CACHE_TTL=300
164
- NITRO_C8Y_DEFAULT_TENANT_OPTIONS_TTL=300
165
- ```
166
-
167
- > **Note**: The credentials cache is used by `useSubscribedTenantCredentials()` and `useDeployedTenantCredentials()` utilities. Both share the same cache.
168
-
169
- ## Development User Injection
170
-
171
- During development, `c8y-nitro` automatically injects your development user credentials into all requests. This allows you to test authentication and authorization middlewares locally.
172
-
173
- The module uses the development credentials from your `.env` file:
174
-
175
- ```sh
176
- C8Y_DEVELOPMENT_TENANT=t12345
177
- C8Y_DEVELOPMENT_USER=your-username
178
- C8Y_DEVELOPMENT_PASSWORD=your-password
179
- ```
180
-
181
- This enables testing of access control middlewares like `hasUserRequiredRole()` and `isUserFromAllowedTenant()` without needing to manually set authorization headers.
182
-
183
- If you run a local proxy that already forwards a user session or authorization header, disable this middleware:
184
-
185
- ```ts
186
- export default defineNitroConfig({
187
- c8y: {
188
- dev: {
189
- injectUser: false,
190
- },
191
- },
192
- modules: [c8y()],
193
- })
194
- ```
195
-
196
- When disabled, `c8y-nitro` does not register the development user injection middleware, so incoming auth headers stay untouched.
197
-
198
- ### Managing Development User Roles
199
-
200
- Use the [CLI roles command](#cli-commands) to assign or remove your microservice's custom roles to your development user:
201
-
202
- ```sh
203
- pnpm dlx c8y-nitro roles
204
- ```
205
-
206
- This interactive command lets you select which roles from your manifest to assign to your development user for testing.
207
-
208
- ## API Client Generation
209
-
210
- For monorepo architectures, `c8y-nitro` can generate TypeScript Angular services that provide fully typed access to your microservice routes.
211
-
212
- ### Configuration
213
-
214
- ```ts
215
- export default defineNitroConfig({
216
- c8y: {
217
- apiClient: {
218
- dir: '../ui/src/app/services', // Output directory for generated client
219
- contextPath: 'my-service' // Optional: override context path
220
- }
221
- },
222
- modules: [c8y()],
223
- })
224
- ```
225
-
226
- ### Generated Client
227
-
228
- The generated service creates one method per route with automatic type inference:
229
-
230
- ```ts
231
- // Generated: my-serviceAPIClient.ts
232
- @Injectable({ providedIn: 'root' })
233
- export class GeneratedMyServiceAPIClient {
234
- async GETHealth(): Promise<{ status: string }> { }
235
- async GETUsersById(params: { id: string | number }): Promise<User> { }
236
- async POSTUsers(body: CreateUserDto): Promise<User> { }
237
- }
238
- ```
239
-
240
- ### Usage in Angular
241
-
242
- ```ts
243
- import { GeneratedMyServiceAPIClient } from './services/my-serviceAPIClient'
244
-
245
- @Component({
246
- /**
247
- * ...
248
- */
249
- })
250
- export class MyComponent {
251
- private api = inject(GeneratedMyServiceAPIClient)
21
+ ## Documentation
252
22
 
253
- async ngOnInit() {
254
- const health = await this.api.GETHealth()
255
- const user = await this.api.GETUsersById({ id: 123 })
256
- }
257
- }
258
- ```
259
-
260
- > **Note**: The client regenerates automatically when routes change during development.
261
-
262
- ## Logging
263
-
264
- `c8y-nitro` builds on [evlog](https://www.evlog.dev) to provide structured **wide-event logging**, one comprehensive log per request that accumulates all relevant context rather than scattering individual log lines throughout your code.
265
-
266
- evlog is automatically configured, no extra setup required. The service name is derived from your package name.
267
-
268
- ### useLogger
269
-
270
- Use `useLogger(event)` in your route handlers to get a request-scoped logger. The logger accumulates context throughout the request lifetime and emits a single wide event when the response is sent.
271
-
272
- ```ts
273
- import { defineHandler } from 'nitro/h3'
274
- import { useLogger } from 'c8y-nitro/utils'
275
-
276
- export default defineHandler(async (event) => {
277
- const log = useLogger(event)
278
-
279
- const user = await useUser(event)
280
- log.set({ action: 'process-order', user: { id: user.userName } })
281
-
282
- // Add more context as it becomes available
283
- log.set({ order: { id: '42', total: 9999 } })
284
-
285
- return { success: true }
286
- })
287
- ```
288
-
289
- > **Note**: `useLogger` requires the `event` parameter. If you enable `experimental.asyncContext: true` in your Nitro config, you can access the logger anywhere in the call stack via `useRequest()` from `nitro/context` — see the [evlog Nitro v3 setup](https://www.evlog.dev/getting-started/installation#nitro-v3) for details.
290
-
291
- ### createError
292
-
293
- Use `createError` from `c8y-nitro/utils` instead of Nitro's built-in error helper to get richer, structured error responses. This adds `why`, `fix`, and `link` fields that are:
294
-
295
- - Logged as part of the wide event so you can see exactly what went wrong without guessing
296
- - Returned in the JSON response body so clients can display actionable context
297
-
298
- ```ts
299
- import { defineHandler } from 'nitro/h3'
300
- import { useLogger, createError } from 'c8y-nitro/utils'
301
-
302
- export default defineHandler(async (event) => {
303
- const log = useLogger(event)
304
- log.set({ action: 'payment', userId: 'user_123' })
305
-
306
- throw createError({
307
- message: 'Payment failed',
308
- status: 402,
309
- why: 'Card declined by issuer (insufficient funds)',
310
- fix: 'Try a different payment method or contact your bank',
311
- link: 'https://docs.example.com/payments/declined',
312
- })
313
- })
314
- ```
315
-
316
- The error response returned to the client:
317
-
318
- ```json
319
- {
320
- "message": "Payment failed",
321
- "status": 402,
322
- "data": {
323
- "why": "Card declined by issuer (insufficient funds)",
324
- "fix": "Try a different payment method or contact your bank",
325
- "link": "https://docs.example.com/payments/declined"
326
- }
327
- }
328
- ```
329
-
330
- > **Tip**: Always prefer `createError` from `c8y-nitro/utils`. It ensures the error is captured in the wide log event with full context, making investigation straightforward.
331
-
332
- ### createLogger (standalone)
333
-
334
- For code that runs **outside a request handler** — background jobs, queue workers, event-driven workflows, scheduled tasks — use `createLogger()` to get the same wide-event logger interface without needing an HTTP event.
335
-
336
- ```ts
337
- import { createLogger } from 'c8y-nitro/utils'
338
-
339
- export async function processSubscriptionRenewal(tenantId: string) {
340
- const log = createLogger({ job: 'subscription-renewal', tenantId })
341
-
342
- log.set({ subscription: { id: 'sub_123', plan: 'pro' } })
343
-
344
- // ... do work ...
345
-
346
- log.set({ result: 'renewed' })
347
- log.emit() // Must call emit() manually outside request context
348
- }
349
- ```
350
-
351
- This is useful for Cumulocity notification workflows where your microservice reacts to platform events (device management, alarms, etc.) outside of the standard request/response cycle.
352
-
353
- > **Note**: Unlike `useLogger`, `createLogger` does **not** auto-emit at request end. You must call `log.emit()` manually when the work is complete.
23
+ Full documentation is available at [schplitt.github.io/c8y-nitro](https://schplitt.github.io/c8y-nitro/).
354
24
 
355
- For more on wide events, structured errors, and advanced configuration (sampling, draining to Axiom/Loki, enrichers), see the [evlog documentation](https://www.evlog.dev/core-concepts/wide-events).
356
-
357
- ### Logging Utilities
358
-
359
- | Function | Description | Request Context |
360
- | ---------------------- | ------------------------------------------------------------- | :-------------: |
361
- | `useLogger(event)` | Get the request-scoped wide-event logger | ✅ |
362
- | `createLogger(ctx?)` | Create a standalone wide-event logger; call `emit()` manually | ❌ |
363
- | `createError(options)` | Create a structured error with `why`, `fix`, `link` | ❌ |
364
-
365
- ## Utilities
366
-
367
- `c8y-nitro` provides several utility functions to simplify common tasks in Cumulocity microservices.
368
-
369
- To use these utilities, simply import them from `c8y-nitro/utils`:
370
-
371
- ```ts
372
- import { useUser, useUserClient } from 'c8y-nitro/utils'
373
- ```
374
-
375
- ### Usage
376
-
377
- All utility functions that require request context accept either an `H3Event` or `ServerRequest` parameter:
378
-
379
- ```ts
380
- // Pass the event/request parameter
381
- export default defineHandler(async (event) => {
382
- const user = await useUser(event)
383
- const client = useUserClient(event)
384
- return { user }
385
- })
386
- ```
25
+ Useful entry points:
387
26
 
388
- **Optional: Using with `asyncContext`**
389
-
390
- If you enable `experimental.asyncContext: true` in your Nitro config, you can use Nitro's `useRequest()` to avoid passing the event through deeply nested function calls:
391
-
392
- ```ts
393
- import { useRequest } from 'nitro/context'
394
-
395
- export default defineHandler(async (event) => {
396
- // Deep nested function - no need to pass event down
397
- return await someDeepFunction()
398
- })
399
-
400
- async function someDeepFunction() {
401
- return await anotherFunction()
402
- }
403
-
404
- async function anotherFunction() {
405
- // Use useRequest() to get the request in any nested function
406
- const request = useRequest()
407
- const user = await useUser(request)
408
- return { user }
409
- }
410
- ```
411
-
412
- ### Credentials
413
-
414
- | Function | Description | Request Context |
415
- | ---------------------------------- | ------------------------------------------------------------------ | :-------------: |
416
- | `useSubscribedTenantCredentials()` | Get credentials for all subscribed tenants (cached, default 10min) | ❌ |
417
- | `useDeployedTenantCredentials()` | Get credentials for the deployed tenant (cached, default 10min) | ❌ |
418
- | `useUserTenantCredentials()` | Get credentials for the current user's tenant | ✅ |
419
-
420
- > **Note**: `useDeployedTenantCredentials()` shares its cache with `useSubscribedTenantCredentials()`. Both functions support `.invalidate()` and `.refresh()` methods. Invalidating or refreshing one will affect the other.
421
- >
422
- > **Cache Duration**: The cache TTL is configurable via the `cache.credentialsTTL` option or `NITRO_C8Y_CREDENTIALS_CACHE_TTL` environment variable. See [Cache Configuration](#cache-configuration) for details.
423
-
424
- ### Tenant Options
425
-
426
- | Function | Description | Request Context |
427
- | ------------------- | -------------------------------------------------------- | :-------------: |
428
- | `useTenantOption()` | Get a tenant option value by key (cached, default 10min) | ❌ |
429
-
430
- Fetch tenant options (settings) configured for your microservice:
431
-
432
- ```ts
433
- import { useTenantOption } from 'c8y-nitro/utils'
434
-
435
- export default defineHandler(async (event) => {
436
- // Fetch a tenant option
437
- const value = await useTenantOption('myOption')
438
-
439
- // Fetch an encrypted secret
440
- const secret = await useTenantOption('credentials.apiKey')
441
-
442
- // Cache management
443
- await useTenantOption.invalidate('myOption') // Invalidate specific key
444
- const fresh = await useTenantOption.refresh('myOption') // Force refresh
445
- await useTenantOption.invalidateAll() // Invalidate all accessed keys
446
- await useTenantOption.refreshAll() // Refresh all accessed keys
447
- return { value, secret, fresh }
448
- })
449
- ```
450
-
451
- Define your settings in the manifest to get type-safe keys:
452
-
453
- ```ts
454
- export default defineNitroConfig({
455
- c8y: {
456
- manifest: {
457
- settings: [
458
- { key: 'myOption', defaultValue: 'default' },
459
- { key: 'credentials.secret', defaultValue: 'change-me' }, // Encrypted option
460
- ],
461
- settingsCategory: 'my-service', // Optional, defaults to contextPath/name
462
- requiredRoles: ['ROLE_OPTION_MANAGEMENT_READ'], // Required for reading tenant options
463
- },
464
- },
465
- modules: [c8y()],
466
- })
467
- ```
468
-
469
- `manifest.settings[].defaultValue` is required and must be a non-empty string. `''` is rejected during manifest generation so invalid settings fail early during development/build.
470
-
471
- > **Important**: To read tenant options, your microservice **must** have the `ROLE_OPTION_MANAGEMENT_READ` role in `manifest.requiredRoles`. Without this role, API calls will fail with a 403 Forbidden error.
472
-
473
- > **Note on Encrypted Options**: Keys prefixed with `credentials.` are stored encrypted by Cumulocity. See more details [here](https://cumulocity.com/api/core/#operation/postOptionCollectionResource).
474
-
475
- > **Note on Missing Options**: If a tenant option is not set (404 Not Found), `useTenantOption()` returns `undefined` instead of throwing an error. Other errors (e.g., 403 Forbidden) are thrown normally.
476
-
477
- ### Resources
478
-
479
- | Function | Description | Request Context |
480
- | ---------------- | ---------------------------------- | :-------------: |
481
- | `useUser()` | Fetch current user from Cumulocity | ✅ |
482
- | `useUserRoles()` | Get roles of the current user | ✅ |
483
-
484
- ### Client
485
-
486
- | Function | Description | Request Context |
487
- | ------------------------------ | --------------------------------------------------- | :-------------: |
488
- | `useUserClient()` | Create client authenticated with user's credentials | ✅ |
489
- | `useUserTenantClient()` | Create client for user's tenant (microservice user) | ✅ |
490
- | `useSubscribedTenantClients()` | Create clients for all subscribed tenants | ❌ |
491
- | `useDeployedTenantClient()` | Create client for the deployed tenant | ❌ |
492
-
493
- ### Middleware
494
-
495
- | Function | Description | Request Context |
496
- | ------------------------------------------ | ----------------------------------------- | :-------------: |
497
- | `hasUserRequiredRole(role\|roles)` | Check if user has required role(s) | ✅ |
498
- | `isUserFromAllowedTenant(tenant\|tenants)` | Check if user is from allowed tenant(s) | ✅ |
499
- | `isUserFromDeployedTenant()` | Check if user is from the deployed tenant | ✅ |
500
-
501
- Probe requests targeting the manifest-defined `livenessProbe.httpGet.path` or `readinessProbe.httpGet.path` bypass these auth helpers so orchestration health checks are not blocked by route-wide access control.
502
-
503
- ## CLI Commands
504
-
505
- | Command | Description |
506
- | ----------- | ------------------------------------------------------- |
507
- | `bootstrap` | Manually register microservice and retrieve credentials |
508
- | `roles` | Manage development user roles |
509
- | `options` | Manage tenant options on development tenant |
510
-
511
- For more information, run:
512
-
513
- ```sh
514
- pnpm dlx c8y-nitro -h
515
- ```
27
+ - [What is c8y-nitro?](https://schplitt.github.io/c8y-nitro/guide/what-is-c8y-nitro)
28
+ - [Configuration](https://schplitt.github.io/c8y-nitro/guide/configuration)
29
+ - [Module Options](https://schplitt.github.io/c8y-nitro/reference/module-options)
30
+ - [Utilities](https://schplitt.github.io/c8y-nitro/reference/utilities)
516
31
 
517
32
  ## Development
518
33
 
34
+ For contributors working on this repository:
35
+
519
36
  ```sh
520
- # Install dependencies
521
37
  pnpm install
522
-
523
- # Run dev watcher
524
38
  pnpm dev
525
-
526
- # Build for production
527
39
  pnpm build
528
-
529
- # Run tests (watch mode)
530
- pnpm test
531
-
532
- # Run tests once
533
40
  pnpm test:run
41
+ pnpm lint
42
+ pnpm typecheck
43
+ pnpm docs:build
534
44
  ```
535
45
 
536
- ### Testing
537
-
538
- Tests are organized in two categories:
539
-
540
- - **Unit tests** (`tests/unit/`) — Test individual functions in isolation
541
- - **Server tests** (`tests/server/`) — Integration tests that spin up a Nitro dev server with the c8y-nitro module
542
-
543
- Server tests use Nitro's virtual modules to mock `@c8y/client` at build time, allowing full integration testing without real Cumulocity API calls. See [AGENTS.md](AGENTS.md#server-integration-tests) for implementation details.
46
+ Use `pnpm dev` for the package watcher, `pnpm docs:dev` for the VitePress site, and check the full contributor-facing behavior in the docs plus <AGENTS.md>.
544
47
 
545
48
  ## License
546
49
 
547
- MIT
50
+ Use `pnpm dev` for the package watcher, `pnpm docs:dev` for the VitePress site, and check the full contributor-facing behavior in the docs plus <AGENTS.md>.
@@ -1,4 +1,4 @@
1
- import { a as findMicroserviceByName, d as updateMicroservice, l as subscribeToApplication, n as createBasicAuthHeader, o as getBootstrapCredentials, p as createC8yManifest, r as createMicroservice } from "./c8y-api-BgTNTqHd.mjs";
1
+ import { a as findMicroserviceByName, d as updateMicroservice, l as subscribeToApplication, n as createBasicAuthHeader, o as getBootstrapCredentials, p as createC8yManifest, r as createMicroservice } from "./c8y-api-BbRS1-Ls.mjs";
2
2
  import { t as writeBootstrapCredentials } from "./env-file-B0BK-uZW.mjs";
3
3
  import { n as validateBootstrapEnv, t as loadC8yConfig } from "./config-BRnvtthI.mjs";
4
4
  import { defineCommand, runCommand } from "citty";
@@ -52,7 +52,7 @@ var bootstrap_default = defineCommand({
52
52
  });
53
53
  consola.success(`Bootstrap credentials written to ${envFileName}`);
54
54
  if (manifest.roles && manifest.roles.length > 0) {
55
- if (await consola.prompt("Do you want to manage microservice roles for your development user?", { type: "confirm" })) await runCommand(await import("./roles-CoBPqvDv.mjs").then((r) => r.default), { rawArgs: [] });
55
+ if (await consola.prompt("Do you want to manage microservice roles for your development user?", { type: "confirm" })) await runCommand(await import("./roles-D7rpbxPp.mjs").then((r) => r.default), { rawArgs: [] });
56
56
  }
57
57
  consola.success("Bootstrap complete!");
58
58
  }
@@ -6,6 +6,8 @@ const GENERATED_READINESS_ROUTE = "/_c8y_nitro/readiness";
6
6
  const GENERATED_INVALIDATE_TENANT_OPTIONS_ROUTE = "/_c8y_nitro/invalidate-tenant-options";
7
7
  //#endregion
8
8
  //#region src/module/manifest.ts
9
+ const ROLE_OPTION_MANAGEMENT_READ = "ROLE_OPTION_MANAGEMENT_READ";
10
+ const ROLE_OPTION_MANAGEMENT_ADMIN = "ROLE_OPTION_MANAGEMENT_ADMIN";
9
11
  function validateManifestSettings(options) {
10
12
  const invalidSettings = options.settings?.filter((setting) => typeof setting.defaultValue !== "string" || setting.defaultValue.length === 0) ?? [];
11
13
  if (invalidSettings.length === 0) return;
@@ -56,10 +58,16 @@ async function createC8yManifest(rootDir, options = {}, logger) {
56
58
  httpGet: { path: GENERATED_READINESS_ROUTE }
57
59
  };
58
60
  const key = `${name}-key`;
61
+ let requiredRoles = options.requiredRoles ? [...options.requiredRoles] : void 0;
62
+ if (options.settings && options.settings.length > 0 && !requiredRoles?.includes(ROLE_OPTION_MANAGEMENT_READ) && !requiredRoles?.includes(ROLE_OPTION_MANAGEMENT_ADMIN)) {
63
+ requiredRoles = [...requiredRoles ?? [], ROLE_OPTION_MANAGEMENT_READ];
64
+ logger?.debug(`Auto-added ${ROLE_OPTION_MANAGEMENT_READ} to requiredRoles because manifest.settings are defined`);
65
+ }
59
66
  const manifest = {
60
67
  ...restManifestFields,
61
68
  ...probeFields,
62
69
  ...options,
70
+ ...requiredRoles !== void 0 ? { requiredRoles } : {},
63
71
  provider,
64
72
  name,
65
73
  version,
@@ -1,4 +1,4 @@
1
- import { n as name, r as version, t as description } from "../package-bfMasPPg.mjs";
1
+ import { n as name, r as version, t as description } from "../package-DsLC9Mo3.mjs";
2
2
  import { defineCommand, runMain } from "citty";
3
3
  //#region src/cli/index.ts
4
4
  runMain(defineCommand({
@@ -8,9 +8,9 @@ runMain(defineCommand({
8
8
  description
9
9
  },
10
10
  subCommands: {
11
- bootstrap: () => import("../bootstrap-BSdVwFWO.mjs").then((r) => r.default),
12
- roles: () => import("../roles-CoBPqvDv.mjs").then((r) => r.default),
13
- options: () => import("../options-BkWL1FOa.mjs").then((r) => r.default)
11
+ bootstrap: () => import("../bootstrap-DUDpmXcU.mjs").then((r) => r.default),
12
+ roles: () => import("../roles-D7rpbxPp.mjs").then((r) => r.default),
13
+ options: () => import("../options-Web5UyIU.mjs").then((r) => r.default)
14
14
  }
15
15
  }));
16
16
  //#endregion
@@ -0,0 +1,6 @@
1
+ import { ICredentials } from "@c8y/client";
2
+
3
+ //#region src/types/credentials.d.ts
4
+ type TenantCredentials = Record<string, ICredentials>;
5
+ //#endregion
6
+ export { TenantCredentials as t };
@@ -411,7 +411,7 @@ interface C8yNitroModuleOptions {
411
411
  *
412
412
  * Query params:
413
413
  * - `all`: invalidate all created tenant option fetchers
414
- * - `key`: invalidate a single manifest-defined tenant option key if its fetcher exists
414
+ * - `key`: invalidate a single manifest-defined tenant option key if it exists
415
415
  *
416
416
  * `all` takes priority over `key`.
417
417
  * @default false
package/dist/index.d.mts CHANGED
@@ -1,4 +1,5 @@
1
- import { n as C8yNitroModuleOptions } from "./index-Bvu7DqDt.mjs";
1
+ import { n as C8yNitroModuleOptions } from "./index-uOwpI6rD.mjs";
2
+ import { t as TenantCredentials } from "./credentials-9FO7rTIR.mjs";
2
3
  import { NitroModule } from "nitro/types";
3
4
 
4
5
  //#region src/index.d.ts
@@ -7,6 +8,11 @@ declare module 'nitro/types' {
7
8
  c8y?: C8yNitroModuleOptions;
8
9
  }
9
10
  }
11
+ declare module 'nitro/types' {
12
+ interface NitroRuntimeHooks {
13
+ 'c8y:tenantCredentialsUpdated': (prev: TenantCredentials | null, next: TenantCredentials) => void;
14
+ }
15
+ }
10
16
  declare function c8y(): NitroModule;
11
17
  //#endregion
12
18
  export { c8y, c8y as default };
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { _ as GENERATED_READINESS_ROUTE, a as findMicroserviceByName, g as GENERATED_LIVENESS_ROUTE, h as GENERATED_INVALIDATE_TENANT_OPTIONS_ROUTE, l as subscribeToApplication, m as createC8yManifestFromNitro, n as createBasicAuthHeader, o as getBootstrapCredentials, p as createC8yManifest, r as createMicroservice } from "./c8y-api-BgTNTqHd.mjs";
1
+ import { _ as GENERATED_READINESS_ROUTE, a as findMicroserviceByName, g as GENERATED_LIVENESS_ROUTE, h as GENERATED_INVALIDATE_TENANT_OPTIONS_ROUTE, l as subscribeToApplication, m as createC8yManifestFromNitro, n as createBasicAuthHeader, o as getBootstrapCredentials, p as createC8yManifest, r as createMicroservice } from "./c8y-api-BbRS1-Ls.mjs";
2
2
  import { t as writeBootstrapCredentials } from "./env-file-B0BK-uZW.mjs";
3
- import { n as name } from "./package-bfMasPPg.mjs";
3
+ import { n as name } from "./package-DsLC9Mo3.mjs";
4
4
  import { basename, dirname, join, relative } from "node:path";
5
5
  import { mkdir, writeFile } from "node:fs/promises";
6
6
  import { x } from "tinyexec";
@@ -1,4 +1,4 @@
1
- import { c as getTenantOptionsByCategory, f as upsertTenantOption, i as deleteTenantOption, n as createBasicAuthHeader, p as createC8yManifest, s as getTenantOption } from "./c8y-api-BgTNTqHd.mjs";
1
+ import { c as getTenantOptionsByCategory, f as upsertTenantOption, i as deleteTenantOption, n as createBasicAuthHeader, p as createC8yManifest, s as getTenantOption } from "./c8y-api-BbRS1-Ls.mjs";
2
2
  import { n as validateBootstrapEnv, t as loadC8yConfig } from "./config-BRnvtthI.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  import { consola } from "consola";
@@ -1,6 +1,6 @@
1
1
  //#region package.json
2
2
  var name = "c8y-nitro";
3
- var version = "0.5.0";
3
+ var version = "0.6.0";
4
4
  var description = "Lightning fast Cumulocity IoT microservice development powered by Nitro";
5
5
  //#endregion
6
6
  export { name as n, version as r, description as t };
@@ -1,4 +1,4 @@
1
- import { n as createBasicAuthHeader, p as createC8yManifest, t as assignUserRole, u as unassignUserRole } from "./c8y-api-BgTNTqHd.mjs";
1
+ import { n as createBasicAuthHeader, p as createC8yManifest, t as assignUserRole, u as unassignUserRole } from "./c8y-api-BbRS1-Ls.mjs";
2
2
  import { n as validateBootstrapEnv, t as loadC8yConfig } from "./config-BRnvtthI.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  import { consola } from "consola";
package/dist/types.d.mts CHANGED
@@ -1,2 +1,3 @@
1
- import { a as C8YRoles, c as C8YManifest, i as C8YTenantOptionKeysCacheConfig, l as C8YManifestOptions, n as C8yNitroModuleOptions, o as C8yCacheOptions, r as C8YTenantOptionKey, s as C8YZipOptions, t as C8yDevOptions, u as C8YAPIClientOptions } from "./index-Bvu7DqDt.mjs";
2
- export { C8YAPIClientOptions, C8YManifest, C8YManifestOptions, C8YRoles, C8YTenantOptionKey, C8YTenantOptionKeysCacheConfig, C8YZipOptions, C8yCacheOptions, C8yDevOptions, C8yNitroModuleOptions };
1
+ import { a as C8YRoles, c as C8YManifest, i as C8YTenantOptionKeysCacheConfig, l as C8YManifestOptions, n as C8yNitroModuleOptions, o as C8yCacheOptions, r as C8YTenantOptionKey, s as C8YZipOptions, t as C8yDevOptions, u as C8YAPIClientOptions } from "./index-uOwpI6rD.mjs";
2
+ import { t as TenantCredentials } from "./credentials-9FO7rTIR.mjs";
3
+ export { C8YAPIClientOptions, C8YManifest, C8YManifestOptions, C8YRoles, C8YTenantOptionKey, C8YTenantOptionKeysCacheConfig, C8YZipOptions, C8yCacheOptions, C8yDevOptions, C8yNitroModuleOptions, TenantCredentials };
package/dist/utils.d.mts CHANGED
@@ -1,8 +1,9 @@
1
+ import { t as TenantCredentials } from "./credentials-9FO7rTIR.mjs";
1
2
  import { useLogger } from "evlog/nitro/v3";
2
3
  import { Client, ICredentials, ICurrentUser } from "@c8y/client";
3
4
  import { EventHandler, H3Event } from "nitro/h3";
4
5
  import { createError, createLogger } from "evlog";
5
- import { ServerRequest } from "nitro/types";
6
+ import { ServerRequest, TaskContext, TaskPayload } from "nitro/types";
6
7
  import { C8YRoles, C8YTenantOptionKey } from "c8y-nitro/types";
7
8
 
8
9
  //#region src/utils/client.d.ts
@@ -163,31 +164,9 @@ declare function useUser(requestOrEvent: ServerRequest | H3Event): Promise<ICurr
163
164
  declare function useUserRoles(requestOrEvent: ServerRequest | H3Event): Promise<string[]>;
164
165
  //#endregion
165
166
  //#region src/utils/credentials.d.ts
166
- /**
167
- * Fetches credentials for all tenants subscribed to this microservice.\
168
- * Uses bootstrap credentials from runtime config to query the microservice subscriptions API.\
169
- * Results are cached based on the configured TTL (default: 10 minutes).\
170
- * @returns Object mapping tenant IDs to their respective credentials
171
- * @config Cache TTL can be configured via:
172
- * - `c8y.cache.credentialsTTL` in the Nitro config (value in seconds)
173
- * - `NITRO_C8Y_CACHE_CREDENTIALS_TTL` environment variable
174
- * @example
175
- * // Get all subscribed tenant credentials:
176
- * const credentials = await useSubscribedTenantCredentials()
177
- * console.log(Object.keys(credentials)) // ['t12345', 't67890']
178
- *
179
- * // Access specific tenant:
180
- * const tenant1Creds = credentials['t12345']
181
- *
182
- * // Invalidate cache:
183
- * await useSubscribedTenantCredentials.invalidate()
184
- *
185
- * // Force refresh:
186
- * const freshCreds = await useSubscribedTenantCredentials.refresh()
187
- */
188
- declare const useSubscribedTenantCredentials: (() => Promise<Record<string, ICredentials>>) & {
167
+ declare const useSubscribedTenantCredentials: (() => Promise<TenantCredentials>) & {
189
168
  invalidate: () => Promise<void>;
190
- refresh: () => Promise<Record<string, ICredentials>>;
169
+ refresh: () => Promise<TenantCredentials>;
191
170
  };
192
171
  /**
193
172
  * Fetches credentials for the tenant where this microservice is deployed.\
@@ -285,4 +264,110 @@ declare const useTenantOption: ((key: C8YTenantOptionKey) => Promise<string | un
285
264
  refreshAll: () => Promise<Record<string, string | undefined>>;
286
265
  };
287
266
  //#endregion
288
- export { createError, createLogger, hasUserRequiredRole, isUserFromAllowedTenant, isUserFromDeployedTenant, useDeployedTenantClient, useDeployedTenantCredentials, useLogger, useSubscribedTenantClients, useSubscribedTenantCredentials, useTenantOption, useUser, useUserClient, useUserRoles, useUserTenantClient, useUserTenantCredentials };
267
+ //#region src/utils/schedule.d.ts
268
+ type ScheduledTaskPayload = TaskPayload;
269
+ type ScheduledTaskContext = TaskContext;
270
+ /**
271
+ * A one-shot schedule definition for `scheduleTask()`.\
272
+ * Numbers are treated as seconds from now, strings are parsed as human-readable durations
273
+ * such as `"10 minutes"`, and dates are used as exact run times.
274
+ */
275
+ type ScheduledTaskInput = Date | number | string;
276
+ /**
277
+ * Options for scheduling a Nitro task to run once in the future.\
278
+ * `payload` and `context` are forwarded to the task handler's `run()` method.
279
+ */
280
+ interface ScheduleTaskOptions {
281
+ /**
282
+ * Payload forwarded to the task handler's `TaskEvent.payload`.
283
+ */
284
+ payload?: ScheduledTaskPayload;
285
+ /**
286
+ * Context forwarded to the task handler's `TaskEvent.context`.
287
+ */
288
+ context?: ScheduledTaskContext;
289
+ /**
290
+ * When the task should run. Numbers are seconds from now.
291
+ */
292
+ schedule: ScheduledTaskInput;
293
+ }
294
+ /**
295
+ * Public information about a pending scheduled task.\
296
+ * Returned by `scheduleTask()` and by `listScheduledTasks()`.
297
+ */
298
+ interface ScheduledTaskInfo {
299
+ /**
300
+ * Stable UUID used to list or cancel the scheduled task.
301
+ */
302
+ id: string;
303
+ /**
304
+ * Nitro task name that will be passed to `runTask()`.
305
+ */
306
+ task: string;
307
+ /**
308
+ * Exact execution time as an ISO date string.
309
+ */
310
+ runAt: string;
311
+ }
312
+ /**
313
+ * Schedules a Nitro task to run once in the future.\
314
+ * Resolves and calls the task handler directly from Nitro's virtual task registry\
315
+ * Numbers are treated as seconds, strings are parsed as human-readable durations, and dates are used as exact run times.
316
+ *
317
+ * @param taskName - The Nitro task name to run (from `tasks/*.ts`)
318
+ * @param options - Task payload, context, and the schedule time
319
+ * @returns Information about the scheduled task
320
+ *
321
+ * @example
322
+ * // Run a task in 30 seconds:
323
+ * const scheduled = await scheduleTask('emails:send', {
324
+ * payload: { messageId: 'abc123' },
325
+ * schedule: 30,
326
+ * })
327
+ *
328
+ * @example
329
+ * // Run a task using a human-readable duration:
330
+ * await scheduleTask('reports:generate', {
331
+ * payload: { reportId: 'report-1' },
332
+ * schedule: '1 hour',
333
+ * })
334
+ *
335
+ * @example
336
+ * // Run a task at an exact time:
337
+ * await scheduleTask('cleanup:tenant', {
338
+ * payload: { tenant: 't12345' },
339
+ * schedule: new Date('2026-05-01T12:00:00Z'),
340
+ * })
341
+ */
342
+ declare function scheduleTask(taskName: string, options: ScheduleTaskOptions): Promise<ScheduledTaskInfo>;
343
+ /**
344
+ * Lists all tasks that are currently scheduled and have not started yet.\
345
+ * The returned object is keyed by the scheduled task UUID for easy lookup and cancellation.
346
+ *
347
+ * @returns Object mapping scheduled task IDs to their public task information
348
+ *
349
+ * @example
350
+ * const tasks = await listScheduledTasks()
351
+ * for (const [id, task] of Object.entries(tasks)) {
352
+ * console.log(id, task.task, task.runAt)
353
+ * }
354
+ */
355
+ declare function listScheduledTasks(): Promise<Record<string, ScheduledTaskInfo>>;
356
+ /**
357
+ * Cancels a scheduled task before it starts running.\
358
+ * Once the underlying Nitro task has started, it cannot be cancelled with this utility.
359
+ *
360
+ * @param id - The scheduled task UUID returned by `scheduleTask()` or `listScheduledTasks()`
361
+ * @returns `true` when a pending task was cancelled, otherwise `false`
362
+ *
363
+ * @example
364
+ * const scheduled = await scheduleTask('emails:send', {
365
+ * payload: { messageId: 'abc123' },
366
+ * schedule: '10 minutes',
367
+ * })
368
+ *
369
+ * const cancelled = await cancelScheduledTask(scheduled.id)
370
+ */
371
+ declare function cancelScheduledTask(id: string): Promise<boolean>;
372
+ //#endregion
373
+ export { ScheduleTaskOptions, ScheduledTaskContext, ScheduledTaskInfo, ScheduledTaskInput, ScheduledTaskPayload, cancelScheduledTask, createError, createLogger, hasUserRequiredRole, isUserFromAllowedTenant, isUserFromDeployedTenant, listScheduledTasks, scheduleTask, useDeployedTenantClient, useDeployedTenantCredentials, useLogger, useSubscribedTenantClients, useSubscribedTenantCredentials, useTenantOption, useUser, useUserClient, useUserRoles, useUserTenantClient, useUserTenantCredentials };
package/dist/utils.mjs CHANGED
@@ -3,12 +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
10
  import { c8yManifest } from "c8y-nitro/runtime";
11
11
  import { createError, createLogger } from "evlog";
12
+ import { ms } from "itty-time";
13
+ import { tasks } from "#nitro/virtual/tasks";
12
14
  //#region src/utils/internal/common.ts
13
15
  /**
14
16
  * Converts undici Request headers to the format expected by MicroserviceClientRequestAuth.\
@@ -83,6 +85,14 @@ const getCurrentUserTenantId = defineCachedFunction(async (requestOrEvent) => {
83
85
  });
84
86
  //#endregion
85
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
+ }
86
96
  /**
87
97
  * Fetches credentials for all tenants subscribed to this microservice.\
88
98
  * Uses bootstrap credentials from runtime config to query the microservice subscriptions API.\
@@ -90,7 +100,7 @@ const getCurrentUserTenantId = defineCachedFunction(async (requestOrEvent) => {
90
100
  * @returns Object mapping tenant IDs to their respective credentials
91
101
  * @config Cache TTL can be configured via:
92
102
  * - `c8y.cache.credentialsTTL` in the Nitro config (value in seconds)
93
- * - `NITRO_C8Y_CACHE_CREDENTIALS_TTL` environment variable
103
+ * - `NITRO_C8Y_CREDENTIALS_CACHE_TTL` environment variable
94
104
  * @example
95
105
  * // Get all subscribed tenant credentials:
96
106
  * const credentials = await useSubscribedTenantCredentials()
@@ -105,8 +115,8 @@ const getCurrentUserTenantId = defineCachedFunction(async (requestOrEvent) => {
105
115
  * // Force refresh:
106
116
  * const freshCreds = await useSubscribedTenantCredentials.refresh()
107
117
  */
108
- const useSubscribedTenantCredentials = Object.assign(defineCachedFunction(async () => {
109
- return (await Client.getMicroserviceSubscriptions({
118
+ const cachedSubscribedTenantCredentials = defineCachedFunction(async () => {
119
+ const newCredentials = (await Client.getMicroserviceSubscriptions({
110
120
  tenant: process.env.C8Y_BOOTSTRAP_TENANT,
111
121
  user: process.env.C8Y_BOOTSTRAP_USER,
112
122
  password: process.env.C8Y_BOOTSTRAP_PASSWORD
@@ -114,18 +124,26 @@ const useSubscribedTenantCredentials = Object.assign(defineCachedFunction(async
114
124
  if (cred.tenant) acc[cred.tenant] = cred;
115
125
  return acc;
116
126
  }, {});
127
+ if (shouldEmitTenantCredentialsUpdated(prevCredentials, newCredentials)) useNitroHooks().callHook("c8y:tenantCredentialsUpdated", prevCredentials, newCredentials);
128
+ prevCredentials = newCredentials;
129
+ return newCredentials;
117
130
  }, {
118
131
  maxAge: useRuntimeConfig().c8yCredentialsCacheTTL ?? 600,
119
132
  name: "_c8y_nitro_get_subscribed_tenant_credentials",
120
133
  group: "c8y_nitro",
121
134
  swr: false
122
- }), {
123
- invalidate: async () => {
124
- await useStorage("cache").removeItem(`c8y_nitro:functions:_c8y_nitro_get_subscribed_tenant_credentials.json`);
125
- },
135
+ });
136
+ const useSubscribedTenantCredentials = Object.assign(async () => {
137
+ const credentials = await cachedSubscribedTenantCredentials();
138
+ prevCredentials = credentials;
139
+ return credentials;
140
+ }, {
141
+ invalidate: cachedSubscribedTenantCredentials.invalidate,
126
142
  refresh: async () => {
127
- await useSubscribedTenantCredentials.invalidate();
128
- return await useSubscribedTenantCredentials();
143
+ await cachedSubscribedTenantCredentials.invalidate();
144
+ const credentials = await cachedSubscribedTenantCredentials();
145
+ prevCredentials = credentials;
146
+ return credentials;
129
147
  }
130
148
  });
131
149
  /**
@@ -158,7 +176,7 @@ const useDeployedTenantCredentials = Object.assign(async () => {
158
176
  }, {
159
177
  invalidate: useSubscribedTenantCredentials.invalidate,
160
178
  refresh: async () => {
161
- await useDeployedTenantCredentials.invalidate();
179
+ await useSubscribedTenantCredentials.refresh();
162
180
  return await useDeployedTenantCredentials();
163
181
  }
164
182
  });
@@ -402,7 +420,7 @@ function getTenantOptionCacheTTL(key) {
402
420
  */
403
421
  function createCachedTenantOptionFetcher(key) {
404
422
  const cacheName = `_c8y_nitro_tenant_option_${key.replace(/\./g, "_")}`;
405
- const fetcher = defineCachedFunction(async () => {
423
+ const cachedFetcher = defineCachedFunction(async () => {
406
424
  const client = await useDeployedTenantClient();
407
425
  const category = useRuntimeConfig().c8ySettingsCategory;
408
426
  try {
@@ -420,17 +438,10 @@ function createCachedTenantOptionFetcher(key) {
420
438
  group: "c8y_nitro",
421
439
  swr: false
422
440
  });
423
- return Object.assign(fetcher, {
424
- invalidate: async () => {
425
- const completeKey = `c8y_nitro:functions:${cacheName}.json`;
426
- await useStorage("cache").removeItem(completeKey);
427
- },
428
- refresh: async () => {
429
- const completeKey = `c8y_nitro:functions:${cacheName}.json`;
430
- await useStorage("cache").removeItem(completeKey);
431
- return await fetcher();
432
- }
433
- });
441
+ return Object.assign(cachedFetcher, { refresh: async () => {
442
+ await cachedFetcher.invalidate();
443
+ return await cachedFetcher();
444
+ } });
434
445
  }
435
446
  /**
436
447
  * Gets or creates a cached fetcher for a specific tenant option key.
@@ -479,16 +490,33 @@ function getOrCreateFetcher(key) {
479
490
  const useTenantOption = Object.assign(async (key) => {
480
491
  return await getOrCreateFetcher(key)();
481
492
  }, {
493
+ /**
494
+ * Invalidate the cache for a specific tenant option key.
495
+ * @param key - The tenant option key to invalidate
496
+ */
482
497
  invalidate: async (key) => {
483
498
  const fetcher = tenantOptionFetchers[key];
484
499
  if (fetcher) await fetcher.invalidate();
485
500
  },
501
+ /**
502
+ * Force refresh a specific tenant option key (invalidates and re-fetches).
503
+ * @param key - The tenant option key to refresh
504
+ */
486
505
  refresh: async (key) => {
487
506
  return await getOrCreateFetcher(key).refresh();
488
507
  },
508
+ /**
509
+ * Invalidate all tenant option caches that have been accessed.
510
+ * Only invalidates keys that have been fetched at least once.
511
+ */
489
512
  invalidateAll: async () => {
490
513
  await Promise.all(Object.values(tenantOptionFetchers).map((fetcher) => fetcher?.invalidate()));
491
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
+ */
492
520
  refreshAll: async () => {
493
521
  const entries = Object.entries(tenantOptionFetchers);
494
522
  const values = await Promise.all(entries.map(([, fetcher]) => fetcher?.refresh()));
@@ -496,4 +524,168 @@ const useTenantOption = Object.assign(async (key) => {
496
524
  }
497
525
  });
498
526
  //#endregion
499
- 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.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Lightning fast Cumulocity IoT microservice development powered by Nitro",
6
6
  "keywords": [
@@ -38,31 +38,34 @@
38
38
  "dist"
39
39
  ],
40
40
  "dependencies": {
41
- "c12": "^4.0.0-beta.4",
41
+ "c12": "^4.0.0-beta.5",
42
42
  "citty": "^0.2.2",
43
43
  "consola": "^3.4.2",
44
- "evlog": "^2.11.1",
44
+ "evlog": "^2.17.0",
45
+ "itty-time": "^2.0.2",
45
46
  "jszip": "^3.10.1",
46
47
  "pathe": "^2.0.3",
47
- "pkg-types": "^2.3.0",
48
+ "pkg-types": "^2.3.1",
48
49
  "spinnies": "^0.5.1",
49
- "tinyexec": "^1.1.1",
50
- "tsnapi": "^0.1.1"
50
+ "tinyexec": "^1.1.2"
51
51
  },
52
52
  "devDependencies": {
53
- "@schplitt/eslint-config": "^1.3.1",
53
+ "@schplitt/eslint-config": "^1.5.0",
54
+ "@types/node": "^24.12.3",
54
55
  "@types/spinnies": "^0.5.3",
55
- "bumpp": "^11.0.1",
56
+ "bumpp": "^11.1.0",
56
57
  "changelogithub": "^14.0.0",
57
- "eslint": "^10.2.0",
58
- "memfs": "^4.57.1",
59
- "tsdown": "^0.21.7",
60
- "typescript": "^5.9.3",
61
- "vitest": "^4.1.4"
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"
62
65
  },
63
66
  "peerDependencies": {
64
67
  "@c8y/client": ">=1021",
65
- "nitro": "3.0.260311-beta"
68
+ "nitro": "3.0.260429-beta"
66
69
  },
67
70
  "engines": {
68
71
  "node": ">=24.0.0"
@@ -77,6 +80,9 @@
77
80
  "prerelease": "eslint && tsc --noEmit && tsdown && vitest run",
78
81
  "release": "bumpp",
79
82
  "test": "vitest",
80
- "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"
81
87
  }
82
88
  }