c8y-nitro 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -514
- package/dist/{bootstrap-BSdVwFWO.mjs → bootstrap-ObaEF7rN.mjs} +6 -6
- package/dist/{c8y-api-BgTNTqHd.mjs → c8y-api-BFjK6AYb.mjs} +42 -6
- package/dist/cli/index.mjs +4 -4
- package/dist/credentials-eKnJ7m9Y.d.mts +5 -0
- package/dist/{index-Bvu7DqDt.d.mts → index-ByDSMEvU.d.mts} +21 -5
- package/dist/index.d.mts +7 -2
- package/dist/index.mjs +163 -35
- package/dist/{options-BkWL1FOa.mjs → options-B7_yb_wr.mjs} +4 -4
- package/dist/{package-bfMasPPg.mjs → package-BhFLRPOb.mjs} +1 -1
- package/dist/{roles-CoBPqvDv.mjs → roles-B58Fguop.mjs} +4 -4
- package/dist/runtime/handlers/invalidateTenantOptions.mjs +616 -5
- package/dist/runtime/middlewares/c8y-client.mjs +2 -2
- package/dist/runtime/middlewares/dev-user.dev.mjs +2 -2
- package/dist/runtime/plugins/c8y-variables.dev.mjs +1 -1
- package/dist/types.d.mts +3 -2
- package/dist/utils.d.mts +309 -63
- package/dist/utils.mjs +577 -114
- package/package.json +23 -15
- /package/dist/{config-BRnvtthI.mjs → config-BVQHqlgV.mjs} +0 -0
- /package/dist/{env-file-B0BK-uZW.mjs → env-file-BrOTmVjA.mjs} +0 -0
- /package/dist/{logging-CVlaRS4O.mjs → logging-DYnYICoX.mjs} +0 -0
package/dist/utils.mjs
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { useLogger } from "evlog/nitro/v3";
|
|
4
5
|
import { BasicAuth, Client, MicroserviceClientRequestAuth } from "@c8y/client";
|
|
5
6
|
import { defineCachedFunction } from "nitro/cache";
|
|
6
|
-
import {
|
|
7
|
-
import { HTTPError, defineHandler } from "nitro/h3";
|
|
8
|
-
import { useStorage } from "nitro/storage";
|
|
7
|
+
import { createError, createLogger } from "evlog";
|
|
9
8
|
import { useRuntimeConfig } from "nitro/runtime-config";
|
|
9
|
+
import { useNitroHooks } from "nitro/app";
|
|
10
|
+
import { defineHandler } from "nitro/h3";
|
|
10
11
|
import { c8yManifest } from "c8y-nitro/runtime";
|
|
11
|
-
import {
|
|
12
|
+
import { ms } from "itty-time";
|
|
13
|
+
import { tasks } from "#nitro/virtual/tasks";
|
|
12
14
|
//#region src/utils/internal/common.ts
|
|
13
15
|
/**
|
|
14
16
|
* Converts undici Request headers to the format expected by MicroserviceClientRequestAuth.\
|
|
@@ -83,6 +85,14 @@ const getCurrentUserTenantId = defineCachedFunction(async (requestOrEvent) => {
|
|
|
83
85
|
});
|
|
84
86
|
//#endregion
|
|
85
87
|
//#region src/utils/credentials.ts
|
|
88
|
+
let prevCredentials = null;
|
|
89
|
+
function shouldEmitTenantCredentialsUpdated(prev, next) {
|
|
90
|
+
if (!prev) return true;
|
|
91
|
+
const prevTenantIds = Object.keys(prev);
|
|
92
|
+
const nextTenantIds = Object.keys(next);
|
|
93
|
+
if (prevTenantIds.length !== nextTenantIds.length) return true;
|
|
94
|
+
return new Set(nextTenantIds).symmetricDifference(new Set(prevTenantIds)).size > 0;
|
|
95
|
+
}
|
|
86
96
|
/**
|
|
87
97
|
* Fetches credentials for all tenants subscribed to this microservice.\
|
|
88
98
|
* Uses bootstrap credentials from runtime config to query the microservice subscriptions API.\
|
|
@@ -90,7 +100,7 @@ const getCurrentUserTenantId = defineCachedFunction(async (requestOrEvent) => {
|
|
|
90
100
|
* @returns Object mapping tenant IDs to their respective credentials
|
|
91
101
|
* @config Cache TTL can be configured via:
|
|
92
102
|
* - `c8y.cache.credentialsTTL` in the Nitro config (value in seconds)
|
|
93
|
-
* - `
|
|
103
|
+
* - `NITRO_C8Y_CREDENTIALS_CACHE_TTL` environment variable
|
|
94
104
|
* @example
|
|
95
105
|
* // Get all subscribed tenant credentials:
|
|
96
106
|
* const credentials = await useSubscribedTenantCredentials()
|
|
@@ -105,8 +115,8 @@ const getCurrentUserTenantId = defineCachedFunction(async (requestOrEvent) => {
|
|
|
105
115
|
* // Force refresh:
|
|
106
116
|
* const freshCreds = await useSubscribedTenantCredentials.refresh()
|
|
107
117
|
*/
|
|
108
|
-
const
|
|
109
|
-
|
|
118
|
+
const cachedSubscribedTenantCredentials = defineCachedFunction(async () => {
|
|
119
|
+
const newCredentials = (await Client.getMicroserviceSubscriptions({
|
|
110
120
|
tenant: process.env.C8Y_BOOTSTRAP_TENANT,
|
|
111
121
|
user: process.env.C8Y_BOOTSTRAP_USER,
|
|
112
122
|
password: process.env.C8Y_BOOTSTRAP_PASSWORD
|
|
@@ -114,18 +124,26 @@ const useSubscribedTenantCredentials = Object.assign(defineCachedFunction(async
|
|
|
114
124
|
if (cred.tenant) acc[cred.tenant] = cred;
|
|
115
125
|
return acc;
|
|
116
126
|
}, {});
|
|
127
|
+
if (shouldEmitTenantCredentialsUpdated(prevCredentials, newCredentials)) useNitroHooks().callHook("c8y:tenantCredentialsUpdated", prevCredentials, newCredentials);
|
|
128
|
+
prevCredentials = newCredentials;
|
|
129
|
+
return newCredentials;
|
|
117
130
|
}, {
|
|
118
131
|
maxAge: useRuntimeConfig().c8yCredentialsCacheTTL ?? 600,
|
|
119
132
|
name: "_c8y_nitro_get_subscribed_tenant_credentials",
|
|
120
133
|
group: "c8y_nitro",
|
|
121
134
|
swr: false
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
135
|
+
});
|
|
136
|
+
const useSubscribedTenantCredentials = Object.assign(async () => {
|
|
137
|
+
const credentials = await cachedSubscribedTenantCredentials();
|
|
138
|
+
prevCredentials = credentials;
|
|
139
|
+
return credentials;
|
|
140
|
+
}, {
|
|
141
|
+
invalidate: cachedSubscribedTenantCredentials.invalidate,
|
|
126
142
|
refresh: async () => {
|
|
127
|
-
await
|
|
128
|
-
|
|
143
|
+
await cachedSubscribedTenantCredentials.invalidate();
|
|
144
|
+
const credentials = await cachedSubscribedTenantCredentials();
|
|
145
|
+
prevCredentials = credentials;
|
|
146
|
+
return credentials;
|
|
129
147
|
}
|
|
130
148
|
});
|
|
131
149
|
/**
|
|
@@ -133,7 +151,7 @@ const useSubscribedTenantCredentials = Object.assign(defineCachedFunction(async
|
|
|
133
151
|
* Uses the C8Y_BOOTSTRAP_TENANT environment variable to identify the deployed tenant.\
|
|
134
152
|
* Returns credentials from the subscribed tenant credentials cache (cached based on configured TTL, default: 10 minutes).
|
|
135
153
|
* @returns Credentials for the deployed tenant
|
|
136
|
-
* @throws {
|
|
154
|
+
* @throws {EvlogError} If no credentials found for the deployed tenant
|
|
137
155
|
* @example
|
|
138
156
|
* // Get deployed tenant credentials:
|
|
139
157
|
* const creds = await useDeployedTenantCredentials()
|
|
@@ -149,16 +167,15 @@ const useSubscribedTenantCredentials = Object.assign(defineCachedFunction(async
|
|
|
149
167
|
const useDeployedTenantCredentials = Object.assign(async () => {
|
|
150
168
|
const tenant = process.env.C8Y_BOOTSTRAP_TENANT;
|
|
151
169
|
const allCredsPromise = await useSubscribedTenantCredentials();
|
|
152
|
-
if (!allCredsPromise[tenant]) throw
|
|
170
|
+
if (!allCredsPromise[tenant]) throw createError({
|
|
153
171
|
message: `No credentials found for tenant deployed tenant '${tenant}'`,
|
|
154
|
-
status: 500
|
|
155
|
-
statusText: "Internal Server Error"
|
|
172
|
+
status: 500
|
|
156
173
|
});
|
|
157
174
|
return allCredsPromise[tenant];
|
|
158
175
|
}, {
|
|
159
176
|
invalidate: useSubscribedTenantCredentials.invalidate,
|
|
160
177
|
refresh: async () => {
|
|
161
|
-
await
|
|
178
|
+
await useSubscribedTenantCredentials.refresh();
|
|
162
179
|
return await useDeployedTenantCredentials();
|
|
163
180
|
}
|
|
164
181
|
});
|
|
@@ -168,7 +185,7 @@ const useDeployedTenantCredentials = Object.assign(async () => {
|
|
|
168
185
|
* Results are cached in the request context for subsequent calls within the same request.
|
|
169
186
|
* @param requestOrEvent - The H3Event or ServerRequest from the current request
|
|
170
187
|
* @returns Credentials for the user's tenant
|
|
171
|
-
* @throws {
|
|
188
|
+
* @throws {EvlogError} If no subscribed tenant credentials found for the user's tenant
|
|
172
189
|
* @example
|
|
173
190
|
* // In a request handler:
|
|
174
191
|
* const userCreds = await useUserTenantCredentials(event)
|
|
@@ -182,10 +199,9 @@ async function useUserTenantCredentials(requestOrEvent) {
|
|
|
182
199
|
if (request.context?.["c8y_user_tenant_credentials"]) return request.context["c8y_user_tenant_credentials"];
|
|
183
200
|
const tenantId = await getCurrentUserTenantId(requestOrEvent);
|
|
184
201
|
const userTenantCreds = (await useSubscribedTenantCredentials())[tenantId];
|
|
185
|
-
if (!userTenantCreds) throw
|
|
202
|
+
if (!userTenantCreds) throw createError({
|
|
186
203
|
message: `No subscribed tenant credentials found for user tenant '${tenantId}'`,
|
|
187
|
-
status: 500
|
|
188
|
-
statusText: "Internal Server Error"
|
|
204
|
+
status: 500
|
|
189
205
|
});
|
|
190
206
|
request.context ??= {};
|
|
191
207
|
request.context["c8y_user_tenant_credentials"] = userTenantCreds;
|
|
@@ -226,12 +242,12 @@ async function useUserTenantClient(requestOrEvent) {
|
|
|
226
242
|
if (request.context?.["c8y_user_tenant_client"]) return request.context["c8y_user_tenant_client"];
|
|
227
243
|
const tenantId = await getCurrentUserTenantId(requestOrEvent);
|
|
228
244
|
const creds = await useSubscribedTenantCredentials();
|
|
229
|
-
if (!creds[tenantId]) throw
|
|
245
|
+
if (!creds[tenantId]) throw createError({
|
|
230
246
|
message: `No subscribed tenant credentials found for user tenant '${tenantId}'`,
|
|
231
|
-
status: 500
|
|
232
|
-
statusText: "Internal Server Error"
|
|
247
|
+
status: 500
|
|
233
248
|
});
|
|
234
249
|
const tenantClient = new Client(new BasicAuth(creds[tenantId]), process.env.C8Y_BASEURL);
|
|
250
|
+
tenantClient.core.tenant = tenantId;
|
|
235
251
|
request.context ??= {};
|
|
236
252
|
request.context["c8y_user_tenant_client"] = tenantClient;
|
|
237
253
|
return tenantClient;
|
|
@@ -251,7 +267,11 @@ async function useUserTenantClient(requestOrEvent) {
|
|
|
251
267
|
async function useSubscribedTenantClients() {
|
|
252
268
|
const creds = await useSubscribedTenantCredentials();
|
|
253
269
|
const clients = {};
|
|
254
|
-
for (const [tenant, tenantCreds] of Object.entries(creds))
|
|
270
|
+
for (const [tenant, tenantCreds] of Object.entries(creds)) {
|
|
271
|
+
const client = new Client(new BasicAuth(tenantCreds), process.env.C8Y_BASEURL);
|
|
272
|
+
client.core.tenant = tenant;
|
|
273
|
+
clients[tenant] = client;
|
|
274
|
+
}
|
|
255
275
|
return clients;
|
|
256
276
|
}
|
|
257
277
|
/**
|
|
@@ -266,12 +286,13 @@ async function useSubscribedTenantClients() {
|
|
|
266
286
|
async function useDeployedTenantClient() {
|
|
267
287
|
const creds = await useSubscribedTenantCredentials();
|
|
268
288
|
const tenant = process.env.C8Y_BOOTSTRAP_TENANT;
|
|
269
|
-
if (!creds[tenant]) throw
|
|
289
|
+
if (!creds[tenant]) throw createError({
|
|
270
290
|
message: `No subscribed tenant credentials found for tenant '${tenant}'`,
|
|
271
|
-
status: 500
|
|
272
|
-
statusText: "Internal Server Error"
|
|
291
|
+
status: 500
|
|
273
292
|
});
|
|
274
|
-
|
|
293
|
+
const client = new Client(new BasicAuth(creds[tenant]), process.env.C8Y_BASEURL);
|
|
294
|
+
client.core.tenant = tenant;
|
|
295
|
+
return client;
|
|
275
296
|
}
|
|
276
297
|
//#endregion
|
|
277
298
|
//#region src/utils/resources.ts
|
|
@@ -289,10 +310,9 @@ async function useUser(requestOrEvent) {
|
|
|
289
310
|
const request = "req" in requestOrEvent ? requestOrEvent.req : requestOrEvent;
|
|
290
311
|
if (request.context?.["c8y_user"]) return request.context["c8y_user"];
|
|
291
312
|
const { res, data: user } = await useUserClient(requestOrEvent).user.currentWithEffectiveRoles();
|
|
292
|
-
if (!res.ok) throw
|
|
313
|
+
if (!res.ok) throw createError({
|
|
293
314
|
message: `Failed to fetch current user`,
|
|
294
|
-
status: res.status
|
|
295
|
-
statusText: res.statusText
|
|
315
|
+
status: res.status
|
|
296
316
|
});
|
|
297
317
|
request.context ??= {};
|
|
298
318
|
request.context["c8y_user"] = user;
|
|
@@ -328,9 +348,8 @@ function hasUserRequiredRole(roleOrRoles) {
|
|
|
328
348
|
if (isProbeRequest(event.url.pathname)) return;
|
|
329
349
|
const requiredRoles = Array.isArray(roleOrRoles) ? roleOrRoles : [roleOrRoles];
|
|
330
350
|
const userRoles = await useUserRoles(event);
|
|
331
|
-
if (!requiredRoles.some((role) => userRoles.includes(role))) throw
|
|
351
|
+
if (!requiredRoles.some((role) => userRoles.includes(role))) throw createError({
|
|
332
352
|
status: 403,
|
|
333
|
-
statusText: "Forbidden",
|
|
334
353
|
message: `User does not have required role(s) to access this resource: ${requiredRoles.join(", ")}`
|
|
335
354
|
});
|
|
336
355
|
});
|
|
@@ -340,9 +359,8 @@ function isUserFromAllowedTenant(tenantIdOrIds) {
|
|
|
340
359
|
if (isProbeRequest(event.url.pathname)) return;
|
|
341
360
|
const allowedTenants = Array.isArray(tenantIdOrIds) ? tenantIdOrIds : [tenantIdOrIds];
|
|
342
361
|
const userTenantId = await getCurrentUserTenantId(event);
|
|
343
|
-
if (!allowedTenants.includes(userTenantId)) throw
|
|
362
|
+
if (!allowedTenants.includes(userTenantId)) throw createError({
|
|
344
363
|
status: 403,
|
|
345
|
-
statusText: "Forbidden",
|
|
346
364
|
message: `User's tenant '${userTenantId}' is not allowed to access this resource. Allowed tenants: ${allowedTenants.join(", ")}`
|
|
347
365
|
});
|
|
348
366
|
});
|
|
@@ -367,14 +385,9 @@ function isUserFromDeployedTenant() {
|
|
|
367
385
|
if (isProbeRequest(event.url.pathname)) return;
|
|
368
386
|
const userTenantId = await getCurrentUserTenantId(event);
|
|
369
387
|
const deployedTenantId = process.env.C8Y_BOOTSTRAP_TENANT;
|
|
370
|
-
if (!deployedTenantId) throw new
|
|
371
|
-
|
|
372
|
-
statusText: "Internal Server Error",
|
|
373
|
-
message: "C8Y_BOOTSTRAP_TENANT environment variable is not set"
|
|
374
|
-
});
|
|
375
|
-
if (userTenantId !== deployedTenantId) throw new HTTPError({
|
|
388
|
+
if (!deployedTenantId) throw new Error("C8Y_BOOTSTRAP_TENANT environment variable is not set");
|
|
389
|
+
if (userTenantId !== deployedTenantId) throw createError({
|
|
376
390
|
status: 403,
|
|
377
|
-
statusText: "Forbidden",
|
|
378
391
|
message: `Only users from tenant '${deployedTenantId}' can access this resource.`
|
|
379
392
|
});
|
|
380
393
|
});
|
|
@@ -382,14 +395,211 @@ function isUserFromDeployedTenant() {
|
|
|
382
395
|
//#endregion
|
|
383
396
|
//#region src/utils/internal/tenantOptionFetchers.ts
|
|
384
397
|
/**
|
|
385
|
-
* Internal storage for cached
|
|
398
|
+
* Internal storage for cached option fetchers, keyed by
|
|
399
|
+
* `${tenant}::${category}::${key}`.
|
|
400
|
+
*
|
|
401
|
+
* Including the tenant and category in the key keeps options from different
|
|
402
|
+
* tenants (multi-tenancy) and settings categories from colliding on a shared
|
|
403
|
+
* `key`.
|
|
386
404
|
*/
|
|
387
405
|
const tenantOptionFetchers = {};
|
|
406
|
+
/**
|
|
407
|
+
* Builds the composite store key for a tenant/category/key triple.
|
|
408
|
+
* @param tenant - The tenant id owning the option
|
|
409
|
+
* @param category - The settings category
|
|
410
|
+
* @param key - The tenant option key
|
|
411
|
+
*/
|
|
412
|
+
function fetcherStoreKey(tenant, category, key) {
|
|
413
|
+
return `${tenant}::${category}::${key}`;
|
|
414
|
+
}
|
|
388
415
|
//#endregion
|
|
389
416
|
//#region src/utils/tenantOptions.ts
|
|
390
417
|
/**
|
|
418
|
+
* The microservice's own settings category, resolved once from runtime config
|
|
419
|
+
* (`manifest.settingsCategory` → `contextPath` → `name`). This is independent of
|
|
420
|
+
* which tenant is targeted.
|
|
421
|
+
*/
|
|
422
|
+
function getOwnCategory() {
|
|
423
|
+
return useRuntimeConfig().c8ySettingsCategory;
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Resolves the tenant id a client operates on, used for per-tenant cache keys.
|
|
427
|
+
*
|
|
428
|
+
* Prefers the stamped `client.core.tenant` (set by the `use*Client` helpers);
|
|
429
|
+
* otherwise resolves it on demand via `/tenant/currentTenant` and memoizes it
|
|
430
|
+
* back onto the client.
|
|
431
|
+
* @param client - The Cumulocity client
|
|
432
|
+
*/
|
|
433
|
+
async function resolveClientTenant(client) {
|
|
434
|
+
if (client.core.tenant) return client.core.tenant;
|
|
435
|
+
const res = await client.core.fetch("/tenant/currentTenant", {
|
|
436
|
+
method: "GET",
|
|
437
|
+
headers: { Accept: "application/json" }
|
|
438
|
+
});
|
|
439
|
+
const tenant = res.ok ? (await res.json())?.name : void 0;
|
|
440
|
+
if (!tenant) throw createError({
|
|
441
|
+
status: 500,
|
|
442
|
+
message: "Could not determine the target tenant",
|
|
443
|
+
fix: "This is unexpected. Retry the request, and contact the microservice operator if it keeps happening.",
|
|
444
|
+
internal: {
|
|
445
|
+
reason: res.ok ? "GET /tenant/currentTenant returned no tenant name" : "GET /tenant/currentTenant failed",
|
|
446
|
+
upstreamStatus: res.status
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
client.core.tenant = tenant;
|
|
450
|
+
return tenant;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Builds the Cumulocity Options API path for a category (and optional key).
|
|
454
|
+
* @param category - The settings category
|
|
455
|
+
* @param key - The tenant option key (omit for category-level operations)
|
|
456
|
+
*/
|
|
457
|
+
function optionsPath(category, key) {
|
|
458
|
+
const base = `/tenant/options/${encodeURIComponent(category)}`;
|
|
459
|
+
return key === void 0 ? base : `${base}/${encodeURIComponent(key)}`;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Cumulocity role required to read tenant options.
|
|
463
|
+
*/
|
|
464
|
+
const OPTION_READ_ROLE = "ROLE_OPTION_MANAGEMENT_READ";
|
|
465
|
+
/**
|
|
466
|
+
* Cumulocity role required to create, update or delete tenant options.
|
|
467
|
+
*/
|
|
468
|
+
const OPTION_ADMIN_ROLE = "ROLE_OPTION_MANAGEMENT_ADMIN";
|
|
469
|
+
/**
|
|
470
|
+
* Builds a structured error for a failed Options API request.
|
|
471
|
+
*
|
|
472
|
+
* A failed options request is a microservice/config problem (e.g. a missing
|
|
473
|
+
* `ROLE_OPTION_MANAGEMENT_*` role), not something the frontend caller can act on
|
|
474
|
+
* — so the response is a **generic 500** that reveals nothing about tenant
|
|
475
|
+
* options. All the diagnostic detail (which operation, upstream status, internal
|
|
476
|
+
* endpoint/method, and the Cumulocity role it needs) goes into `internal`, which
|
|
477
|
+
* is logged for debugging but never serialized to the HTTP response.
|
|
478
|
+
* @param status - The upstream HTTP status (for `internal`)
|
|
479
|
+
* @param operation - A human-readable summary of the failed operation (for `internal`)
|
|
480
|
+
* @param internal - Backend-only debugging context (endpoint, method, required role)
|
|
481
|
+
*/
|
|
482
|
+
function optionRequestError(status, operation, internal) {
|
|
483
|
+
return createError({
|
|
484
|
+
status: 500,
|
|
485
|
+
message: "Internal Server Error",
|
|
486
|
+
internal: {
|
|
487
|
+
operation,
|
|
488
|
+
upstreamStatus: status,
|
|
489
|
+
...internal
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Reads a single option value directly from the Options API.
|
|
495
|
+
* Returns `undefined` when the option is not set (404).
|
|
496
|
+
* @param client - The Cumulocity client (determines tenant + auth)
|
|
497
|
+
* @param category - The settings category
|
|
498
|
+
* @param key - The tenant option key
|
|
499
|
+
*/
|
|
500
|
+
async function fetchOptionValue(client, category, key) {
|
|
501
|
+
const endpoint = optionsPath(category, key);
|
|
502
|
+
const res = await client.core.fetch(endpoint, {
|
|
503
|
+
method: "GET",
|
|
504
|
+
headers: { Accept: "application/json" }
|
|
505
|
+
});
|
|
506
|
+
if (res.status === 404) return;
|
|
507
|
+
if (!res.ok) throw optionRequestError(res.status, `Failed to read tenant option '${key}'`, {
|
|
508
|
+
method: "GET",
|
|
509
|
+
endpoint,
|
|
510
|
+
requiredRole: OPTION_READ_ROLE
|
|
511
|
+
});
|
|
512
|
+
return (await res.json())?.value;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Creates or updates a single option value (upsert) via the Options API.
|
|
516
|
+
* @param client - The Cumulocity client (determines tenant + auth)
|
|
517
|
+
* @param category - The settings category
|
|
518
|
+
* @param key - The tenant option key
|
|
519
|
+
* @param value - The value to store
|
|
520
|
+
*/
|
|
521
|
+
async function putOptionValue(client, category, key, value) {
|
|
522
|
+
const endpoint = optionsPath(category, key);
|
|
523
|
+
const res = await client.core.fetch(endpoint, {
|
|
524
|
+
method: "PUT",
|
|
525
|
+
headers: {
|
|
526
|
+
"Content-Type": "application/json",
|
|
527
|
+
"Accept": "application/json"
|
|
528
|
+
},
|
|
529
|
+
body: JSON.stringify({ value })
|
|
530
|
+
});
|
|
531
|
+
if (!res.ok) throw optionRequestError(res.status, `Failed to set tenant option '${key}'`, {
|
|
532
|
+
method: "PUT",
|
|
533
|
+
endpoint,
|
|
534
|
+
requiredRole: OPTION_ADMIN_ROLE
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Deletes a single option via the Options API. Idempotent: a 404 resolves.
|
|
539
|
+
* @param client - The Cumulocity client (determines tenant + auth)
|
|
540
|
+
* @param category - The settings category
|
|
541
|
+
* @param key - The tenant option key
|
|
542
|
+
*/
|
|
543
|
+
async function deleteOptionValue(client, category, key) {
|
|
544
|
+
const endpoint = optionsPath(category, key);
|
|
545
|
+
const res = await client.core.fetch(endpoint, {
|
|
546
|
+
method: "DELETE",
|
|
547
|
+
headers: { Accept: "application/json" }
|
|
548
|
+
});
|
|
549
|
+
if (res.status === 404) return;
|
|
550
|
+
if (!res.ok) throw optionRequestError(res.status, `Failed to delete tenant option '${key}'`, {
|
|
551
|
+
method: "DELETE",
|
|
552
|
+
endpoint,
|
|
553
|
+
requiredRole: OPTION_ADMIN_ROLE
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Updates the `editable` flag of a single option (management tenant only).
|
|
558
|
+
* @param client - The Cumulocity client (must be the management tenant)
|
|
559
|
+
* @param category - The settings category
|
|
560
|
+
* @param key - The tenant option key
|
|
561
|
+
* @param editable - Whether the option may be edited
|
|
562
|
+
* @param targetTenant - The tenant whose option flag is updated
|
|
563
|
+
*/
|
|
564
|
+
async function putOptionEditable(client, category, key, editable, targetTenant) {
|
|
565
|
+
const endpoint = `${optionsPath(category, key)}/editable`;
|
|
566
|
+
const res = await client.core.fetch(`${endpoint}?targetTenant=${encodeURIComponent(targetTenant)}`, {
|
|
567
|
+
method: "PUT",
|
|
568
|
+
headers: {
|
|
569
|
+
"Content-Type": "application/json",
|
|
570
|
+
"Accept": "application/json"
|
|
571
|
+
},
|
|
572
|
+
body: JSON.stringify({ editable })
|
|
573
|
+
});
|
|
574
|
+
if (!res.ok) throw optionRequestError(res.status, `Failed to update editable flag for tenant option '${key}'`, {
|
|
575
|
+
method: "PUT",
|
|
576
|
+
endpoint,
|
|
577
|
+
targetTenant,
|
|
578
|
+
requiredRole: `${OPTION_ADMIN_ROLE} (management tenant only)`
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Lists all options set in a category as a key/value map.
|
|
583
|
+
* @param client - The Cumulocity client (determines tenant + auth)
|
|
584
|
+
* @param category - The settings category
|
|
585
|
+
*/
|
|
586
|
+
async function listCategoryOptions(client, category) {
|
|
587
|
+
const endpoint = optionsPath(category);
|
|
588
|
+
const res = await client.core.fetch(endpoint, {
|
|
589
|
+
method: "GET",
|
|
590
|
+
headers: { Accept: "application/json" }
|
|
591
|
+
});
|
|
592
|
+
if (res.status === 404) return {};
|
|
593
|
+
if (!res.ok) throw optionRequestError(res.status, `Failed to list tenant options for category '${category}'`, {
|
|
594
|
+
method: "GET",
|
|
595
|
+
endpoint,
|
|
596
|
+
requiredRole: OPTION_READ_ROLE
|
|
597
|
+
});
|
|
598
|
+
return { ...await res.json() };
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
391
601
|
* Gets the cache TTL for a specific tenant option key.
|
|
392
|
-
* Uses per-key override if defined, otherwise falls back to default TTL.
|
|
602
|
+
* Uses per-key override if defined, otherwise falls back to the default TTL.
|
|
393
603
|
* @param key - The tenant option key
|
|
394
604
|
*/
|
|
395
605
|
function getTenantOptionCacheTTL(key) {
|
|
@@ -397,60 +607,143 @@ function getTenantOptionCacheTTL(key) {
|
|
|
397
607
|
return config.c8yTenantOptionsPerKeyTTL?.[key] ?? config.c8yDefaultTenantOptionsTTL ?? 600;
|
|
398
608
|
}
|
|
399
609
|
/**
|
|
400
|
-
* Factory
|
|
610
|
+
* Factory that creates a cached read fetcher for a specific tenant/category/key.
|
|
611
|
+
* All fetchers share the same cache group so they can be invalidated together.
|
|
612
|
+
* @param client - The Cumulocity client (determines tenant + auth)
|
|
613
|
+
* @param tenant - The tenant id (for the cache key)
|
|
614
|
+
* @param category - The settings category
|
|
401
615
|
* @param key - The tenant option key
|
|
402
616
|
*/
|
|
403
|
-
function createCachedTenantOptionFetcher(key) {
|
|
404
|
-
const cacheName = `_c8y_nitro_tenant_option_${key.replace(
|
|
405
|
-
const
|
|
406
|
-
const client = await useDeployedTenantClient();
|
|
407
|
-
const category = useRuntimeConfig().c8ySettingsCategory;
|
|
408
|
-
try {
|
|
409
|
-
return (await client.options.tenant.detail({
|
|
410
|
-
key,
|
|
411
|
-
category
|
|
412
|
-
})).data.value;
|
|
413
|
-
} catch (error) {
|
|
414
|
-
if (error?.res?.status === 404 || error?.status === 404) return;
|
|
415
|
-
throw error;
|
|
416
|
-
}
|
|
417
|
-
}, {
|
|
617
|
+
function createCachedTenantOptionFetcher(client, tenant, category, key) {
|
|
618
|
+
const cacheName = `_c8y_nitro_tenant_option_${fetcherStoreKey(tenant, category, key).replace(/[^a-zA-Z0-9]/g, "_")}`;
|
|
619
|
+
const cachedFetcher = defineCachedFunction(async () => fetchOptionValue(client, category, key), {
|
|
418
620
|
maxAge: getTenantOptionCacheTTL(key),
|
|
419
621
|
name: cacheName,
|
|
420
622
|
group: "c8y_nitro",
|
|
421
623
|
swr: false
|
|
422
624
|
});
|
|
423
|
-
return Object.assign(
|
|
424
|
-
invalidate
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
},
|
|
428
|
-
refresh: async () => {
|
|
429
|
-
const completeKey = `c8y_nitro:functions:${cacheName}.json`;
|
|
430
|
-
await useStorage("cache").removeItem(completeKey);
|
|
431
|
-
return await fetcher();
|
|
432
|
-
}
|
|
433
|
-
});
|
|
625
|
+
return Object.assign(cachedFetcher, { refresh: async () => {
|
|
626
|
+
await cachedFetcher.invalidate();
|
|
627
|
+
return await cachedFetcher();
|
|
628
|
+
} });
|
|
434
629
|
}
|
|
435
630
|
/**
|
|
436
|
-
* Gets or creates
|
|
631
|
+
* Gets or creates the cached read fetcher for a tenant/category/key triple.
|
|
632
|
+
* @param client - The Cumulocity client (determines tenant + auth)
|
|
633
|
+
* @param tenant - The resolved tenant id
|
|
634
|
+
* @param category - The settings category
|
|
437
635
|
* @param key - The tenant option key
|
|
438
636
|
*/
|
|
439
|
-
function getOrCreateFetcher(key) {
|
|
440
|
-
|
|
637
|
+
function getOrCreateFetcher(client, tenant, category, key) {
|
|
638
|
+
const storeKey = fetcherStoreKey(tenant, category, key);
|
|
639
|
+
let fetcher = tenantOptionFetchers[storeKey];
|
|
441
640
|
if (!fetcher) {
|
|
442
|
-
fetcher = createCachedTenantOptionFetcher(key);
|
|
443
|
-
tenantOptionFetchers[
|
|
641
|
+
fetcher = createCachedTenantOptionFetcher(client, tenant, category, key);
|
|
642
|
+
tenantOptionFetchers[storeKey] = fetcher;
|
|
444
643
|
}
|
|
445
644
|
return fetcher;
|
|
446
645
|
}
|
|
447
646
|
/**
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
|
|
647
|
+
* Invalidates the cached read for a store key, if one has been created.
|
|
648
|
+
* @param storeKey - The composite `${tenant}::${category}::${key}` store key
|
|
649
|
+
*/
|
|
650
|
+
async function invalidateStoreKey(storeKey) {
|
|
651
|
+
const fetcher = tenantOptionFetchers[storeKey];
|
|
652
|
+
if (fetcher) await fetcher.invalidate();
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Invalidates every created fetcher whose store key matches the predicate.
|
|
656
|
+
* @param predicate - Selects store keys to invalidate
|
|
657
|
+
*/
|
|
658
|
+
async function invalidateFetchers(predicate) {
|
|
659
|
+
await Promise.all(Object.entries(tenantOptionFetchers).filter(([storeKey]) => predicate(storeKey)).map(([, fetcher]) => fetcher.invalidate()));
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Refreshes every created fetcher whose store key matches the predicate.
|
|
663
|
+
* @param predicate - Selects store keys to refresh
|
|
664
|
+
* @returns Map of matched store keys to their refreshed values
|
|
665
|
+
*/
|
|
666
|
+
async function refreshFetchers(predicate) {
|
|
667
|
+
const entries = Object.entries(tenantOptionFetchers).filter(([storeKey]) => predicate(storeKey));
|
|
668
|
+
const values = await Promise.all(entries.map(([, fetcher]) => fetcher.refresh()));
|
|
669
|
+
return Object.fromEntries(entries.map(([storeKey], i) => [storeKey, values[i]]));
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Builds a single-option handle for a client/category/key.
|
|
673
|
+
* @param client - The Cumulocity client (determines tenant + auth)
|
|
674
|
+
* @param category - The settings category
|
|
675
|
+
* @param key - The tenant option key
|
|
676
|
+
*/
|
|
677
|
+
function makeTenantOption(client, category, key) {
|
|
678
|
+
const currentStoreKey = async () => fetcherStoreKey(await resolveClientTenant(client), category, key);
|
|
679
|
+
const readFetcher = async () => getOrCreateFetcher(client, await resolveClientTenant(client), category, key);
|
|
680
|
+
return {
|
|
681
|
+
read: async () => (await readFetcher())(),
|
|
682
|
+
set: async (value) => {
|
|
683
|
+
await putOptionValue(client, category, key, value);
|
|
684
|
+
await invalidateStoreKey(await currentStoreKey());
|
|
685
|
+
},
|
|
686
|
+
getOrInsert: async (fallback) => {
|
|
687
|
+
const current = await (await readFetcher())();
|
|
688
|
+
if (current !== void 0) return current;
|
|
689
|
+
await putOptionValue(client, category, key, fallback);
|
|
690
|
+
await invalidateStoreKey(await currentStoreKey());
|
|
691
|
+
return fallback;
|
|
692
|
+
},
|
|
693
|
+
delete: async () => {
|
|
694
|
+
await deleteOptionValue(client, category, key);
|
|
695
|
+
await invalidateStoreKey(await currentStoreKey());
|
|
696
|
+
},
|
|
697
|
+
setEditable: async (editable, options) => {
|
|
698
|
+
await putOptionEditable(client, category, key, editable, options.targetTenant);
|
|
699
|
+
await invalidateStoreKey(await currentStoreKey());
|
|
700
|
+
},
|
|
701
|
+
refresh: async () => (await readFetcher()).refresh(),
|
|
702
|
+
invalidate: async () => invalidateStoreKey(await currentStoreKey())
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Builds a category handle. The runtime shape is identical for the own and
|
|
707
|
+
* foreign categories; the public overloads differ only at the type level.
|
|
708
|
+
* @param client - The Cumulocity client (determines tenant + auth)
|
|
709
|
+
* @param category - The settings category
|
|
710
|
+
*/
|
|
711
|
+
function makeTenantOptionCategory(client, category) {
|
|
712
|
+
const categoryPrefix = async () => `${await resolveClientTenant(client)}::${category}::`;
|
|
713
|
+
return {
|
|
714
|
+
option: (key) => makeTenantOption(client, category, key),
|
|
715
|
+
list: () => listCategoryOptions(client, category),
|
|
716
|
+
setAll: async (values) => {
|
|
717
|
+
const entries = Object.entries(values);
|
|
718
|
+
await Promise.all(entries.map(([key, value]) => putOptionValue(client, category, key, value)));
|
|
719
|
+
const tenant = await resolveClientTenant(client);
|
|
720
|
+
await Promise.all(entries.map(([key]) => invalidateStoreKey(fetcherStoreKey(tenant, category, key))));
|
|
721
|
+
},
|
|
722
|
+
invalidateAll: async () => {
|
|
723
|
+
const prefix = await categoryPrefix();
|
|
724
|
+
await invalidateFetchers((storeKey) => storeKey.startsWith(prefix));
|
|
725
|
+
},
|
|
726
|
+
refreshAll: async () => {
|
|
727
|
+
const prefix = await categoryPrefix();
|
|
728
|
+
const refreshed = await refreshFetchers((storeKey) => storeKey.startsWith(prefix));
|
|
729
|
+
return Object.fromEntries(Object.entries(refreshed).map(([storeKey, value]) => [storeKey.slice(prefix.length), value]));
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Gets a handle for a single tenant option in the microservice's **own** settings
|
|
735
|
+
* category, targeting the tenant of the given `client`.\
|
|
736
|
+
* Reads are cached per tenant, using the client's service user credentials.
|
|
737
|
+
*
|
|
738
|
+
* This is a shortcut for `useTenantOptions(client).option(key)` — it only drops
|
|
739
|
+
* the category argument. For a single option in a **foreign** category, use
|
|
740
|
+
* `useTenantOptions(client, category).option(key)`.
|
|
451
741
|
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
742
|
+
* Manifest-declared keys are offered via autocomplete, but any dynamic string
|
|
743
|
+
* is accepted too (e.g. `encrypted.password.${hash}`).
|
|
744
|
+
*
|
|
745
|
+
* @param client - The Cumulocity client that determines the target tenant
|
|
746
|
+
* @param key - The tenant option key
|
|
454
747
|
*
|
|
455
748
|
* @config Cache TTL can be configured via:
|
|
456
749
|
* - `c8y.cache.defaultTenantOptionsTTL` — Default TTL for all keys (in seconds)
|
|
@@ -458,42 +751,212 @@ function getOrCreateFetcher(key) {
|
|
|
458
751
|
* - `NITRO_C8Y_DEFAULT_TENANT_OPTIONS_TTL` — Environment variable for default TTL
|
|
459
752
|
*
|
|
460
753
|
* @example
|
|
461
|
-
* //
|
|
462
|
-
* const value = await useTenantOption('
|
|
754
|
+
* // Read the owner tenant's config:
|
|
755
|
+
* const value = await useTenantOption(await useDeployedTenantClient(), 'featureMode').read()
|
|
463
756
|
*
|
|
464
|
-
* //
|
|
465
|
-
* const
|
|
757
|
+
* // Read the current request tenant's config (multi-tenant):
|
|
758
|
+
* const value = await useTenantOption(await useUserTenantClient(event), 'featureMode').read()
|
|
466
759
|
*
|
|
467
|
-
* //
|
|
468
|
-
* await useTenantOption.
|
|
760
|
+
* // Create or update, or seed a dynamic key:
|
|
761
|
+
* await useTenantOption(client, 'featureMode').set('advanced')
|
|
762
|
+
* const token = await useTenantOption(client, `encrypted.password.${hash}`).getOrInsert('')
|
|
763
|
+
*/
|
|
764
|
+
function useTenantOption(client, key) {
|
|
765
|
+
return makeTenantOption(client, getOwnCategory(), key);
|
|
766
|
+
}
|
|
767
|
+
const useTenantOptionsBase = ((client, category) => makeTenantOptionCategory(client, category ?? getOwnCategory()));
|
|
768
|
+
/**
|
|
769
|
+
* Gets a handle for a whole settings category, targeting the tenant of the given
|
|
770
|
+
* `client`.\
|
|
771
|
+
* Omit the category (or pass the own {@link C8YSettingsCategory}) for the
|
|
772
|
+
* microservice's own category; pass any other category to read/write options in
|
|
773
|
+
* a foreign category (where `credentials.*` keys are rejected).
|
|
469
774
|
*
|
|
470
|
-
*
|
|
471
|
-
* const fresh = await useTenantOption.refresh('myOption')
|
|
775
|
+
* @param client - The Cumulocity client that determines the target tenant
|
|
472
776
|
*
|
|
473
|
-
*
|
|
474
|
-
* await
|
|
777
|
+
* @example
|
|
778
|
+
* const client = await useUserTenantClient(event)
|
|
779
|
+
*
|
|
780
|
+
* // Own category:
|
|
781
|
+
* const values = await useTenantOptions(client).list()
|
|
782
|
+
* await useTenantOptions(client).option('featureMode').set('advanced')
|
|
783
|
+
*
|
|
784
|
+
* // Foreign category:
|
|
785
|
+
* const value = await useTenantOptions(client, 'other-service').option('someKey').read()
|
|
475
786
|
*
|
|
476
|
-
* //
|
|
477
|
-
*
|
|
787
|
+
* // Invalidate all cached options of one tenant + category:
|
|
788
|
+
* await useTenantOptions(client, 'other-service').invalidateAll()
|
|
789
|
+
*
|
|
790
|
+
* // Invalidate everything, across all tenants and categories:
|
|
791
|
+
* await useTenantOptions.invalidateAll()
|
|
478
792
|
*/
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
invalidate: async (key) => {
|
|
483
|
-
const fetcher = tenantOptionFetchers[key];
|
|
484
|
-
if (fetcher) await fetcher.invalidate();
|
|
485
|
-
},
|
|
486
|
-
refresh: async (key) => {
|
|
487
|
-
return await getOrCreateFetcher(key).refresh();
|
|
488
|
-
},
|
|
489
|
-
invalidateAll: async () => {
|
|
490
|
-
await Promise.all(Object.values(tenantOptionFetchers).map((fetcher) => fetcher?.invalidate()));
|
|
491
|
-
},
|
|
492
|
-
refreshAll: async () => {
|
|
493
|
-
const entries = Object.entries(tenantOptionFetchers);
|
|
494
|
-
const values = await Promise.all(entries.map(([, fetcher]) => fetcher?.refresh()));
|
|
495
|
-
return Object.fromEntries(entries.map(([key], i) => [key, values[i]]));
|
|
496
|
-
}
|
|
793
|
+
const useTenantOptions = Object.assign(useTenantOptionsBase, {
|
|
794
|
+
invalidateAll: () => invalidateFetchers(() => true),
|
|
795
|
+
refreshAll: () => refreshFetchers(() => true)
|
|
497
796
|
});
|
|
498
797
|
//#endregion
|
|
499
|
-
|
|
798
|
+
//#region src/utils/schedule.ts
|
|
799
|
+
const MAX_TIMEOUT_MS = 2147483647;
|
|
800
|
+
const SCHEDULE_LOOKAHEAD_MS = 3600 * 1e3;
|
|
801
|
+
const SCHEDULER_TICK_MS = 100;
|
|
802
|
+
const scheduledTasks = /* @__PURE__ */ new Map();
|
|
803
|
+
const scheduledTaskTimers = /* @__PURE__ */ new Map();
|
|
804
|
+
const schedulerReady = Promise.resolve();
|
|
805
|
+
let schedulerInterval;
|
|
806
|
+
function createScheduledTaskId() {
|
|
807
|
+
return randomUUID();
|
|
808
|
+
}
|
|
809
|
+
function resolveScheduleTime(schedule) {
|
|
810
|
+
if (schedule instanceof Date) {
|
|
811
|
+
const timestamp = schedule.getTime();
|
|
812
|
+
if (Number.isNaN(timestamp)) throw new TypeError("schedule date must be valid");
|
|
813
|
+
return timestamp;
|
|
814
|
+
}
|
|
815
|
+
if (typeof schedule === "number") {
|
|
816
|
+
if (!Number.isFinite(schedule) || schedule < 0) throw new TypeError("schedule number must be a non-negative number of seconds");
|
|
817
|
+
return Date.now() + schedule * 1e3;
|
|
818
|
+
}
|
|
819
|
+
const delay = ms(schedule);
|
|
820
|
+
if (!Number.isFinite(delay) || delay < 0) throw new TypeError("schedule string must be a valid non-negative duration");
|
|
821
|
+
return Date.now() + delay;
|
|
822
|
+
}
|
|
823
|
+
async function executeScheduledTask(id) {
|
|
824
|
+
const record = scheduledTasks.get(id);
|
|
825
|
+
if (!record) return;
|
|
826
|
+
if (record.timeoutId !== void 0) scheduledTaskTimers.delete(record.timeoutId);
|
|
827
|
+
scheduledTasks.delete(id);
|
|
828
|
+
stopSchedulerIntervalIfIdle();
|
|
829
|
+
const taskDef = tasks[record.taskName];
|
|
830
|
+
if (!taskDef) throw new Error(`Task "${record.taskName}" is not available!`);
|
|
831
|
+
if (!taskDef.resolve) throw new Error(`Task "${record.taskName}" is not implemented!`);
|
|
832
|
+
await (await taskDef.resolve()).run({
|
|
833
|
+
name: record.taskName,
|
|
834
|
+
payload: record.payload,
|
|
835
|
+
context: record.context
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
function armScheduledTaskTimeout(id) {
|
|
839
|
+
const record = scheduledTasks.get(id);
|
|
840
|
+
if (!record || record.timeoutId !== void 0) return;
|
|
841
|
+
const remainingMs = Math.max(record.runAt - Date.now(), 0);
|
|
842
|
+
if (remainingMs > SCHEDULE_LOOKAHEAD_MS) return;
|
|
843
|
+
const timeout = setTimeout(() => {
|
|
844
|
+
executeScheduledTask(id);
|
|
845
|
+
}, Math.min(remainingMs, MAX_TIMEOUT_MS));
|
|
846
|
+
record.timeoutId = Number(timeout);
|
|
847
|
+
scheduledTaskTimers.set(record.timeoutId, timeout);
|
|
848
|
+
}
|
|
849
|
+
function runSchedulerTick() {
|
|
850
|
+
for (const record of scheduledTasks.values()) armScheduledTaskTimeout(record.id);
|
|
851
|
+
}
|
|
852
|
+
function ensureSchedulerInterval() {
|
|
853
|
+
if (schedulerInterval) return;
|
|
854
|
+
schedulerInterval = setInterval(runSchedulerTick, SCHEDULER_TICK_MS);
|
|
855
|
+
}
|
|
856
|
+
function stopSchedulerIntervalIfIdle() {
|
|
857
|
+
if (!schedulerInterval || scheduledTasks.size > 0) return;
|
|
858
|
+
clearInterval(schedulerInterval);
|
|
859
|
+
schedulerInterval = void 0;
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Schedules a Nitro task to run once in the future.\
|
|
863
|
+
* Resolves and calls the task handler directly from Nitro's virtual task registry\
|
|
864
|
+
* Numbers are treated as seconds, strings are parsed as human-readable durations, and dates are used as exact run times.
|
|
865
|
+
*
|
|
866
|
+
* @param taskName - The Nitro task name to run (from `tasks/*.ts`)
|
|
867
|
+
* @param options - Task payload, context, and the schedule time
|
|
868
|
+
* @returns Information about the scheduled task
|
|
869
|
+
*
|
|
870
|
+
* @example
|
|
871
|
+
* // Run a task in 30 seconds:
|
|
872
|
+
* const scheduled = await scheduleTask('emails:send', {
|
|
873
|
+
* payload: { messageId: 'abc123' },
|
|
874
|
+
* schedule: 30,
|
|
875
|
+
* })
|
|
876
|
+
*
|
|
877
|
+
* @example
|
|
878
|
+
* // Run a task using a human-readable duration:
|
|
879
|
+
* await scheduleTask('reports:generate', {
|
|
880
|
+
* payload: { reportId: 'report-1' },
|
|
881
|
+
* schedule: '1 hour',
|
|
882
|
+
* })
|
|
883
|
+
*
|
|
884
|
+
* @example
|
|
885
|
+
* // Run a task at an exact time:
|
|
886
|
+
* await scheduleTask('cleanup:tenant', {
|
|
887
|
+
* payload: { tenant: 't12345' },
|
|
888
|
+
* schedule: new Date('2026-05-01T12:00:00Z'),
|
|
889
|
+
* })
|
|
890
|
+
*/
|
|
891
|
+
async function scheduleTask(taskName, options) {
|
|
892
|
+
if (!import.meta._tasks) throw new Error("scheduleTask() requires tasks to be enabled. Set `experimental: { tasks: true }` in your nitro.config.ts.");
|
|
893
|
+
if (!taskName) throw new TypeError("taskName is required");
|
|
894
|
+
const runAt = resolveScheduleTime(options.schedule);
|
|
895
|
+
const record = {
|
|
896
|
+
id: createScheduledTaskId(),
|
|
897
|
+
taskName,
|
|
898
|
+
payload: options.payload ?? {},
|
|
899
|
+
context: options.context ?? {},
|
|
900
|
+
runAt
|
|
901
|
+
};
|
|
902
|
+
scheduledTasks.set(record.id, record);
|
|
903
|
+
armScheduledTaskTimeout(record.id);
|
|
904
|
+
ensureSchedulerInterval();
|
|
905
|
+
return {
|
|
906
|
+
id: record.id,
|
|
907
|
+
task: record.taskName,
|
|
908
|
+
runAt: new Date(record.runAt).toISOString()
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Lists all tasks that are currently scheduled and have not started yet.\
|
|
913
|
+
* The returned object is keyed by the scheduled task UUID for easy lookup and cancellation.
|
|
914
|
+
*
|
|
915
|
+
* @returns Object mapping scheduled task IDs to their public task information
|
|
916
|
+
*
|
|
917
|
+
* @example
|
|
918
|
+
* const tasks = await listScheduledTasks()
|
|
919
|
+
* for (const [id, task] of Object.entries(tasks)) {
|
|
920
|
+
* console.log(id, task.task, task.runAt)
|
|
921
|
+
* }
|
|
922
|
+
*/
|
|
923
|
+
async function listScheduledTasks() {
|
|
924
|
+
await schedulerReady;
|
|
925
|
+
return Object.fromEntries([...scheduledTasks.values()].map((record) => [record.id, {
|
|
926
|
+
id: record.id,
|
|
927
|
+
task: record.taskName,
|
|
928
|
+
runAt: new Date(record.runAt).toISOString()
|
|
929
|
+
}]));
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Cancels a scheduled task before it starts running.\
|
|
933
|
+
* Once the underlying Nitro task has started, it cannot be cancelled with this utility.
|
|
934
|
+
*
|
|
935
|
+
* @param id - The scheduled task UUID returned by `scheduleTask()` or `listScheduledTasks()`
|
|
936
|
+
* @returns `true` when a pending task was cancelled, otherwise `false`
|
|
937
|
+
*
|
|
938
|
+
* @example
|
|
939
|
+
* const scheduled = await scheduleTask('emails:send', {
|
|
940
|
+
* payload: { messageId: 'abc123' },
|
|
941
|
+
* schedule: '10 minutes',
|
|
942
|
+
* })
|
|
943
|
+
*
|
|
944
|
+
* const cancelled = await cancelScheduledTask(scheduled.id)
|
|
945
|
+
*/
|
|
946
|
+
async function cancelScheduledTask(id) {
|
|
947
|
+
await schedulerReady;
|
|
948
|
+
const record = scheduledTasks.get(id);
|
|
949
|
+
if (!record) return false;
|
|
950
|
+
if (record.timeoutId !== void 0) {
|
|
951
|
+
const timeout = scheduledTaskTimers.get(record.timeoutId);
|
|
952
|
+
if (timeout) {
|
|
953
|
+
clearTimeout(timeout);
|
|
954
|
+
scheduledTaskTimers.delete(record.timeoutId);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
const cancelled = scheduledTasks.delete(id);
|
|
958
|
+
stopSchedulerIntervalIfIdle();
|
|
959
|
+
return cancelled;
|
|
960
|
+
}
|
|
961
|
+
//#endregion
|
|
962
|
+
export { cancelScheduledTask, createError, createLogger, hasUserRequiredRole, isUserFromAllowedTenant, isUserFromDeployedTenant, listScheduledTasks, scheduleTask, useDeployedTenantClient, useDeployedTenantCredentials, useLogger, useSubscribedTenantClients, useSubscribedTenantCredentials, useTenantOption, useTenantOptions, useUser, useUserClient, useUserRoles, useUserTenantClient, useUserTenantCredentials };
|