c8y-nitro 0.8.0 → 0.9.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.
@@ -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-DscUs-Kd.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-DpCWhaJU.mjs";
2
2
  import { t as writeBootstrapCredentials } from "./env-file-BrOTmVjA.mjs";
3
3
  import { join } from "node:path";
4
4
  import { mkdir, readFile, writeFile } from "node:fs/promises";
@@ -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-DscUs-Kd.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-DpCWhaJU.mjs";
2
2
  import { t as writeBootstrapCredentials } from "./env-file-BrOTmVjA.mjs";
3
3
  import { n as validateBootstrapEnv, t as loadC8yConfig } from "./config-BVQHqlgV.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-B-DNuScZ.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-CkUvL64s.mjs").then((r) => r.default), { rawArgs: [] });
56
56
  }
57
57
  consola.success("Bootstrap complete!");
58
58
  }
@@ -9,6 +9,13 @@ const GENERATED_INVALIDATE_TENANT_OPTIONS_ROUTE = "/_c8y_nitro/invalidate-tenant
9
9
  const DEFAULT_OPENAPI_ROUTE = "/_openapi.json";
10
10
  const ROLE_OPTION_MANAGEMENT_READ = "ROLE_OPTION_MANAGEMENT_READ";
11
11
  /**
12
+ * Cumulocity microservice name constraints: lowercase letters, digits and
13
+ * hyphens only, 1-23 characters. Enforced by the platform on upload, so we
14
+ * fail early with a clear message instead of a cryptic API rejection.
15
+ * @see https://github.com/schplitt/c8y-nitro/issues/75
16
+ */
17
+ const MICROSERVICE_NAME_REGEX = /^[a-z0-9-]{1,23}$/;
18
+ /**
12
19
  * Decide whether Nitro will expose an OpenAPI spec in the deployed build,
13
20
  * and if so return the configured route (with leading slash).
14
21
  * Returns null when OpenAPI is not enabled for production.
@@ -45,6 +52,7 @@ async function readPackageJsonFieldsForManifest(rootDir, logger) {
45
52
  const authorEmail = typeof author === "string" ? void 0 : author?.email;
46
53
  const authorUrl = typeof author === "string" ? void 0 : author?.url;
47
54
  if (!name || !version || !authorName) throw new Error("package.json must contain name, version, and author name fields");
55
+ if (!MICROSERVICE_NAME_REGEX.test(name)) throw new Error(`Invalid microservice name "${name}". Cumulocity requires the name to match ${MICROSERVICE_NAME_REGEX.source} (lowercase letters, digits and hyphens only, 1-23 characters). Adjust the "name" field in package.json accordingly (scope like "@org/" is stripped and does not count).`);
48
56
  const support = typeof pkg.bugs === "string" ? pkg.bugs : pkg.bugs?.url ?? pkg.bugs?.email;
49
57
  const provider = {
50
58
  name: authorName ?? name,
@@ -1,4 +1,4 @@
1
- import { n as name, r as version, t as description } from "../package-CNWz76PV.mjs";
1
+ import { n as name, r as version, t as description } from "../package-gK1cGX1w.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-C1Qly0J_.mjs").then((r) => r.default),
12
- roles: () => import("../roles-B-DNuScZ.mjs").then((r) => r.default),
13
- options: () => import("../options-CmLpZ3Qf.mjs").then((r) => r.default)
11
+ bootstrap: () => import("../bootstrap-BcTCUiqG.mjs").then((r) => r.default),
12
+ roles: () => import("../roles-CkUvL64s.mjs").then((r) => r.default),
13
+ options: () => import("../options-BBnR-y_W.mjs").then((r) => r.default)
14
14
  }
15
15
  }));
16
16
  //#endregion
@@ -8,6 +8,18 @@ import { useRuntimeConfig } from "nitro/runtime-config";
8
8
  import { useNitroHooks } from "nitro/app";
9
9
  //#region src/utils/internal/common.ts
10
10
  /**
11
+ * Whether two sets of tenant credentials are equivalent for the purposes of
12
+ * change detection: same tenant, user, and password. Used to detect a
13
+ * service-user password rotation (which keeps the tenant id but changes the
14
+ * secret), not just tenants entering/leaving the subscription set.
15
+ * @param a - First credentials, or `undefined` when the tenant is absent
16
+ * @param b - Second credentials, or `undefined` when the tenant is absent
17
+ */
18
+ function credsEqual(a, b) {
19
+ if (!a || !b) return a === b;
20
+ return a.tenant === b.tenant && a.user === b.user && a.password === b.password;
21
+ }
22
+ /**
11
23
  * Converts undici Request headers to the format expected by MicroserviceClientRequestAuth.\
12
24
  * Extracts the following headers from the request:
13
25
  * - `authorization`: Used for Basic Auth or Bearer token authentication
@@ -145,10 +157,9 @@ function buildPublicRequestUrl(tenantDomain, contextPath, url) {
145
157
  let prevCredentials = null;
146
158
  function shouldEmitTenantCredentialsUpdated(prev, next) {
147
159
  if (!prev) return true;
148
- const prevTenantIds = Object.keys(prev);
149
- const nextTenantIds = Object.keys(next);
150
- if (prevTenantIds.length !== nextTenantIds.length) return true;
151
- return new Set(nextTenantIds).symmetricDifference(new Set(prevTenantIds)).size > 0;
160
+ const tenantIds = /* @__PURE__ */ new Set([...Object.keys(prev), ...Object.keys(next)]);
161
+ for (const tenantId of tenantIds) if (!credsEqual(prev[tenantId], next[tenantId])) return true;
162
+ return false;
152
163
  }
