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.
- package/dist/{autoBootstrap-Drk7ltXK.mjs → autoBootstrap-qCcCgset.mjs} +1 -1
- package/dist/{bootstrap-C1Qly0J_.mjs → bootstrap-BcTCUiqG.mjs} +2 -2
- package/dist/{c8y-api-DscUs-Kd.mjs → c8y-api-DpCWhaJU.mjs} +8 -0
- package/dist/cli/index.mjs +4 -4
- package/dist/{client-D0Tdq7H1.mjs → client-DCov9jV6.mjs} +15 -4
- package/dist/{index-vu4xaFkz.d.mts → index-Dil1PcDr.d.mts} +108 -4
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +36 -3
- package/dist/{options-CmLpZ3Qf.mjs → options-BBnR-y_W.mjs} +2 -2
- package/dist/{package-CNWz76PV.mjs → package-gK1cGX1w.mjs} +1 -1
- package/dist/{roles-B-DNuScZ.mjs → roles-CkUvL64s.mjs} +1 -1
- package/dist/runtime/handlers/invalidateTenantOptions.mjs +1 -1
- package/dist/runtime/handlers/openapi.mjs +1 -1
- package/dist/runtime/handlers/rewriteRequestUrl.mjs +1 -1
- package/dist/types.d.mts +2 -2
- package/dist/utils.d.mts +121 -6
- package/dist/utils.mjs +278 -6
- package/package.json +4 -1
|
@@ -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-
|
|
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-
|
|
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-
|
|
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,
|
package/dist/cli/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as name, r as version, t as description } from "../package-
|
|
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-
|
|
12
|
-
roles: () => import("../roles-
|
|
13
|
-
options: () => import("../options-
|
|
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
|
|
149
|
-
const
|
|
150
|
-
|
|
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?:
|
|
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/
|
|
411
|
-
|
|
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,
|
|
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-
|
|
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-
|
|
2
|
-
import { t as autoBootstrap } from "./autoBootstrap-
|
|
3
|
-
import { n as name } from "./package-
|
|
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-
|
|
2
|
-
import { n as readBootstrapCredentialsFromEnv, r as syncBootstrappedMicroservice } from "./autoBootstrap-
|
|
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,4 +1,4 @@
|
|
|
1
|
-
import { n as createBasicAuthHeader, p as createC8yManifest, t as assignUserRole, u as unassignUserRole } from "./c8y-api-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
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 {
|
|
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 |
|
|
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`
|
|
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
|
-
|
|
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 {
|
|
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
|
|
180
|
-
const
|
|
181
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|