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/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 prevTenantIds = Object.keys(prev);
181
- const nextTenantIds = Object.keys(next);
182
- if (prevTenantIds.length !== nextTenantIds.length) return true;
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/schedule.ts
888
- const MAX_TIMEOUT_MS = 2147483647;
889
- const SCHEDULE_LOOKAHEAD_MS = 3600 * 1e3;
890
- const SCHEDULER_TICK_MS = 100;
891
- const scheduledTasks = /* @__PURE__ */ new Map();
892
- const scheduledTaskTimers = /* @__PURE__ */ new Map();
893
- const schedulerReady = Promise.resolve();
894
- let schedulerInterval;
895
- function createScheduledTaskId() {
896
- return randomUUID();
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
- function resolveScheduleTime(schedule) {
899
- if (schedule instanceof Date) {
900
- const timestamp = schedule.getTime();
901
- if (Number.isNaN(timestamp)) throw new TypeError("schedule date must be valid");
902
- return timestamp;
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
- if (typeof schedule === "number") {
905
- if (!Number.isFinite(schedule) || schedule < 0) throw new TypeError("schedule number must be a non-negative number of seconds");
906
- return Date.now() + schedule * 1e3;
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 delay = ms(schedule);
909
- if (!Number.isFinite(delay) || delay < 0) throw new TypeError("schedule string must be a valid non-negative duration");
910
- return Date.now() + delay;
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
- async function executeScheduledTask(id) {
913
- const record = scheduledTasks.get(id);
914
- if (!record) return;
915
- if (record.timeoutId !== void 0) scheduledTaskTimers.delete(record.timeoutId);
916
- scheduledTasks.delete(id);
917
- stopSchedulerIntervalIfIdle();
918
- const taskDef = tasks[record.taskName];
919
- if (!taskDef) throw new Error(`Task "${record.taskName}" is not available!`);
920
- if (!taskDef.resolve) throw new Error(`Task "${record.taskName}" is not implemented!`);
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
- function armScheduledTaskTimeout(id) {
928
- const record = scheduledTasks.get(id);
929
- if (!record || record.timeoutId !== void 0) return;
930
- const remainingMs = Math.max(record.runAt - Date.now(), 0);
931
- if (remainingMs > SCHEDULE_LOOKAHEAD_MS) return;
932
- const timeout = setTimeout(() => {
933
- executeScheduledTask(id);
934
- }, Math.min(remainingMs, MAX_TIMEOUT_MS));
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
- function runSchedulerTick() {
939
- for (const record of scheduledTasks.values()) armScheduledTaskTimeout(record.id);
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 ensureSchedulerInterval() {
942
- if (schedulerInterval) return;
943
- schedulerInterval = setInterval(runSchedulerTick, SCHEDULER_TICK_MS);
1190
+ function createRealtimeClientForTenant(tenantId, creds) {
1191
+ return createRealtimeClient({
1192
+ ...realtimeConfig(),
1193
+ ...toLibCredentials(tenantId, creds),
1194
+ logger: console
1195
+ });
944
1196
  }
945
- function stopSchedulerIntervalIfIdle() {
946
- if (!schedulerInterval || scheduledTasks.size > 0) return;
947
- clearInterval(schedulerInterval);
948
- schedulerInterval = void 0;
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
- * Schedules a Nitro task to run once in the future.\
952
- * Resolves and calls the task handler directly from Nitro's virtual task registry\
953
- * Numbers are treated as seconds, strings are parsed as human-readable durations, and dates are used as exact run times.
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
- scheduledTasks.set(record.id, record);
992
- armScheduledTaskTimeout(record.id);
993
- ensureSchedulerInterval();
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
- id: record.id,
996
- task: record.taskName,
997
- runAt: new Date(record.runAt).toISOString()
1234
+ tenantId,
1235
+ creds: await subscribedCredsFor(tenantId)
998
1236
  };
999
1237
  }
1000
1238
  /**
1001
- * Lists all tasks that are currently scheduled and have not started yet.\
1002
- * The returned object is keyed by the scheduled task UUID for easy lookup and cancellation.
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
- * @returns Object mapping scheduled task IDs to their public task information
1005
- *
1006
- * @example
1007
- * const tasks = await listScheduledTasks()
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 listScheduledTasks() {
1013
- await schedulerReady;
1014
- return Object.fromEntries([...scheduledTasks.values()].map((record) => [record.id, {
1015
- id: record.id,
1016
- task: record.taskName,
1017
- runAt: new Date(record.runAt).toISOString()
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
- * Cancels a scheduled task before it starts running.\
1022
- * Once the underlying Nitro task has started, it cannot be cancelled with this utility.
1023
- *
1024
- * @param id - The scheduled task UUID returned by `scheduleTask()` or `listScheduledTasks()`
1025
- * @returns `true` when a pending task was cancelled, otherwise `false`
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 scheduled = await scheduleTask('emails:send', {
1029
- * payload: { messageId: 'abc123' },
1030
- * schedule: '10 minutes',
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 cancelScheduledTask(id) {
1036
- await schedulerReady;
1037
- const record = scheduledTasks.get(id);
1038
- if (!record) return false;
1039
- if (record.timeoutId !== void 0) {
1040
- const timeout = scheduledTaskTimers.get(record.timeoutId);
1041
- if (timeout) {
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
- const cancelled = scheduledTasks.delete(id);
1047
- stopSchedulerIntervalIfIdle();
1048
- return cancelled;
1318
+ return client;
1049
1319
  }
1050
1320
  //#endregion
1051
- export { cancelScheduledTask, createError, createLogger, hasUserRequiredRole, isUserFromAllowedTenant, isUserFromDeployedTenant, listScheduledTasks, scheduleTask, useDeployedTenantClient, useDeployedTenantCredentials, useLogger, useSubscribedTenantClients, useSubscribedTenantCredentials, useTenantOption, useTenantOptions, useUser, useUserClient, useUserRoles, useUserTenantClient, useUserTenantCredentials };
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.7.2",
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",