153
164
  /**
154
165
  * Fetches credentials for all tenants subscribed to this microservice.\
@@ -1,4 +1,5 @@
1
1
  import "./credentials-eKnJ7m9Y.mjs";
2
+ import { ConsumerResilienceOptions, SubscriptionApi } from "c8y-realtime";
2
3
  import { C8YTenantOptionKeysCacheConfig } from "c8y-nitro/types";
3
4
  //#region src/types/apiClient.d.ts
4
5
  interface C8YAPIClientOptions {
@@ -15,6 +16,34 @@ interface C8YAPIClientOptions {
15
16
  contextPath?: string;
16
17
  }
17
18
  //#endregion
19
+ //#region src/types/roles.generated.d.ts
20
+ /**
21
+ * AUTO-GENERATED by scripts/generate-roles.ts — do not edit by hand.
22
+ *
23
+ * Source: https://cumulocity.com/api/core/dist/c8y-oas.json
24
+ * Spec version: Latest
25
+ * Known roles: 46
26
+ *
27
+ * Run `pnpm gen:roles` to regenerate.
28
+ */
29
+ /**
30
+ * Cumulocity platform roles ("required roles") known from the core OpenAPI spec.
31
+ */
32
+ type C8YKnownRole = 'ROLE_ALARM_ADMIN' | 'ROLE_ALARM_READ' | 'ROLE_APPLICATION_MANAGEMENT_ADMIN' | 'ROLE_APPLICATION_MANAGEMENT_READ' | 'ROLE_AUDIT_ADMIN' | 'ROLE_AUDIT_READ' | 'ROLE_BINARY_ADMIN' | 'ROLE_BINARY_CREATE' | 'ROLE_BINARY_READ' | 'ROLE_BULK_OPERATION_ADMIN' | 'ROLE_BULK_OPERATION_READ' | 'ROLE_DEVICE' | 'ROLE_DEVICE_BOOTSTRAP' | 'ROLE_DEVICE_CONTROL_ADMIN' | 'ROLE_DEVICE_CONTROL_READ' | 'ROLE_EVENT_ADMIN' | 'ROLE_EVENT_READ' | 'ROLE_IDENTITY_ADMIN' | 'ROLE_IDENTITY_READ' | 'ROLE_INVENTORY_ADMIN' | 'ROLE_INVENTORY_CREATE' | 'ROLE_INVENTORY_READ' | 'ROLE_MANAGED_OBJECT_ADMIN' | 'ROLE_MANAGED_OBJECT_CREATE' | 'ROLE_MANAGED_OBJECT_READ' | 'ROLE_MEASUREMENT_ADMIN' | 'ROLE_MEASUREMENT_READ' | 'ROLE_NOTIFICATION_2_ADMIN' | 'ROLE_OPTION_MANAGEMENT_ADMIN' | 'ROLE_OPTION_MANAGEMENT_READ' | 'ROLE_RETENTION_RULE_ADMIN' | 'ROLE_RETENTION_RULE_READ' | 'ROLE_SMS_READ' | 'ROLE_SYSTEM' | 'ROLE_TENANT_ADMIN' | 'ROLE_TENANT_ALARM_ADMIN' | 'ROLE_TENANT_MANAGEMENT_ADMIN' | 'ROLE_TENANT_MANAGEMENT_CREATE' | 'ROLE_TENANT_MANAGEMENT_READ' | 'ROLE_TENANT_MANAGEMENT_UPDATE' | 'ROLE_TENANT_STATISTICS_READ' | 'ROLE_USER_MANAGEMENT_ADMIN' | 'ROLE_USER_MANAGEMENT_CREATE' | 'ROLE_USER_MANAGEMENT_OWN_ADMIN' | 'ROLE_USER_MANAGEMENT_OWN_READ' | 'ROLE_USER_MANAGEMENT_READ';
33
+ //#endregion
34
+ //#region src/types/roles.d.ts
35
+ /**
36
+ * Roles your microservice provides. Populated at runtime from the manifest's
37
+ * `roles` field (see the module's generated `c8y-nitro.d.ts`).
38
+ */
39
+ interface C8YRoles$1 {}
40
+ /**
41
+ * A Cumulocity role usable in the manifest. Autocompletes the platform roles
42
+ * known from the OpenAPI spec ({@link C8YKnownRole}) while still accepting any
43
+ * other string (custom roles, or roles not yet in the generated list).
44
+ */
45
+ type C8YRole$1 = C8YKnownRole | (string & {});
46
+ //#endregion
18
47
  //#region src/types/manifest.d.ts
19
48
  type C8YManifestOptions = Omit<C8YManifest, 'name' | 'version' | 'apiVersion' | 'key' | 'type' | 'provider' | 'openApiSpec'>;
