c8y-nitro 0.7.2 → 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-BHOzdhHp.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 +344 -75
- package/dist/utils.mjs +417 -147
- package/package.json +4 -1
package/dist/utils.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { createRealtimeClient } from "c8y-realtime";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
1
3
|
import { Buffer } from "node:buffer";
|
|
2
|
-
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
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";
|
|
@@ -9,10 +10,22 @@ import { useRuntimeConfig } from "nitro/runtime-config";
|
|
|
9
10
|
import { useNitroHooks } from "nitro/app";
|
|
10
11
|
import { defineHandler } from "nitro/h3";
|
|
11
12
|
import { c8yManifest } from "c8y-nitro/runtime";
|
|
13
|
+
import { Cron } from "croner";
|
|
12
14
|
import { ms } from "itty-time";
|
|
13
|
-
import { tasks } from "#nitro/virtual/tasks";
|
|
14
15
|
//#region src/utils/internal/common.ts
|
|
15
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
|
+
/**
|
|
16
29
|
* Converts undici Request headers to the format expected by MicroserviceClientRequestAuth.\
|
|
17
30
|
* Extracts the following headers from the request:
|
|
18
31
|
* - `authorization`: Used for Basic Auth or Bearer token authentication
|
|
@@ -177,10 +190,9 @@ const createError = createError$1;
|
|
|
177
190
|
let prevCredentials = null;
|
|
178
191
|
function shouldEmitTenantCredentialsUpdated(prev, next) {
|
|
179
192
|
if (!prev) return true;
|
|
180
|
-
const
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
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;
|
|
184
196
|
}
|
|
185
197
|
/**
|
|
186
198
|
* Fetches credentials for all tenants subscribed to this microservice.\
|
|
@@ -384,6 +396,97 @@ async function useDeployedTenantClient() {
|
|
|
384
396
|
return client;
|
|
385
397
|
}
|
|
386
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
|
|
387
490
|
//#region src/utils/resources.ts
|
|
388
491
|
/**
|
|
389
492
|
* Fetches the current user from Cumulocity using credentials extracted from the current request's headers.
|
|
@@ -884,168 +987,335 @@ const useTenantOptions = Object.assign(useTenantOptionsBase, {
|
|
|
884
987
|
refreshAll: () => refreshFetchers(() => true)
|
|
885
988
|
});
|
|
886
989
|
//#endregion
|
|
887
|
-
//#region src/utils/
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
const
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
990
|
+
//#region src/utils/tasks.ts
|
|
991
|
+
function resolveOnceDate(schedule) {
|
|
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
|
+
}
|
|
998
|
+
if (!Number.isFinite(schedule.in) || schedule.in < 0) throw new TypeError("schedule.in must be a non-negative number of seconds");
|
|
999
|
+
return new Date(Date.now() + schedule.in * 1e3);
|
|
1000
|
+
}
|
|
1001
|
+
const date = schedule.at instanceof Date ? schedule.at : new Date(schedule.at);
|
|
1002
|
+
if (Number.isNaN(date.getTime())) throw new TypeError("schedule.at must be a valid Date or ISO 8601 string");
|
|
1003
|
+
return date;
|
|
897
1004
|
}
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
1005
|
+
/**
|
|
1006
|
+
* Creates a new, empty task registry.
|
|
1007
|
+
*
|
|
1008
|
+
* @example
|
|
1009
|
+
* import { c8yTasks } from 'c8y-nitro/utils'
|
|
1010
|
+
*
|
|
1011
|
+
* export const tasks = c8yTasks()
|
|
1012
|
+
* .createTask('sync', async ({ payload }) => {
|
|
1013
|
+
* const config = await loadConfig(payload.configId) // resolve live state here
|
|
1014
|
+
* await sync(config)
|
|
1015
|
+
* })
|
|
1016
|
+
*
|
|
1017
|
+
* // one per config, each an independent job
|
|
1018
|
+
* tasks.scheduleJob({ name: 'sync-abc', task: 'sync', payload: { configId: 'abc' }, schedule: { cron: '*\/5 * * * *' } })
|
|
1019
|
+
* tasks.scheduleJob({ name: 'sync-def', task: 'sync', payload: { configId: 'def' }, schedule: { cron: '*\/5 * * * *' }, concurrency: 'parallel' })
|
|
1020
|
+
*
|
|
1021
|
+
* @remarks
|
|
1022
|
+
* The registry is a plain runtime singleton — export it from one module and import
|
|
1023
|
+
* it wherever you schedule or cancel jobs. Recurring jobs only arm when the module
|
|
1024
|
+
* that schedules them executes, so register them from a Nitro plugin (or another
|
|
1025
|
+
* startup path) if they must run from boot.
|
|
1026
|
+
*/
|
|
1027
|
+
function c8yTasks() {
|
|
1028
|
+
const tasks = /* @__PURE__ */ new Map();
|
|
1029
|
+
const jobs = /* @__PURE__ */ new Map();
|
|
1030
|
+
function execute(record, override) {
|
|
1031
|
+
const definition = tasks.get(record.task);
|
|
1032
|
+
if (!definition) return Promise.reject(/* @__PURE__ */ new Error(`Task "${record.task}" is not registered`));
|
|
1033
|
+
if (record.concurrency === "single" && record.inFlight) return record.inFlight;
|
|
1034
|
+
const event = {
|
|
1035
|
+
task: record.task,
|
|
1036
|
+
job: record.name,
|
|
1037
|
+
payload: override?.payload ?? record.payload,
|
|
1038
|
+
context: override?.context ?? record.context
|
|
1039
|
+
};
|
|
1040
|
+
const promise = (async () => definition.handler(event))();
|
|
1041
|
+
if (record.concurrency === "single") {
|
|
1042
|
+
record.inFlight = promise;
|
|
1043
|
+
promise.finally(() => {
|
|
1044
|
+
if (record.inFlight === promise) record.inFlight = void 0;
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
return promise;
|
|
1048
|
+
}
|
|
1049
|
+
function toJobInfo(record) {
|
|
1050
|
+
const next = record.cron.nextRun();
|
|
1051
|
+
const base = {
|
|
1052
|
+
name: record.name,
|
|
1053
|
+
task: record.task,
|
|
1054
|
+
concurrency: record.concurrency,
|
|
1055
|
+
nextRun: next ? next.toISOString() : null,
|
|
1056
|
+
running: record.inFlight !== void 0
|
|
1057
|
+
};
|
|
1058
|
+
if (record.kind === "recurring") return {
|
|
1059
|
+
...base,
|
|
1060
|
+
kind: "recurring",
|
|
1061
|
+
cron: record.cronExpr,
|
|
1062
|
+
timezone: record.timezone,
|
|
1063
|
+
nextRuns: record.cron.nextRuns(3).map((date) => date.toISOString())
|
|
1064
|
+
};
|
|
1065
|
+
return {
|
|
1066
|
+
...base,
|
|
1067
|
+
kind: "once",
|
|
1068
|
+
cron: null
|
|
1069
|
+
};
|
|
903
1070
|
}
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
1071
|
+
function cancelJob(name) {
|
|
1072
|
+
const record = jobs.get(name);
|
|
1073
|
+
if (!record) return false;
|
|
1074
|
+
record.cron.stop();
|
|
1075
|
+
return jobs.delete(name);
|
|
907
1076
|
}
|
|
908
|
-
const
|
|
909
|
-
|
|
910
|
-
|
|
1077
|
+
const api = {
|
|
1078
|
+
createTask(name, handler) {
|
|
1079
|
+
if (typeof name !== "string" || !name) throw new TypeError("task name is required");
|
|
1080
|
+
if (tasks.has(name)) throw new Error(`Task "${name}" is already registered`);
|
|
1081
|
+
tasks.set(name, {
|
|
1082
|
+
name,
|
|
1083
|
+
handler
|
|
1084
|
+
});
|
|
1085
|
+
return api;
|
|
1086
|
+
},
|
|
1087
|
+
async run(task, options) {
|
|
1088
|
+
const definition = tasks.get(task);
|
|
1089
|
+
if (!definition) throw new Error(`Task "${task}" is not registered`);
|
|
1090
|
+
return definition.handler({
|
|
1091
|
+
task,
|
|
1092
|
+
payload: options?.payload ?? {},
|
|
1093
|
+
context: options?.context ?? {}
|
|
1094
|
+
});
|
|
1095
|
+
},
|
|
1096
|
+
scheduleJob(options) {
|
|
1097
|
+
const { name, task, schedule, concurrency = "single", replace = false } = options;
|
|
1098
|
+
if (!name) throw new TypeError("job name is required");
|
|
1099
|
+
if (!tasks.has(task)) throw new Error(`Task "${task}" is not registered. Register it with createTask() first.`);
|
|
1100
|
+
if (jobs.has(name)) {
|
|
1101
|
+
if (!replace) throw new Error(`Job "${name}" already exists. Pass { replace: true } to overwrite it.`);
|
|
1102
|
+
cancelJob(name);
|
|
1103
|
+
}
|
|
1104
|
+
const isRecurring = "cron" in schedule;
|
|
1105
|
+
const timezone = options.timezone ?? "UTC";
|
|
1106
|
+
const record = {
|
|
1107
|
+
name,
|
|
1108
|
+
task,
|
|
1109
|
+
kind: isRecurring ? "recurring" : "once",
|
|
1110
|
+
concurrency,
|
|
1111
|
+
cronExpr: isRecurring ? schedule.cron : null,
|
|
1112
|
+
timezone,
|
|
1113
|
+
payload: options.payload ?? {},
|
|
1114
|
+
context: options.context ?? {},
|
|
1115
|
+
cron: void 0
|
|
1116
|
+
};
|
|
1117
|
+
const callback = () => {
|
|
1118
|
+
Promise.resolve(execute(record)).catch((error) => {
|
|
1119
|
+
console.error(`c8y-nitro: job "${name}" (task "${task}") failed`, error);
|
|
1120
|
+
}).finally(() => {
|
|
1121
|
+
if (!record.cron.nextRun()) jobs.delete(name);
|
|
1122
|
+
});
|
|
1123
|
+
};
|
|
1124
|
+
if (isRecurring) {
|
|
1125
|
+
const cronOptions = { timezone };
|
|
1126
|
+
if (typeof options.maxRuns === "number") cronOptions.maxRuns = options.maxRuns;
|
|
1127
|
+
record.cron = new Cron(schedule.cron, cronOptions, callback);
|
|
1128
|
+
} else {
|
|
1129
|
+
const fireAt = Math.max(resolveOnceDate(schedule).getTime(), Date.now() + 50);
|
|
1130
|
+
record.cron = new Cron(new Date(fireAt), { maxRuns: 1 }, callback);
|
|
1131
|
+
}
|
|
1132
|
+
jobs.set(name, record);
|
|
1133
|
+
if (isRecurring && options.immediate) Promise.resolve(execute(record)).catch((error) => {
|
|
1134
|
+
console.error(`c8y-nitro: job "${name}" immediate run failed`, error);
|
|
1135
|
+
});
|
|
1136
|
+
return toJobInfo(record);
|
|
1137
|
+
},
|
|
1138
|
+
triggerJob(name, options) {
|
|
1139
|
+
const record = jobs.get(name);
|
|
1140
|
+
if (!record) throw new Error(`Job "${name}" is not registered`);
|
|
1141
|
+
return execute(record, options);
|
|
1142
|
+
},
|
|
1143
|
+
cancelJob,
|
|
1144
|
+
listJobs() {
|
|
1145
|
+
return [...jobs.values()].map(toJobInfo);
|
|
1146
|
+
},
|
|
1147
|
+
getJob(name) {
|
|
1148
|
+
const record = jobs.get(name);
|
|
1149
|
+
return record ? toJobInfo(record) : void 0;
|
|
1150
|
+
},
|
|
1151
|
+
hasTask(name) {
|
|
1152
|
+
return tasks.has(name);
|
|
1153
|
+
}
|
|
1154
|
+
};
|
|
1155
|
+
return api;
|
|
911
1156
|
}
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
await (await taskDef.resolve()).run({
|
|
922
|
-
name: record.taskName,
|
|
923
|
-
payload: record.payload,
|
|
924
|
-
context: record.context
|
|
925
|
-
});
|
|
1157
|
+
//#endregion
|
|
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;
|
|
926
1166
|
}
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
record.timeoutId = Number(timeout);
|
|
936
|
-
scheduledTaskTimers.set(record.timeoutId, timeout);
|
|
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;
|
|
937
1175
|
}
|
|
938
|
-
|
|
939
|
-
|
|
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
|
+
};
|
|
940
1189
|
}
|
|
941
|
-
function
|
|
942
|
-
|
|
943
|
-
|
|
1190
|
+
function createRealtimeClientForTenant(tenantId, creds) {
|
|
1191
|
+
return createRealtimeClient({
|
|
1192
|
+
...realtimeConfig(),
|
|
1193
|
+
...toLibCredentials(tenantId, creds),
|
|
1194
|
+
logger: console
|
|
1195
|
+
});
|
|
944
1196
|
}
|
|
945
|
-
function
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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;
|
|
949
1204
|
}
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
*
|
|
955
|
-
* @param taskName - The Nitro task name to run (from `tasks/*.ts`)
|
|
956
|
-
* @param options - Task payload, context, and the schedule time
|
|
957
|
-
* @returns Information about the scheduled task
|
|
958
|
-
*
|
|
959
|
-
* @example
|
|
960
|
-
* // Run a task in 30 seconds:
|
|
961
|
-
* const scheduled = await scheduleTask('emails:send', {
|
|
962
|
-
* payload: { messageId: 'abc123' },
|
|
963
|
-
* schedule: 30,
|
|
964
|
-
* })
|
|
965
|
-
*
|
|
966
|
-
* @example
|
|
967
|
-
* // Run a task using a human-readable duration:
|
|
968
|
-
* await scheduleTask('reports:generate', {
|
|
969
|
-
* payload: { reportId: 'report-1' },
|
|
970
|
-
* schedule: '1 hour',
|
|
971
|
-
* })
|
|
972
|
-
*
|
|
973
|
-
* @example
|
|
974
|
-
* // Run a task at an exact time:
|
|
975
|
-
* await scheduleTask('cleanup:tenant', {
|
|
976
|
-
* payload: { tenant: 't12345' },
|
|
977
|
-
* schedule: new Date('2026-05-01T12:00:00Z'),
|
|
978
|
-
* })
|
|
979
|
-
*/
|
|
980
|
-
async function scheduleTask(taskName, options) {
|
|
981
|
-
if (!import.meta._tasks) throw new Error("scheduleTask() requires tasks to be enabled. Set `experimental: { tasks: true }` in your nitro.config.ts.");
|
|
982
|
-
if (!taskName) throw new TypeError("taskName is required");
|
|
983
|
-
const runAt = resolveScheduleTime(options.schedule);
|
|
984
|
-
const record = {
|
|
985
|
-
id: createScheduledTaskId(),
|
|
986
|
-
taskName,
|
|
987
|
-
payload: options.payload ?? {},
|
|
988
|
-
context: options.context ?? {},
|
|
989
|
-
runAt
|
|
1205
|
+
async function resolveTenant(input) {
|
|
1206
|
+
if (typeof input === "string") return {
|
|
1207
|
+
tenantId: input,
|
|
1208
|
+
creds: await subscribedCredsFor(input)
|
|
990
1209
|
};
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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);
|
|
994
1233
|
return {
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
runAt: new Date(record.runAt).toISOString()
|
|
1234
|
+
tenantId,
|
|
1235
|
+
creds: await subscribedCredsFor(tenantId)
|
|
998
1236
|
};
|
|
999
1237
|
}
|
|
1000
1238
|
/**
|
|
1001
|
-
*
|
|
1002
|
-
*
|
|
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.
|
|
1003
1244
|
*
|
|
1004
|
-
*
|
|
1005
|
-
*
|
|
1006
|
-
*
|
|
1007
|
-
*
|
|
1008
|
-
* for (const [id, task] of Object.entries(tasks)) {
|
|
1009
|
-
* console.log(id, task.task, task.runAt)
|
|
1010
|
-
* }
|
|
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.
|
|
1011
1249
|
*/
|
|
1012
|
-
async function
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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
|
+
}
|
|
1019
1270
|
}
|
|
1020
1271
|
/**
|
|
1021
|
-
*
|
|
1022
|
-
*
|
|
1023
|
-
*
|
|
1024
|
-
*
|
|
1025
|
-
*
|
|
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.
|
|
1026
1302
|
*
|
|
1303
|
+
* Register handlers directly on the returned client (see `c8y-realtime`):
|
|
1027
1304
|
* @example
|
|
1028
|
-
* const
|
|
1029
|
-
*
|
|
1030
|
-
*
|
|
1031
|
-
*
|
|
1032
|
-
*
|
|
1033
|
-
* const cancelled = await cancelScheduledTask(scheduled.id)
|
|
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`.
|
|
1034
1309
|
*/
|
|
1035
|
-
async function
|
|
1036
|
-
await
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
if (
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
clearTimeout(timeout);
|
|
1043
|
-
scheduledTaskTimers.delete(record.timeoutId);
|
|
1044
|
-
}
|
|
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);
|
|
1045
1317
|
}
|
|
1046
|
-
|
|
1047
|
-
stopSchedulerIntervalIfIdle();
|
|
1048
|
-
return cancelled;
|
|
1318
|
+
return client;
|
|
1049
1319
|
}
|
|
1050
1320
|
//#endregion
|
|
1051
|
-
export {
|
|
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,8 +39,10 @@
|
|
|
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",
|
|
45
|
+
"croner": "^10.0.1",
|
|
44
46
|
"evlog": "^2.21.0",
|
|
45
47
|
"itty-time": "^2.0.2",
|
|
46
48
|
"jszip": "^3.10.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",
|