20
49
  interface C8YManifest {
@@ -105,7 +134,7 @@ interface C8YManifest {
105
134
  * Needed to access Cumulocity APIs (inventory, alarms, etc.).
106
135
  * @example ["ROLE_ALARM_READ", "ROLE_INVENTORY_ADMIN"]
107
136
  */
108
- requiredRoles?: string[];
137
+ requiredRoles?: C8YRole$1[];
109
138
  /**
110
139
  * New permissions provided by this microservice.
111
140
  * Users can be assigned these to access your endpoints.
@@ -407,8 +436,78 @@ interface C8yDockerOptions {
407
436
  extraInstructions?: string[];
408
437
  }
409
438
  //#endregion
410
- //#region src/types/roles.d.ts
411
- interface C8YRoles$1 {}
439
+ //#region src/types/realtime.d.ts
440
+ /**
441
+ * Options for the Notification 2.0 realtime clients (backed by `c8y-realtime`).
442
+ *
443
+ * Resolved once at build time into runtime config and spread **identically**
444
+ * into every per-tenant `RealtimeClient` the pool creates — so all subscribed
445
+ * tenants behave consistently in a multi-tenant deployment. Only the
446
+ * credentials differ per tenant.
447
+ */
448
+ interface C8yRealtimeOptions {
449
+ /**
450
+ * Base name for this app's realtime topics/consumers (must be alphanumeric).
451
+ *
452
+ * Leave unset to derive it deterministically from the microservice
453
+ * `contextPath` (a short hash is appended so distinct services never collide
454
+ * on one platform topic). The derived name is **stable** across restarts and
455
+ * redeploys, so a reconnecting service resumes the same persistent
456
+ * subscription and its backlog. Set this only if you need to pin the name
457
+ * explicitly; it is a permanent identity — changing it later orphans the old
458
+ * subscriptions.
459
+ */
460
+ name?: string;
461
+ /**
462
+ * Delete **every** remote subscription this service created when Nitro shuts
463
+ * down. Keep `false` so persistent backlogs survive a redeploy.
464
+ * @default false
465
+ */
466
+ deleteSubscriptionsOnClose?: boolean;
467
+ /**
468
+ * Delete a `(type, device)` remote subscription once its last handler is
469
+ * removed. This is the only path that deletes a remote subscription — tenant
470
+ * churn never does.
471
+ * @default true
472
+ */
473
+ deleteSubscriptionOnEmpty?: boolean;
474
+ /**
475
+ * Acknowledge each notification after its handlers resolve.
476
+ * @default true
477
+ */
478
+ autoAck?: boolean;
479
+ /**
480
+ * Drop duplicate notifications redelivered on the same stream (e.g. on
481
+ * reconnect).
482
+ * @default true
483
+ */
484
+ dedupe?: boolean;
485
+ /**
486
+ * Open the connection automatically as handlers are registered. Set `false`
487
+ * to connect manually via the client's `start()`.
488
+ * @default true
489
+ */
490
+ autoStart?: boolean;
491
+ /**
492
+ * Subscription delivery mode applied to every tenant's client.
493
+ */
494
+ subscription?: {
495
+ /**
496
+ * APIs for the `onAny` firehose topic. Typed topics always use their own
497
+ * type. Defaults to `['*']`.
498
+ */
499
+ apis?: SubscriptionApi[];
500
+ /**
501
+ * Target a non-persistent topic (no server-side backlog; cheaper, but a
502
+ * disconnect loses messages). Defaults to `false`.
503
+ */
504
+ nonPersistent?: boolean;
505
+ };
506
+ /**
507
+ * Reconnect/backoff behaviour, applied identically to every tenant's client.
508
+ */
509
+ resilience?: ConsumerResilienceOptions;
510
+ }
412
511
  //#endregion
413
512
  //#region src/types/tenantOptions.d.ts
414
513
  /**
@@ -466,6 +565,11 @@ interface C8yNitroModuleOptions {
466
565
  */
467
566
  docker?: C8yDockerOptions;
468
567
  cache?: C8yCacheOptions;
568
+ /**
569
+ * Options for the Notification 2.0 realtime clients (backed by `c8y-realtime`),
570
+ * shared by every subscribed tenant's pooled client.
571
+ */
572
+ realtime?: C8yRealtimeOptions;
469
573
  /**
470
574
  * Controls how the OpenAPI document served by Nitro (`experimental.openAPI`)
471
575
  * is transformed for Cumulocity microservices.
@@ -494,4 +598,4 @@ interface C8yNitroModuleOptions {
494
598
  skipBootstrap?: boolean;
495
599
  }
496
600
  //#endregion
497
- export { C8YTenantOptionKey$1 as a, C8yDockerOptions as c, C8YManifest as d, C8YManifestOptions as f, C8YSettingsCategory$1 as i, C8yCacheOptions as l, C8yNitroModuleOptions as n, C8YTenantOptionKeysCacheConfig$1 as o, C8YAPIClientOptions as p, C8yOpenAPIOptions as r, C8YRoles$1 as s, C8yDevOptions as t, C8YZipOptions as u };
601
+ export { C8YTenantOptionKey$1 as a, C8yDockerOptions as c, C8YManifest as d, C8YManifestOptions as f, C8YAPIClientOptions as g, C8YKnownRole as h, C8YSettingsCategory$1 as i, C8yCacheOptions as l, C8YRoles$1 as m, C8yNitroModuleOptions as n, C8YTenantOptionKeysCacheConfig$1 as o, C8YRole$1 as p, C8yOpenAPIOptions as r, C8yRealtimeOptions as s, C8yDevOptions as t, C8YZipOptions as u };
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { n as C8yNitroModuleOptions } from "./index-vu4xaFkz.mjs";
1
+ import { n as C8yNitroModuleOptions } from "./index-Dil1PcDr.mjs";
2
2
  import { t as TenantCredentials } from "./credentials-eKnJ7m9Y.mjs";
3
3
  import { NitroModule } from "nitro/types";
4
4
  //#region src/index.d.ts
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { _ as GENERATED_READINESS_ROUTE, g as GENERATED_LIVENESS_ROUTE, h as GENERATED_INVALIDATE_TENANT_OPTIONS_ROUTE, m as createC8yManifestFromNitro, p as createC8yManifest } from "./c8y-api-DscUs-Kd.mjs";
2
- import { t as autoBootstrap } from "./autoBootstrap-Drk7ltXK.mjs";
3
- import { n as name } from "./package-CNWz76PV.mjs";
1
+ import { _ as GENERATED_READINESS_ROUTE, g as GENERATED_LIVENESS_ROUTE, h as GENERATED_INVALIDATE_TENANT_OPTIONS_ROUTE, m as createC8yManifestFromNitro, p as createC8yManifest } from "./c8y-api-DpCWhaJU.mjs";
2
+ import { t as autoBootstrap } from "./autoBootstrap-qCcCgset.mjs";
3
+ import { n as name } from "./package-gK1cGX1w.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";
@@ -10,6 +10,8 @@ import JSZip from "jszip";
10
10
  import Spinnies from "spinnies";
11
11
  import { colors } from "consola/utils";
12
12
  import { mkdirSync, writeFileSync } from "fs";
13
+ import { isValidRealtimeName, toRealtimeName } from "c8y-realtime";
14
+ import { createHash } from "node:crypto";
13
15
  import { fileURLToPath } from "node:url";
14
16
  import evlog from "evlog/nitro/v3";
15
17
  //#region src/module/apiClient.ts
@@ -409,6 +411,24 @@ export const c8yTenantOptionKeys = [${settingKeys.map((key) => `'${key}'`).join(
409
411
  //#endregion
410
412
  //#region src/module/runtimeConfig.ts
411
413
  /**
414
+ * Derive a stable, unique realtime client name from the microservice identity
415
+ * (its `contextPath`, falling back to `name`).
416
+ *
417
+ * The identity is sanitized to Cumulocity's alphanumeric name rule via
418
+ * `toRealtimeName`, then a short deterministic hash of the **raw** identity is
419
+ * appended. Sanitizing is lossy (`my-svc` and `my_svc` both become `mysvc`), so
420
+ * the hash restores the per-service uniqueness the `contextPath` guaranteed —
421
+ * without it, two different services could share one platform topic and clobber
422
+ * each other's subscriptions. Being deterministic, the name is identical across
423
+ * restarts and redeploys, so a reconnecting service resumes the same persistent
424
+ * subscription (and its backlog). This scheme is a permanent identity contract:
425
+ * changing it orphans existing subscriptions.
426
+ * @param identity - The microservice `contextPath` (or `name`)
427
+ */
428
+ function deriveRealtimeName(identity) {
429
+ return `${toRealtimeName(identity)}${createHash("sha256").update(identity).digest("hex").slice(0, 8)}`;
430
+ }
431
+ /**
412
432
  * Sets up runtime configuration values from module options.
413
433
  * These can be overridden by environment variables.
414
434
  * @param nitro - The Nitro instance
@@ -421,6 +441,19 @@ async function setupRuntimeConfig(nitro, options) {
421
441
  nitro.options.runtimeConfig.c8yTenantOptionsPerKeyTTL = options.cache?.tenantOptions ?? {};
422
442
  const manifest = await createC8yManifest(nitro.options.rootDir, nitro.options, nitro.logger);
423
443
  nitro.options.runtimeConfig.c8ySettingsCategory = options.manifest?.settingsCategory ?? manifest.contextPath ?? manifest.name;
444
+ const realtime = options.realtime ?? {};
445
+ const realtimeName = realtime.name ?? deriveRealtimeName(manifest.contextPath ?? manifest.name);
446
+ if (!isValidRealtimeName(realtimeName)) throw new Error(`c8y-nitro: invalid realtime name ${JSON.stringify(realtimeName)} — must be alphanumeric (^[a-zA-Z0-9]+\$). Set \`c8y.realtime.name\` to a valid value.`);
447
+ nitro.options.runtimeConfig.c8yRealtime = {
448
+ name: realtimeName,
449
+ autoAck: realtime.autoAck ?? true,
450
+ dedupe: realtime.dedupe ?? true,
451
+ autoStart: realtime.autoStart ?? true,
452
+ deleteSubscriptionOnEmpty: realtime.deleteSubscriptionOnEmpty ?? true,
453
+ deleteSubscriptionsOnClose: realtime.deleteSubscriptionsOnClose ?? false,
454
+ subscription: realtime.subscription,
455
+ resilience: realtime.resilience
456
+ };
424
457
  const excludeInternalRoutes = options.openapi?.excludeInternalRoutes ?? true;
425
458
  const probePaths = [options.manifest?.livenessProbe?.httpGet?.path, options.manifest?.readinessProbe?.httpGet?.path].filter((path) => Boolean(path));
426
459
  nitro.options.runtimeConfig.c8yOpenApiExcludeRoutes = [...excludeInternalRoutes ? ["/_", ...probePaths] : [], ...options.openapi?.excludeRoutes ?? []];
@@ -1,5 +1,5 @@
1
- import { c as getTenantOptionsByCategory, f as upsertTenantOption, i as deleteTenantOption, n as createBasicAuthHeader, p as createC8yManifest, s as getCurrentApplicationSettings } from "./c8y-api-DscUs-Kd.mjs";
2
- import { n as readBootstrapCredentialsFromEnv, r as syncBootstrappedMicroservice } from "./autoBootstrap-Drk7ltXK.mjs";
1
+ import { c as getTenantOptionsByCategory, f as upsertTenantOption, i as deleteTenantOption, n as createBasicAuthHeader, p as createC8yManifest, s as getCurrentApplicationSettings } from "./c8y-api-DpCWhaJU.mjs";
2
+ import { n as readBootstrapCredentialsFromEnv, r as syncBootstrappedMicroservice } from "./autoBootstrap-qCcCgset.mjs";
3
3
  import { n as validateBootstrapEnv, t as loadC8yConfig } from "./config-BVQHqlgV.mjs";
4
4
  import process from "node:process";
5
5
  import { defineCommand } from "citty";
@@ -1,6 +1,6 @@
1
1
  //#region package.json
2
2
  var name = "c8y-nitro";
3
- var version = "0.8.0";
3
+ var version = "0.9.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-DscUs-Kd.mjs";
1
+ import { n as createBasicAuthHeader, p as createC8yManifest, t as assignUserRole, u as unassignUserRole } from "./c8y-api-DpCWhaJU.mjs";
2
2
  import { n as validateBootstrapEnv, t as loadC8yConfig } from "./config-BVQHqlgV.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  import { consola } from "consola";
@@ -1,5 +1,5 @@
1
1
  import { t as createError } from "../../logging-BUYT8Z2V.mjs";
2
- import { t as useDeployedTenantClient } from "../../client-D0Tdq7H1.mjs";
2
+ import { t as useDeployedTenantClient } from "../../client-DCov9jV6.mjs";
3
3
  import { c8yTenantOptionKeys } from "c8y-nitro/runtime";
4
4
  import { defineCachedFunction } from "nitro/cache";
5
5
  import { useRuntimeConfig } from "nitro/runtime-config";
@@ -1,4 +1,4 @@
1
- import { r as tryGetUserTenantDomainFast } from "../../client-D0Tdq7H1.mjs";
1
+ import { r as tryGetUserTenantDomainFast } from "../../client-DCov9jV6.mjs";
2
2
  import { c8yManifest } from "c8y-nitro/runtime";
3
3
  import { useRuntimeConfig } from "nitro/runtime-config";
4
4
  import { getRequestURL } from "nitro/h3";
@@ -1,4 +1,4 @@
1
- import { n as buildPublicRequestUrl, r as tryGetUserTenantDomainFast } from "../../client-D0Tdq7H1.mjs";
1
+ import { n as buildPublicRequestUrl, r as tryGetUserTenantDomainFast } from "../../client-DCov9jV6.mjs";
2
2
  import { c8yManifest } from "c8y-nitro/runtime";
3
3
  import { defineMiddleware } from "nitro";
4
4
  //#region src/module/runtime/handlers/rewriteRequestUrl.ts
package/dist/types.d.mts CHANGED
@@ -1,3 +1,3 @@
1
- import { a as C8YTenantOptionKey, c as C8yDockerOptions, d as C8YManifest, f as C8YManifestOptions, i as C8YSettingsCategory, l as C8yCacheOptions, n as C8yNitroModuleOptions, o as C8YTenantOptionKeysCacheConfig, p as C8YAPIClientOptions, r as C8yOpenAPIOptions, s as C8YRoles, t as C8yDevOptions, u as C8YZipOptions } from "./index-vu4xaFkz.mjs";
1
+ import { a as C8YTenantOptionKey, c as C8yDockerOptions, d as C8YManifest, f as C8YManifestOptions, g as C8YAPIClientOptions, h as C8YKnownRole, i as C8YSettingsCategory, l as C8yCacheOptions, m as C8YRoles, n as C8yNitroModuleOptions, o as C8YTenantOptionKeysCacheConfig, p as C8YRole, r as C8yOpenAPIOptions, s as C8yRealtimeOptions, t as C8yDevOptions, u as C8YZipOptions } from "./index-Dil1PcDr.mjs";
2
2
  import { t as TenantCredentials } from "./credentials-eKnJ7m9Y.mjs";
3
- export { C8YAPIClientOptions, type C8YManifest, type C8YManifestOptions, C8YRoles, C8YSettingsCategory, C8YTenantOptionKey, C8YTenantOptionKeysCacheConfig, type C8YZipOptions, type C8yCacheOptions, C8yDevOptions, type C8yDockerOptions, C8yNitroModuleOptions, C8yOpenAPIOptions, type TenantCredentials };
3
+ export { C8YAPIClientOptions, type C8YKnownRole, type C8YManifest, type C8YManifestOptions, C8YRole, C8YRoles, C8YSettingsCategory, C8YTenantOptionKey, C8YTenantOptionKeysCacheConfig, type C8YZipOptions, type C8yCacheOptions, C8yDevOptions, type C8yDockerOptions, C8yNitroModuleOptions, C8yOpenAPIOptions, type C8yRealtimeOptions, type TenantCredentials };
package/dist/utils.d.mts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { t as TenantCredentials } from "./credentials-eKnJ7m9Y.mjs";
2
- import { Client, ICredentials, ICurrentUser } from "@c8y/client";
2
+ import { RealtimeClient } from "c8y-realtime";
3
+ import { Client, ICredentials, ICurrentUser, IResultList } from "@c8y/client";
3
4
  import { createError as createError$1, createLogger } from "evlog";
4
5
  import { EventHandler, H3Event, HTTPEvent } from "nitro/h3";
5
6
  import { ServerRequest, TaskContext, TaskPayload } from "nitro/types";
6
- import { C8YRoles, C8YSettingsCategory, C8YTenantOptionKey } from "c8y-nitro/types";
7
+ import { C8YRole, C8YRoles, C8YSettingsCategory, C8YTenantOptionKey } from "c8y-nitro/types";
7
8
  //#region src/utils/client.d.ts
8
9
  /**
9
10
  * Creates a Cumulocity client authenticated with the current user's credentials.\
@@ -51,8 +52,98 @@ declare function useSubscribedTenantClients(): Promise<Record<string, Client>>;
51
52
  */
52
53
  declare function useDeployedTenantClient(): Promise<Client>;
53
54
  //#endregion
55
+ //#region src/utils/paging.d.ts
56
+ /**
57
+ * The first page of a Cumulocity list result, in any of the shapes a caller
58
+ * naturally has on hand:
59
+ *
60
+ * - an already-awaited {@link IResultList} (`await client.inventory.list(...)`)
61
+ * - the pending `list()` promise itself (`client.inventory.list(...)`)
62
+ * - a thunk that produces it lazily (`() => client.inventory.list(...)`)
63
+ *
64
+ * Only the first page has to be supplied. Every subsequent page is fetched via
65
+ * the result's own `paging.next()`, which re-uses the exact client, auth, and
66
+ * filter that produced the first page — so there is no client to pass and no
67
+ * ambiguity between user, tenant, or microservice clients.
68
+ */
69
+ type PageSource<T> = IResultList<T> | Promise<IResultList<T>> | (() => IResultList<T> | Promise<IResultList<T>>);
70
+ /**
71
+ * Optional safety guards for walking large or unbounded Cumulocity collections.
72
+ */
73
+ interface PagingOptions {
74
+ /**
75
+ * Stop after fetching this many pages. Guards against runaway iteration over
76
+ * unexpectedly large collections. Unlimited when omitted.
77
+ */
78
+ maxPages?: number;
79
+ /**
80
+ * Stop once this many items have been produced. The page that crosses the
81
+ * limit is truncated so exactly `maxItems` items are yielded. Unlimited when
82
+ * omitted.
83
+ */
84
+ maxItems?: number;
85
+ }
86
+ /**
87
+ * Lazily walks a Cumulocity list result page by page, yielding each page's data
88
+ * array. This is the engine behind {@link paginate} and {@link fetchAllPages}.
89
+ *
90
+ * Pages are fetched on demand: the next request is only issued when the consumer
91
+ * asks for the next value. Iteration stops when a page comes back shorter than
92
+ * the requested page size (Cumulocity's signal that there is no more data),
93
+ * when the result carries no paging information, or when a {@link PagingOptions}
94
+ * guard is hit.
95
+ *
96
+ * @param source - The first page (result, promise, or thunk). See {@link PageSource}.
97
+ * @param options - Optional {@link PagingOptions} safety guards.
98
+ * @yields Each page as an array of items.
99
+ * @example
100
+ * // Process one page at a time (e.g. bulk insert per page):
101
+ * for await (const page of paginatePages(client.event.list({ pageSize: 2000 }))) {
102
+ * await bulkInsert(page)
103
+ * }
104
+ */
105
+ declare function paginatePages<T>(source: PageSource<T>, options?: PagingOptions): AsyncGenerator<T[]>;
106
+ /**
107
+ * Lazily iterates every item across all pages of a Cumulocity list result,
108
+ * yielding one entry at a time — ideal for streaming large collections with a
109
+ * low memory footprint.
110
+ *
111
+ * Pages are fetched on demand as iteration advances, walking via the result's
112
+ * own `paging.next()` (same client and filter as the first page). Iteration
113
+ * stops when a page comes back shorter than the requested page size.
114
+ *
115
+ * @param source - The first page (result, promise, or thunk). See {@link PageSource}.
116
+ * @param options - Optional {@link PagingOptions} safety guards.
117
+ * @yields Each item, in order, across all pages.
118
+ * @example
119
+ * // Stream every active alarm, one at a time:
120
+ * for await (const alarm of paginate(() => client.alarm.list({ pageSize: 2000, status: 'ACTIVE' }))) {
121
+ * await handleAlarm(alarm)
122
+ * }
123
+ */
124
+ declare function paginate<T>(source: PageSource<T>, options?: PagingOptions): AsyncGenerator<T>;
125
+ /**
126
+ * Collects every item across all pages of a Cumulocity list result into a single
127
+ * array, awaiting until all pages have been fetched.
128
+ *
129
+ * Walks via the result's own `paging.next()` (same client and filter as the
130
+ * first page), stopping when a page comes back shorter than the requested page
131
+ * size. For very large collections prefer {@link paginate} to avoid holding
132
+ * every item in memory at once, or cap the result with
133
+ * {@link PagingOptions.maxItems}.
134
+ *
135
+ * @param source - The first page (result, promise, or thunk). See {@link PageSource}.
136
+ * @param options - Optional {@link PagingOptions} safety guards.
137
+ * @returns All items across all pages, in order.
138
+ * @example
139
+ * // Fetch every managed object matching a fragment:
140
+ * const client = useUserClient(event)
141
+ * const all = await fetchAllPages(client.inventory.list({ pageSize: 2000, fragmentType: 'c8y_Sensor' }))
142
+ */
143
+ declare function fetchAllPages<T>(source: PageSource<T>, options?: PagingOptions): Promise<T[]>;
144
+ //#endregion
54
145
  //#region src/utils/middleware.d.ts
55
- type UserRole = keyof C8YRoles | (string & {});
146
+ type UserRole = keyof C8YRoles | C8YRole;
56
147
  /**
57
148
  * Middleware to check if the current user has the required role.\
58
149
  * If the user doesn't have the required role, throws a 403 Forbidden error.\
@@ -543,7 +634,8 @@ type TaskHandler<TPayload = TaskRegistryPayload, TResult = unknown> = (event: Ta
543
634
  * When a job should run.
544
635
  * - `{ cron }`: recurring on a cron expression. 5- or 6-field (with seconds) is
545
636
  * supported. Evaluated in UTC by default, or in the job's `timezone` if set.
546
- * - `{ in }`: one-shot, `in` seconds from now.
637
+ * - `{ in }`: one-shot, from now — either a number of seconds (`{ in: 30 }`) or a
638
+ * human duration string (`{ in: '5 minutes' }`, `{ in: '1 hour' }`).
547
639
  * - `{ at }`: one-shot, at an exact instant given as a `Date` or ISO 8601 string,
548
640
  * taken literally (never re-projected to another timezone). Times in the past
549
641
  * fire almost immediately.
@@ -551,7 +643,7 @@ type TaskHandler<TPayload = TaskRegistryPayload, TResult = unknown> = (event: Ta
551
643
  type JobSchedule = {
552
644
  cron: string;
553
645
  } | {
554
- in: number;
646
+ in: number | string;
555
647
  } | {
556
648
  at: Date | string;
557
649
  };
@@ -762,4 +854,27 @@ interface TaskRegistry<TTasks extends TaskMap = {}> {
762
854
  */
763
855
  declare function c8yTasks(): TaskRegistry;
764
856
  //#endregion
765
- export { BaseJobInfo, ForeignTenantOptionCategory, ForeignTenantOptionKeyInput, JobConcurrency, JobInfo, JobInfoFor, JobSchedule, OnceJobInfo, RecurringJobInfo, ScheduleJobOptions, SetEditableOptions, TaskEvent, TaskHandler, TaskMap, TaskRegistry, TaskRegistryContext, TaskRegistryPayload, TaskShape, TenantOption, TenantOptionCategory, TenantOptionKeyInput, c8yTasks, createError, createLogger, hasUserRequiredRole, isUserFromAllowedTenant, isUserFromDeployedTenant, useDeployedTenantClient, useDeployedTenantCredentials, useLogger, useSubscribedTenantClients, useSubscribedTenantCredentials, useTenantOption, useTenantOptions, useUser, useUserClient, useUserRoles, useUserTenantClient, useUserTenantCredentials };
857
+ //#region src/utils/realtime.d.ts
858
+ /**
859
+ * Anything a caller might already have on hand that identifies a tenant, from
860
+ * which {@link useTenantRealtimeClient} resolves the tenant's service
861
+ * credentials: a tenant id, that tenant's credentials, a `@c8y/client` scoped to
862
+ * it, or the current request.
863
+ */
864
+ type TenantRealtimeInput = string | ICredentials | Client | H3Event | ServerRequest;
865
+ /**
866
+ * Get the pooled Notification 2.0 realtime client for a tenant, creating it on
867
+ * first use and reusing it after. Accepts whatever identifies the tenant — a
868
+ * tenant id, that tenant's credentials, a `@c8y/client`, or the current request
869
+ * — and always connects with the tenant's **service** credentials.
870
+ *
871
+ * Register handlers directly on the returned client (see `c8y-realtime`):
872
+ * @example
873
+ * const rt = await useTenantRealtimeClient(event)
874
+ * rt.alarms.onCreate('*', alarm => log.info(alarm.severity, alarm.text))
875
+ * rt.measurements.onCreate('device42', m => log.info(m.type, m.time))
876
+ * @param input - A tenant id, `ICredentials`, a `Client`, or an `H3Event`/`ServerRequest`.
877
+ */
878
+ declare function useTenantRealtimeClient(input: TenantRealtimeInput): Promise<RealtimeClient>;
879
+ //#endregion
880
+ export { BaseJobInfo, ForeignTenantOptionCategory, ForeignTenantOptionKeyInput, JobConcurrency, JobInfo, JobInfoFor, JobSchedule, OnceJobInfo, PageSource, PagingOptions, RecurringJobInfo, ScheduleJobOptions, SetEditableOptions, TaskEvent, TaskHandler, TaskMap, TaskRegistry, TaskRegistryContext, TaskRegistryPayload, TaskShape, TenantOption, TenantOptionCategory, TenantOptionKeyInput, type TenantRealtimeInput, c8yTasks, createError, createLogger, fetchAllPages, hasUserRequiredRole, isUserFromAllowedTenant, isUserFromDeployedTenant, paginate, paginatePages, useDeployedTenantClient, useDeployedTenantCredentials, useLogger, useSubscribedTenantClients, useSubscribedTenantCredentials, useTenantOption, useTenantOptions, useTenantRealtimeClient, useUser, useUserClient, useUserRoles, useUserTenantClient, useUserTenantCredentials };
package/dist/utils.mjs CHANGED
@@ -1,5 +1,6 @@
1
- import { Buffer } from "node:buffer";
1
+ import { createRealtimeClient } from "c8y-realtime";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
+ import { Buffer } from "node:buffer";
3
4
  import process from "node:process";
4
5
  import { useLogger as useLogger$1 } from "evlog/nitro/v3";
5
6
  import { BasicAuth, Client, MicroserviceClientRequestAuth } from "@c8y/client";
@@ -10,8 +11,21 @@ import { useNitroHooks } from "nitro/app";
10
11
  import { defineHandler } from "nitro/h3";
11
12
  import { c8yManifest } from "c8y-nitro/runtime";
12
13
  import { Cron } from "croner";
14
+ import { ms } from "itty-time";
13
15
  //#region src/utils/internal/common.ts
14
16
  /**
17
+ * Whether two sets of tenant credentials are equivalent for the purposes of
18
+ * change detection: same tenant, user, and password. Used to detect a
19
+ * service-user password rotation (which keeps the tenant id but changes the
20
+ * secret), not just tenants entering/leaving the subscription set.
21
+ * @param a - First credentials, or `undefined` when the tenant is absent
22
+ * @param b - Second credentials, or `undefined` when the tenant is absent
23
+ */
24
+ function credsEqual(a, b) {
25
+ if (!a || !b) return a === b;
26
+ return a.tenant === b.tenant && a.user === b.user && a.password === b.password;
27
+ }
28
+ /**
15
29
  * Converts undici Request headers to the format expected by MicroserviceClientRequestAuth.\
16
30
  * Extracts the following headers from the request:
17
31
  * - `authorization`: Used for Basic Auth or Bearer token authentication
@@ -176,10 +190,9 @@ const createError = createError$1;
176
190
  let prevCredentials = null;
177
191
  function shouldEmitTenantCredentialsUpdated(prev, next) {
178
192
  if (!prev) return true;
179
- const prevTenantIds = Object.keys(prev);
180
- const nextTenantIds = Object.keys(next);
181
- if (prevTenantIds.length !== nextTenantIds.length) return true;
182
- return new Set(nextTenantIds).symmetricDifference(new Set(prevTenantIds)).size > 0;
193
+ const tenantIds = /* @__PURE__ */ new Set([...Object.keys(prev), ...Object.keys(next)]);
194
+ for (const tenantId of tenantIds) if (!credsEqual(prev[tenantId], next[tenantId])) return true;
195
+ return false;
183
196
  }
184
197
  /**
185
198
  * Fetches credentials for all tenants subscribed to this microservice.\
@@ -383,6 +396,97 @@ async function useDeployedTenantClient() {
383
396
  return client;
384
397
  }
385
398
  //#endregion
399
+ //#region src/utils/paging.ts
400
+ async function resolvePageSource(source) {
401
+ return await (typeof source === "function" ? source() : source);
402
+ }
403
+ /**
404
+ * Lazily walks a Cumulocity list result page by page, yielding each page's data
405
+ * array. This is the engine behind {@link paginate} and {@link fetchAllPages}.
406
+ *
407
+ * Pages are fetched on demand: the next request is only issued when the consumer
408
+ * asks for the next value. Iteration stops when a page comes back shorter than
409
+ * the requested page size (Cumulocity's signal that there is no more data),
410
+ * when the result carries no paging information, or when a {@link PagingOptions}
411
+ * guard is hit.
412
+ *
413
+ * @param source - The first page (result, promise, or thunk). See {@link PageSource}.
414
+ * @param options - Optional {@link PagingOptions} safety guards.
415
+ * @yields Each page as an array of items.
416
+ * @example
417
+ * // Process one page at a time (e.g. bulk insert per page):
418
+ * for await (const page of paginatePages(client.event.list({ pageSize: 2000 }))) {
419
+ * await bulkInsert(page)
420
+ * }
421
+ */
422
+ async function* paginatePages(source, options = {}) {
423
+ const { maxPages, maxItems } = options;
424
+ let result = await resolvePageSource(source);
425
+ let pagesSeen = 0;
426
+ let itemsSeen = 0;
427
+ while (true) {
428
+ pagesSeen++;
429
+ let page = result.data ?? [];
430
+ if (maxItems != null && page.length > maxItems - itemsSeen) page = page.slice(0, Math.max(0, maxItems - itemsSeen));
431
+ if (page.length > 0) {
432
+ yield page;
433
+ itemsSeen += page.length;
434
+ }
435
+ if (maxItems != null && itemsSeen >= maxItems) break;
436
+ if (maxPages != null && pagesSeen >= maxPages) break;
437
+ const paging = result.paging;
438
+ if (!paging) break;
439
+ const fullPage = paging.pageSize ? (result.data?.length ?? 0) >= paging.pageSize : true;
440
+ const hasNextPage = paging.nextPage != null && paging.nextPage > paging.currentPage;
441
+ if (!fullPage || !hasNextPage) break;
442
+ result = await paging.next();
443
+ }
444
+ }
445
+ /**
446
+ * Lazily iterates every item across all pages of a Cumulocity list result,
447
+ * yielding one entry at a time — ideal for streaming large collections with a
448
+ * low memory footprint.
449
+ *
450
+ * Pages are fetched on demand as iteration advances, walking via the result's
451
+ * own `paging.next()` (same client and filter as the first page). Iteration
452
+ * stops when a page comes back shorter than the requested page size.
453
+ *
454
+ * @param source - The first page (result, promise, or thunk). See {@link PageSource}.
455
+ * @param options - Optional {@link PagingOptions} safety guards.
456
+ * @yields Each item, in order, across all pages.
457
+ * @example
458
+ * // Stream every active alarm, one at a time:
459
+ * for await (const alarm of paginate(() => client.alarm.list({ pageSize: 2000, status: 'ACTIVE' }))) {
460
+ * await handleAlarm(alarm)
461
+ * }
462
+ */
463
+ async function* paginate(source, options) {
464
+ for await (const page of paginatePages(source, options)) yield* page;
465
+ }
466
+ /**
467
+ * Collects every item across all pages of a Cumulocity list result into a single
468
+ * array, awaiting until all pages have been fetched.
469
+ *
470
+ * Walks via the result's own `paging.next()` (same client and filter as the
471
+ * first page), stopping when a page comes back shorter than the requested page
472
+ * size. For very large collections prefer {@link paginate} to avoid holding
473
+ * every item in memory at once, or cap the result with
474
+ * {@link PagingOptions.maxItems}.
475
+ *
476
+ * @param source - The first page (result, promise, or thunk). See {@link PageSource}.
477
+ * @param options - Optional {@link PagingOptions} safety guards.
478
+ * @returns All items across all pages, in order.
479
+ * @example
480
+ * // Fetch every managed object matching a fragment:
481
+ * const client = useUserClient(event)
482
+ * const all = await fetchAllPages(client.inventory.list({ pageSize: 2000, fragmentType: 'c8y_Sensor' }))
483
+ */
484
+ async function fetchAllPages(source, options) {
485
+ const all = [];
486
+ for await (const page of paginatePages(source, options)) all.push(...page);
487
+ return all;
488
+ }
489
+ //#endregion
386
490
  //#region src/utils/resources.ts
387
491
  /**
388
492
  * Fetches the current user from Cumulocity using credentials extracted from the current request's headers.
@@ -886,6 +990,11 @@ const useTenantOptions = Object.assign(useTenantOptionsBase, {
886
990
  //#region src/utils/tasks.ts
887
991
  function resolveOnceDate(schedule) {
888
992
  if ("in" in schedule) {
993
+ if (typeof schedule.in === "string") {
994
+ const delay = ms(schedule.in);
995
+ if (!Number.isFinite(delay) || delay < 0) throw new TypeError(`schedule.in must be a valid duration string (e.g. "5 minutes"), got "${schedule.in}"`);
996
+ return new Date(Date.now() + delay);
997
+ }
889
998
  if (!Number.isFinite(schedule.in) || schedule.in < 0) throw new TypeError("schedule.in must be a non-negative number of seconds");
890
999
  return new Date(Date.now() + schedule.in * 1e3);
891
1000
  }
@@ -1046,4 +1155,167 @@ function c8yTasks() {
1046
1155
  return api;
1047
1156
  }
1048
1157
  //#endregion
1049
- export { c8yTasks, createError, createLogger, hasUserRequiredRole, isUserFromAllowedTenant, isUserFromDeployedTenant, useDeployedTenantClient, useDeployedTenantCredentials, useLogger, useSubscribedTenantClients, useSubscribedTenantCredentials, useTenantOption, useTenantOptions, useUser, useUserClient, useUserRoles, useUserTenantClient, useUserTenantCredentials };
1158
+ //#region src/utils/realtime.ts
1159
+ const REPAIR_SWEEP_INTERVAL_MS = 3e4;
1160
+ const pool = /* @__PURE__ */ new Map();
1161
+ let lifecycleWired = false;
1162
+ let sweepTimer;
1163
+ let sweeping = false;
1164
+ function realtimeConfig() {
1165
+ return useRuntimeConfig().c8yRealtime;
1166
+ }
1167
+ /**
1168
+ * Distinguish tenant credentials from a request. Every field on `ICredentials`
1169
+ * is optional, so a bare `'password' in x` doesn't narrow the union — this
1170
+ * predicate does. A request never carries `user`/`password`/`tenant`.
1171
+ * @param input - A value already narrowed to credentials or a request.
1172
+ */
1173
+ function isCredentials(input) {
1174
+ return "user" in input || "password" in input || "tenant" in input;
1175
+ }
1176
+ /**
1177
+ * Shape c8y-nitro's `ICredentials` into the single-tenant credentials
1178
+ * `c8y-realtime` expects (it needs the `baseUrl`, which lives in the env).
1179
+ * @param tenantId - The tenant the credentials belong to.
1180
+ * @param creds - The tenant's service credentials.
1181
+ */
1182
+ function toLibCredentials(tenantId, creds) {
1183
+ return {
1184
+ baseUrl: process.env.C8Y_BASEURL,
1185
+ tenant: creds.tenant ?? tenantId,
1186
+ user: creds.user,
1187
+ password: creds.password
1188
+ };
1189
+ }
1190
+ function createRealtimeClientForTenant(tenantId, creds) {
1191
+ return createRealtimeClient({
1192
+ ...realtimeConfig(),
1193
+ ...toLibCredentials(tenantId, creds),
1194
+ logger: console
1195
+ });
1196
+ }
1197
+ async function subscribedCredsFor(tenantId) {
1198
+ const creds = (await useSubscribedTenantCredentials())[tenantId];
1199
+ if (!creds) throw createError({
1200
+ status: 500,
1201
+ message: `No subscribed tenant credentials found for tenant '${tenantId}'`
1202
+ });
1203
+ return creds;
1204
+ }
1205
+ async function resolveTenant(input) {
1206
+ if (typeof input === "string") return {
1207
+ tenantId: input,
1208
+ creds: await subscribedCredsFor(input)
1209
+ };
1210
+ if (input instanceof Client) {
1211
+ const tenantId = input.core.tenant ?? (await input.tenant.current()).data.name;
1212
+ if (!tenantId) throw createError({
1213
+ status: 500,
1214
+ message: "Could not resolve a tenant id from the provided client"
1215
+ });
1216
+ return {
1217
+ tenantId,
1218
+ creds: await subscribedCredsFor(tenantId)
1219
+ };
1220
+ }
1221
+ if (isCredentials(input)) {
1222
+ const tenantId = input.tenant;
1223
+ if (!tenantId) throw createError({
1224
+ status: 500,
1225
+ message: "The provided credentials have no tenant"
1226
+ });
1227
+ return {
1228
+ tenantId,
1229
+ creds: input
1230
+ };
1231
+ }
1232
+ const tenantId = await getCurrentUserTenantId(input);
1233
+ return {
1234
+ tenantId,
1235
+ creds: await subscribedCredsFor(tenantId)
1236
+ };
1237
+ }
1238
+ /**
1239
+ * Repair pooled clients that have gone unhealthy — a consumer stopped because
1240
+ * its credentials ran out (rotation) or were revoked and later reissued
1241
+ * (unsubscribe → resubscribe). `RealtimeClient.updateCredentials` may only be
1242
+ * called while a client is unhealthy and validates the new creds with a probe,
1243
+ * so this is the trigger that actually performs the repair.
1244
+ *
1245
+ * Cheap when nothing is wrong: it only forces a credential refresh when there is
1246
+ * an unhealthy client whose tenant is still subscribed (so its stored creds may
1247
+ * be stale). A client whose tenant is gone is left idle — its handlers are kept
1248
+ * for a possible return, and no platform call is made on its behalf.
1249
+ */
1250
+ async function repairUnhealthyClients() {
1251
+ if (sweeping) return;
1252
+ sweeping = true;
1253
+ try {
1254
+ const cached = await useSubscribedTenantCredentials();
1255
+ const repairable = [...pool].filter(([tenantId, client]) => !client.healthy && cached[tenantId]);
1256
+ if (repairable.length === 0) return;
1257
+ const fresh = await useSubscribedTenantCredentials.refresh();
1258
+ for (const [tenantId, client] of repairable) {
1259
+ const creds = fresh[tenantId];
1260
+ if (!creds) continue;
1261
+ try {
1262
+ await client.updateCredentials(toLibCredentials(tenantId, creds));
1263
+ } catch (error) {
1264
+ console.error(`c8y-nitro realtime: could not repair client for tenant '${tenantId}'`, error);
1265
+ }
1266
+ }
1267
+ } finally {
1268
+ sweeping = false;
1269
+ }
1270
+ }
1271
+ /**
1272
+ * Wire the realtime lifecycle exactly once, on first client creation. There is
1273
+ * nothing to manage until a client exists, so lazy wiring loses nothing and
1274
+ * keeps the pool a single module instance (no separately-bundled plugin, so no
1275
+ * duplicated pool). The `close` handler registers after any user handlers, which
1276
+ * is the order we want: user code does its final work while clients are alive,
1277
+ * then we tear the sockets down.
1278
+ */
1279
+ function ensureLifecycleWired() {
1280
+ if (lifecycleWired) return;
1281
+ lifecycleWired = true;
1282
+ sweepTimer = setInterval(() => {
1283
+ repairUnhealthyClients().catch((error) => {
1284
+ console.error("c8y-nitro realtime: repair sweep failed", error);
1285
+ });
1286
+ }, REPAIR_SWEEP_INTERVAL_MS);
1287
+ sweepTimer.unref?.();
1288
+ useNitroHooks().hook("close", async () => {
1289
+ if (sweepTimer) {
1290
+ clearInterval(sweepTimer);
1291
+ sweepTimer = void 0;
1292
+ }
1293
+ await Promise.all([...pool.values()].map((client) => client.close().catch(() => {})));
1294
+ pool.clear();
1295
+ });
1296
+ }
1297
+ /**
1298
+ * Get the pooled Notification 2.0 realtime client for a tenant, creating it on
1299
+ * first use and reusing it after. Accepts whatever identifies the tenant — a
1300
+ * tenant id, that tenant's credentials, a `@c8y/client`, or the current request
1301
+ * — and always connects with the tenant's **service** credentials.
1302
+ *
1303
+ * Register handlers directly on the returned client (see `c8y-realtime`):
1304
+ * @example
1305
+ * const rt = await useTenantRealtimeClient(event)
1306
+ * rt.alarms.onCreate('*', alarm => log.info(alarm.severity, alarm.text))
1307
+ * rt.measurements.onCreate('device42', m => log.info(m.type, m.time))
1308
+ * @param input - A tenant id, `ICredentials`, a `Client`, or an `H3Event`/`ServerRequest`.
1309
+ */
1310
+ async function useTenantRealtimeClient(input) {
1311
+ const { tenantId, creds } = await resolveTenant(input);
1312
+ ensureLifecycleWired();
1313
+ let client = pool.get(tenantId);
1314
+ if (!client) {
1315
+ client = createRealtimeClientForTenant(tenantId, creds);
1316
+ pool.set(tenantId, client);
1317
+ }
1318
+ return client;
1319
+ }
1320
+ //#endregion
1321
+ export { c8yTasks, createError, createLogger, fetchAllPages, hasUserRequiredRole, isUserFromAllowedTenant, isUserFromDeployedTenant, paginate, paginatePages, useDeployedTenantClient, useDeployedTenantCredentials, useLogger, useSubscribedTenantClients, useSubscribedTenantCredentials, useTenantOption, useTenantOptions, useTenantRealtimeClient, useUser, useUserClient, useUserRoles, useUserTenantClient, useUserTenantCredentials };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8y-nitro",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "Lightning fast Cumulocity IoT microservice development powered by Nitro",
6
6
  "keywords": [
@@ -39,10 +39,12 @@
39
39
  ],
40
40
  "dependencies": {
41
41
  "c12": "^4.0.0-beta.5",
42
+ "c8y-realtime": "^0.2.2",
42
43
  "citty": "^0.2.2",
43
44
  "consola": "^3.4.2",
44
45
  "croner": "^10.0.1",
45
46
  "evlog": "^2.21.0",
47
+ "itty-time": "^2.0.2",
46
48
  "jszip": "^3.10.1",
47
49
  "pathe": "^2.0.3",
48
50
  "pkg-types": "^2.3.1",
@@ -74,6 +76,7 @@
74
76
  },
75
77
  "scripts": {
76
78
  "dev": "tsdown --watch",
79
+ "gen:roles": "node scripts/generate-roles.ts",
77
80
  "build": "tsdown",
78
81
  "build:update": "tsdown --update-snapshot",
79
82
  "lint": "eslint",