feat: new cache manager infinispan

Signed-off-by: John Redwood <john.r.k.redwood@gmail.com>
This commit is contained in:
John Redwood
2025-08-04 22:23:27 +10:00
parent 615fc4ba7c
commit 133519ba25
12 changed files with 1239 additions and 61 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/backend-test-utils': minor
'@backstage/backend-defaults': minor
---
feat: new cache manager `Infinispan Data Grid`
@@ -199,6 +199,7 @@ impactful
incentivised
Indal
indexable
infinispan
ingestors
inlined
inlinehilite
+36 -2
View File
@@ -284,8 +284,8 @@ stores as a means of improving performance or reliability. Similar to how
databases are supported, plugins receive logically separated cache connections,
which are powered by [Keyv](https://github.com/lukechilds/keyv) under the hood.
At this time of writing, Backstage can be configured to use one of three cache
stores: memory, which is mainly used for local testing, memcache or Redis,
At this time of writing, Backstage can be configured to use one of five cache
stores: memory, which is mainly used for local testing, memcache, redis, valkey or infinispan,
which are cache stores better suited for production deployment. The right cache
store for your Backstage instance will depend on your own run-time constraints
and those required of the plugins you're running.
@@ -316,6 +316,40 @@ backend:
connection: redis://user:pass@cache.example.com:6379
```
### Use Infinispan for cache
#### Minimal configuration
- defaults, no `authentication`, expects cache named `cache` and host `127.0.0.1:11222`)
```yaml
backend:
cache:
store: infinispan
```
#### Extended configuration
- Unlike Redis, Infinispan will **not** create the cache for you. It's expected you've configured the cache in your infinispan server prior to configuration here.
- A full list of configuration items are available: https://docs.jboss.org/infinispan/hotrod-clients/javascript/1.0/apidocs/module-infinispan.html including support for backup clusters.
```yaml
backend:
cache:
store: infinispan
infinispan:
servers:
- host: 127.0.0.1
port: 11222
cacheName: backstage-cache
mediaType: application/json
authentication:
enabled: true
userName: yourusername
password: yourpassword
saslMechanism: PLAIN
```
Contributions supporting other cache stores are welcome!
## Containerization
+179
View File
@@ -750,6 +750,185 @@ export interface Config {
connection: string;
/** An optional default TTL (in milliseconds). */
defaultTtl?: number | HumanDuration | string;
}
| {
/**
* Infinispan cache store configuration.
* @see https://docs.jboss.org/infinispan/hotrod-clients/javascript/1.0/apidocs/module-infinispan.html
*/
store: 'infinispan';
/**
* An optional default TTL (in milliseconds).
*/
defaultTtl?: number | HumanDuration | string;
/**
* Configuration for the Infinispan cache store.
*/
infinispan?: {
/**
* Version of client/server protocol.
* @default '2.9' is the latest version.
*/
version?: '2.9' | '2.5' | '2.2';
/**
* Infinispan Cache Name if not provided default is `cache` recommended to set this.
*/
cacheName?: string;
/**
* Optional number of retries for operation.
* Defaults to 3.
*/
maxRetries?: number;
/**
* Optional flag to controls whether the client deals with topology updates or not.
* @default true
*/
topologyUpdates?: boolean;
/**
* Media type of the cache contents.
* @default 'text/plain'
*/
mediaType?: 'text/plain' | 'application/json';
/**
* Infinispan server host and port configuration.
* If this is an array, the client will connect to all servers in the list based on TOPOLOGY_AWARE routing.
* If this is a single object, it will be used as the default server.
*/
servers?:
| Array<{
/**
* Infinispan server host.
*/
host: string;
/**
* Infinispan server port (Hot Rod protocol). Defaults to `11222`.
*/
port?: number;
}>
| {
/**
* Infinispan server host. Defaults to `127.0.0.1`.
*/
host?: string;
/**
* Infinispan server port (Hot Rod protocol). Defaults to `11222`.
*/
port?: number;
};
authentication?: {
/**
* Enable authentication. Defaults to `false`.
*/
enabled?: boolean;
/**
* Select the SASL mechanism to use. Can be one of PLAIN, DIGEST-MD5, SCRAM-SHA-1, SCRAM-SHA-256, SCRAM-SHA-384, SCRAM-SHA-512, EXTERNAL, OAUTHBEARER
*/
saslMechanism?: string;
/**
* userName for authentication.
*/
userName?: string;
/**
* Password for authentication.
* @visibility secret
*/
password?: string;
/**
* The OAuth token. Required by the OAUTHBEARER mechanism.
* @visibility secret
*/
token?: string;
/**
* The SASL authorization ID.
*/
authzid?: string;
};
/**
* TLS/SSL configuration.
*/
ssl?: {
/**
* Enable ssl connection. Defaults to `false`.
* @default false
*/
enabled?: boolean;
/**
* Optional field with secure protocol in use.
* @default TLSv1_2_method
*/
secureProtocol?: string;
/**
* Optional paths of trusted SSL certificates.
*/
trustCerts?: Array<string>;
clientAuth?: {
/**
* Optional path to client authentication key
*/
key?: string;
/**
* Optional password for client key
*/
passphrase?: string;
/**
* Optional client certificate
*/
cert?: string;
};
/**
* Optional SNI host name.
*/
sniHostName?: string;
/**
* Optional crypto store configuration.
*/
cryptoStore?: {
/** Optional crypto store path. */
path?: string;
/** Optional password for crypto store. */
passphrase?: string;
};
};
/**
* Optional additional clusters for cross-site failovers.
* Array.<Cluster>
*/
clusters?: Array<{
/**
* Optional Cluster name
*/
name?: string;
/**
* Cluster servers details.
* Array.<ServerAddress>
*/
servers: Array<{
/**
* Infinispan cluster server host.
*/
host: string;
/**
* Infinispan server port (Hot Rod protocol). Defaults to `11222`.
*/
port?: number;
}>;
}>;
};
};
cors?: {
+1
View File
@@ -168,6 +168,7 @@
"fs-extra": "^11.2.0",
"git-url-parse": "^15.0.0",
"helmet": "^6.0.0",
"infinispan": "^0.12.0",
"is-glob": "^4.0.3",
"jose": "^5.0.0",
"keyv": "^5.2.1",
@@ -27,9 +27,16 @@ import {
ttlToMilliseconds,
CacheStoreOptions,
RedisCacheStoreOptions,
InfinispanClientBehaviorOptions,
InfinispanServerConfig,
} from './types';
import { InfinispanOptionsMapper } from './providers/infinispan/InfinispanOptionsMapper';
import { durationToMilliseconds } from '@backstage/types';
import { readDurationFromConfig } from '@backstage/config';
import { ConfigReader, readDurationFromConfig } from '@backstage/config';
import {
InfinispanClientCacheInterface,
InfinispanKeyvStore,
} from './providers/infinispan/InfinispanKeyvStore';
type StoreFactory = (pluginId: string, defaultTtl: number | undefined) => Keyv;
@@ -50,6 +57,7 @@ export class CacheManager {
valkey: this.createValkeyStoreFactory(),
memcache: this.createMemcacheStoreFactory(),
memory: this.createMemoryStoreFactory(),
infinispan: this.createInfinispanStoreFactory(),
};
private readonly logger?: LoggerService;
@@ -64,6 +72,8 @@ export class CacheManager {
* config section, specifically the `.cache` key.
*
* @param config - The loaded application configuration.
* @param options - Optional configuration for the CacheManager.
* @returns A new CacheManager instance.
*/
static fromConfig(
config: RootConfigService,
@@ -112,7 +122,7 @@ export class CacheManager {
/**
* Parse store-specific options from configuration.
*
* @param store - The cache store type ('redis', 'valkey', 'memcache', or 'memory')
* @param store - The cache store type ('redis', 'valkey', 'memcache', 'infinispan', or 'memory')
* @param config - The configuration service
* @param logger - Optional logger for warnings
* @returns The parsed store options
@@ -124,11 +134,27 @@ export class CacheManager {
): CacheStoreOptions | undefined {
const storeConfigPath = `backend.cache.${store}`;
if (
(store === 'redis' || store === 'valkey') &&
config.has(storeConfigPath)
) {
return CacheManager.parseRedisOptions(storeConfigPath, config, logger);
if (!config.has(storeConfigPath)) {
logger?.warn(
`No configuration found for cache store '${store}' at '${storeConfigPath}'.`,
);
}
if (store === 'redis' || store === 'valkey') {
return CacheManager.parseRedisOptions(
store,
storeConfigPath,
config,
logger,
);
}
if (store === 'infinispan') {
return InfinispanOptionsMapper.parseInfinispanOptions(
storeConfigPath,
config,
logger,
);
}
return undefined;
@@ -138,12 +164,17 @@ export class CacheManager {
* Parse Redis-specific options from configuration.
*/
private static parseRedisOptions(
store: string,
storeConfigPath: string,
config: RootConfigService,
logger?: LoggerService,
): RedisCacheStoreOptions {
const redisOptions: RedisCacheStoreOptions = {};
const redisConfig = config.getConfig(storeConfigPath);
const redisOptions: RedisCacheStoreOptions = {
type: store as 'redis' | 'valkey',
};
const redisConfig =
config.getOptionalConfig(storeConfigPath) ?? new ConfigReader({});
redisOptions.client = {
namespace: redisConfig.getOptionalString('client.namespace'),
@@ -231,23 +262,25 @@ export class CacheManager {
return (pluginId, defaultTtl) => {
if (!stores[pluginId]) {
const redisOptions = this.storeOptions?.client || {
keyPrefixSeparator: ':',
};
if (this.storeOptions?.cluster) {
// Create a Redis cluster
const cluster = createCluster(this.storeOptions?.cluster);
stores[pluginId] = new KeyvRedis(cluster, redisOptions);
} else {
// Create a regular Redis connection
stores[pluginId] = new KeyvRedis(this.connection, redisOptions);
}
if (this.storeOptions?.type === 'redis') {
const redisOptions = this.storeOptions?.client || {
keyPrefixSeparator: ':',
};
if (this.storeOptions?.cluster) {
// Create a Redis cluster
const cluster = createCluster(this.storeOptions?.cluster);
stores[pluginId] = new KeyvRedis(cluster, redisOptions);
} else {
// Create a regular Redis connection
stores[pluginId] = new KeyvRedis(this.connection, redisOptions);
}
// Always provide an error handler to avoid stopping the process
stores[pluginId].on('error', (err: Error) => {
this.logger?.error('Failed to create redis cache client', err);
this.errorHandler?.(err);
});
// Always provide an error handler to avoid stopping the process
stores[pluginId].on('error', (err: Error) => {
this.logger?.error('Failed to create redis cache client', err);
this.errorHandler?.(err);
});
}
}
return new Keyv({
namespace: pluginId,
@@ -266,23 +299,25 @@ export class CacheManager {
return (pluginId, defaultTtl) => {
if (!stores[pluginId]) {
const valkeyOptions = this.storeOptions?.client || {
keyPrefixSeparator: ':',
};
if (this.storeOptions?.cluster) {
// Create a Valkey cluster (Redis cluster under the hood)
const cluster = createCluster(this.storeOptions?.cluster);
stores[pluginId] = new KeyvValkey(cluster, valkeyOptions);
} else {
// Create a regular Valkey connection
stores[pluginId] = new KeyvValkey(this.connection, valkeyOptions);
}
if (this.storeOptions?.type === 'valkey') {
const valkeyOptions = this.storeOptions?.client || {
keyPrefixSeparator: ':',
};
if (this.storeOptions?.cluster) {
// Create a Valkey cluster (Redis cluster under the hood)
const cluster = createCluster(this.storeOptions?.cluster);
stores[pluginId] = new KeyvValkey(cluster, valkeyOptions);
} else {
// Create a regular Valkey connection
stores[pluginId] = new KeyvValkey(this.connection, valkeyOptions);
}
// Always provide an error handler to avoid stopping the process
stores[pluginId].on('error', (err: Error) => {
this.logger?.error('Failed to create valkey cache client', err);
this.errorHandler?.(err);
});
// Always provide an error handler to avoid stopping the process
stores[pluginId].on('error', (err: Error) => {
this.logger?.error('Failed to create valkey cache client', err);
this.errorHandler?.(err);
});
}
}
return new Keyv({
namespace: pluginId,
@@ -326,4 +361,96 @@ export class CacheManager {
store,
});
}
private createInfinispanStoreFactory(): StoreFactory {
const stores: Record<string, InfinispanKeyvStore> = {};
return (pluginId, defaultTtl) => {
if (!stores[pluginId]) {
if (this.storeOptions?.type === 'infinispan') {
// Use sync version for testing environments
const isTest =
process.env.NODE_ENV === 'test' || typeof jest !== 'undefined';
// Create the client promise ONCE and reuse it
const clientPromise: Promise<InfinispanClientCacheInterface> = isTest
? this.createInfinispanClientSync()
: this.createInfinispanClientAsync();
this.logger?.info(
`Creating Infinispan cache client for plugin ${pluginId} isTest = ${isTest}`,
);
const storeInstance = new InfinispanKeyvStore({
clientPromise,
logger: this.logger!,
});
stores[pluginId] = storeInstance;
// Always provide an error handler to avoid stopping the process
storeInstance.on('error', (err: Error) => {
this.logger?.error('Failed to create infinispan cache client', err);
this.errorHandler?.(err);
});
}
}
return new Keyv({
namespace: pluginId,
ttl: defaultTtl,
store: stores[pluginId],
emitErrors: false,
});
};
}
/**
* Creates an Infinispan client using dynamic import (production use).
* @returns Promise that resolves to an Infinispan client
*/
private async createInfinispanClientAsync(): Promise<InfinispanClientCacheInterface> {
return this.createInfinispanClient(false);
}
/**
* Creates an Infinispan client using synchronous import (testing purposes).
* @returns Promise that resolves to an Infinispan client
*/
private createInfinispanClientSync(): Promise<InfinispanClientCacheInterface> {
return this.createInfinispanClient(true);
}
/**
* Creates an Infinispan client based on the provided configuration.
* @param useSync - Whether to use synchronous import (for testing) or dynamic import
* @returns Promise that resolves to an Infinispan client
*/
private async createInfinispanClient(
useSync: boolean = false,
): Promise<InfinispanClientCacheInterface> {
try {
this.logger?.info('Creating Infinispan client');
if (this.storeOptions?.type === 'infinispan') {
// Import infinispan based on the useSync parameter
const infinispan = useSync
? require('infinispan')
: await import('infinispan');
const client = await infinispan.client(
this.storeOptions.servers as InfinispanServerConfig[],
this.storeOptions.options as InfinispanClientBehaviorOptions,
);
this.logger?.info('Infinispan client created successfully');
return client;
}
throw new Error('Infinispan store options are not defined');
} catch (error: any) {
this.logger?.error('Failed to create Infinispan client', {
error: error.message,
});
throw error;
}
}
}
@@ -0,0 +1,177 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { InfinispanKeyvStore } from './InfinispanKeyvStore';
import { mockServices } from '@backstage/backend-test-utils';
describe('InfinispanKeyvStore', () => {
const logger = mockServices.logger.mock();
let store: InfinispanKeyvStore;
let mockClient: any;
beforeEach(() => {
mockClient = {
get: jest.fn(),
put: jest.fn(),
remove: jest.fn(),
clear: jest.fn(),
disconnect: jest.fn(),
on: jest.fn(),
};
store = new InfinispanKeyvStore({
clientPromise: Promise.resolve(mockClient),
logger,
});
});
afterEach(() => {
jest.clearAllMocks();
});
describe('get', () => {
it('returns undefined when key is not found', async () => {
mockClient.get.mockResolvedValueOnce(null);
const result = await store.get('test-key');
expect(result).toBeUndefined();
expect(mockClient.get).toHaveBeenCalledWith('test-key');
});
it('returns value when key is found', async () => {
const value = 'test-value';
mockClient.get.mockResolvedValueOnce(value);
const result = await store.get('test-key');
expect(result).toBe(value);
expect(mockClient.get).toHaveBeenCalledWith('test-key');
});
it('handles client errors', async () => {
const error = new Error('Connection error');
mockClient.get.mockRejectedValueOnce(error);
await expect(store.get('test-key')).rejects.toThrow('Connection error');
});
});
describe('set', () => {
it('sets value with default options', async () => {
await store.set('test-key', 'test-value');
expect(mockClient.put).toHaveBeenCalledWith('test-key', 'test-value', {});
});
it('sets value with TTL', async () => {
await store.set('test-key', 'test-value', 1000);
expect(mockClient.put).toHaveBeenCalledWith('test-key', 'test-value', {
lifespan: '1000ms',
});
});
it('handles client errors', async () => {
const error = new Error('Connection error');
mockClient.put.mockRejectedValueOnce(error);
await expect(store.set('test-key', 'test-value')).rejects.toThrow(
'Connection error',
);
});
});
describe('delete', () => {
it('deletes key', async () => {
mockClient.remove.mockResolvedValueOnce(true);
const result = await store.delete('test-key');
expect(result).toBe(true);
expect(mockClient.remove).toHaveBeenCalledWith('test-key');
});
it('handles client errors', async () => {
const error = new Error('Connection error');
mockClient.remove.mockRejectedValueOnce(error);
await expect(store.delete('test-key')).rejects.toThrow(
'Connection error',
);
});
});
describe('clear', () => {
it('clears all entries', async () => {
await store.clear();
expect(mockClient.clear).toHaveBeenCalled();
});
it('handles client errors', async () => {
const error = new Error('Connection error');
mockClient.clear.mockRejectedValueOnce(error);
await expect(store.clear()).rejects.toThrow('Connection error');
});
});
describe('disconnect', () => {
it('is a no-op as client is managed by CacheManager', async () => {
const disconnectMockClient = {
disconnect: jest.fn().mockResolvedValue(undefined),
get: jest.fn().mockResolvedValue(null),
put: jest.fn().mockResolvedValue(undefined),
remove: jest.fn().mockResolvedValue(undefined),
clear: jest.fn().mockResolvedValue(undefined),
};
const disconnectStore = new InfinispanKeyvStore({
clientPromise: Promise.resolve(disconnectMockClient),
logger: mockServices.logger.mock(),
});
await disconnectStore.disconnect();
expect(disconnectMockClient.disconnect).not.toHaveBeenCalled();
});
it('handles client errors', async () => {
const error = new Error('Connection error');
const errorMockClient = {
disconnect: jest.fn().mockRejectedValue(error),
get: jest.fn().mockResolvedValue(null),
put: jest.fn().mockResolvedValue(undefined),
remove: jest.fn().mockResolvedValue(undefined),
clear: jest.fn().mockResolvedValue(undefined),
};
const errorStore = new InfinispanKeyvStore({
clientPromise: Promise.resolve(errorMockClient),
logger: mockServices.logger.mock(),
});
await expect(errorStore.disconnect()).resolves.not.toThrow();
});
});
describe('error handling', () => {
it('emits error events from client', async () => {
const error = new Error('Client error');
const errorHandler = jest.fn();
store.on('error', errorHandler);
// Simulate client error
const clientPromise = Promise.resolve(mockClient);
store = new InfinispanKeyvStore({
clientPromise,
logger,
});
// Wait for client to be resolved
await clientPromise;
// Trigger error event
const errorCallback = mockClient.on.mock.calls[0][1];
errorCallback(error);
expect(errorHandler).toHaveBeenCalledWith(error);
});
});
});
@@ -0,0 +1,206 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EventEmitter } from 'events';
import { LoggerService } from '@backstage/backend-plugin-api';
import { InfinispanPutOptions } from '../../types';
/**
* Interface defining the required methods for an Infinispan client.
* @public
*/
export interface InfinispanClientCacheInterface {
get(key: string): Promise<string | null | undefined>;
put(key: string, value: string, options?: InfinispanPutOptions): Promise<any>;
remove(key: string): Promise<boolean>;
clear(): Promise<void>;
disconnect(): Promise<void>;
on?(event: 'error' | string, listener: (...args: any[]) => void): this;
connect?(): Promise<any>;
query?(query: string): Promise<any[] | null>;
containsKey?(key: string): Promise<boolean>;
}
/**
* Options for creating an InfinispanKeyvStore instance.
*/
export interface InfinispanKeyvStoreOptions {
clientPromise: Promise<InfinispanClientCacheInterface>;
logger: LoggerService;
defaultTtl?: number; // TTL in milliseconds
}
/**
* A Keyv store implementation that uses Infinispan as the backend.
* This store implements the Keyv store interface and provides caching functionality
* using Infinispan's distributed cache capabilities.
*/
export class InfinispanKeyvStore extends EventEmitter {
private readonly clientPromise: Promise<InfinispanClientCacheInterface>;
private readonly logger: LoggerService;
private readonly defaultTtl?: number;
private resolvedClient: InfinispanClientCacheInterface | null = null;
public readonly namespace?: string; // Keyv expects this
constructor(options: InfinispanKeyvStoreOptions) {
super();
this.clientPromise = options.clientPromise;
this.logger = options.logger.child({ class: InfinispanKeyvStore.name });
this.defaultTtl = options.defaultTtl;
// Eagerly try to resolve the client to attach error listeners early
// and to have it ready for disconnect if resolved.
this.clientPromise
.then(client => {
this.resolvedClient = client;
if (typeof client.on === 'function') {
client.on('error', (error: Error) => {
this.logger.error('Native Infinispan client reported an error.', {
error: error.message,
});
this.emit('error', error);
});
} else {
this.logger.warn(
'Native Infinispan client does not appear to support .on("error") event listening.',
);
}
})
.catch(err => {
this.logger.error(
'Failed to resolve Infinispan client promise in constructor.',
{ error: err.message },
);
// Errors from operations will also be emitted when clientPromise is awaited and rejects.
this.emit('error', err);
});
}
private async getClient(): Promise<InfinispanClientCacheInterface> {
if (this.resolvedClient) {
return this.resolvedClient;
}
// If not yet resolved (e.g. called very quickly or promise rejected and retrying implicitly)
// Await the promise. This will throw if the promise is rejected.
this.resolvedClient = await this.clientPromise;
return this.resolvedClient;
}
async get(key: string): Promise<string | undefined> {
this.logger.debug(`Getting key: ${key}`);
try {
const client = await this.getClient();
const value = await client.get(key);
if (value === null || value === undefined) {
this.logger.debug(`Key not found or value is null/undefined: ${key}`);
return undefined;
}
this.logger.debug(`Successfully retrieved key: ${key}`);
return value;
} catch (error: any) {
this.logger.error(`Error getting key '${key}' from Infinispan.`, {
error: error.message,
});
this.emit(
'error',
error instanceof Error ? error : new Error(String(error)),
);
throw error;
}
}
async set(key: string, value: string, ttl?: number): Promise<void> {
this.logger.debug(`Setting key: ${key}`, { ttl });
this.logger.debug(`Setting key: ${key}`, { ttlInput: ttl });
const currentTtl = ttl ?? this.defaultTtl;
this.logger.debug(`Calculated currentTtl for key ${key}: ${currentTtl}ms`); // Log do TTL calculado
const storeOptions: InfinispanPutOptions = {};
if (typeof currentTtl === 'number' && currentTtl > 0) {
storeOptions.lifespan = `${currentTtl}ms`; // Ensure time unit is passed as string
// Ensure this matches client expectations. If client expects string '10s', convert here.
// For now, assuming number in ms is fine or string like '10000ms'.
// The PutOptions defines lifespan as string | number | null.
} else if (typeof currentTtl === 'string') {
storeOptions.lifespan = currentTtl;
}
try {
const client = await this.getClient();
await client.put(key, value, storeOptions);
this.logger.debug(`Successfully set key: ${key}`);
} catch (error: any) {
this.logger.error(`Error setting key '${key}' in Infinispan.`, {
error: error.message,
});
this.emit(
'error',
error instanceof Error ? error : new Error(String(error)),
);
throw error;
}
}
async delete(key: string): Promise<boolean> {
this.logger.debug(`Deleting key: ${key}`);
try {
const client = await this.getClient();
const deleted = await client.remove(key);
this.logger.debug(`Key deletion status for '${key}': ${deleted}`);
return deleted;
} catch (error: any) {
this.logger.error(`Error deleting key '${key}' from Infinispan.`, {
error: error.message,
});
this.emit(
'error',
error instanceof Error ? error : new Error(String(error)),
);
throw error;
}
}
async clear(): Promise<void> {
this.logger.info('Clearing all entries from Infinispan cache.');
try {
const client = await this.getClient();
await client.clear();
this.logger.info('Infinispan cache cleared successfully.');
} catch (error: any) {
this.logger.error('Error clearing Infinispan cache.', {
error: error.message,
});
this.emit(
'error',
error instanceof Error ? error : new Error(String(error)),
);
throw error;
}
}
// This disconnect is for the Keyv store instance, but the actual client is shared.
// The CacheManager should handle the shared client's disconnection.
// However, if Keyv calls this, we shouldn't error.
async disconnect(): Promise<void> {
this.logger.info(
'InfinispanKeyvStore disconnect called. Shared client managed by CacheManager.',
);
// No-op for this store instance as the actual client is managed externally by CacheManager.
// The CacheManager's stop() method will disconnect the shared native client.
return Promise.resolve();
}
}
@@ -0,0 +1,225 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
LoggerService,
RootConfigService,
} from '@backstage/backend-plugin-api';
import {
InfinispanAuthOptions,
InfinispanCacheStoreOptions,
InfinispanClientAuthOptions,
InfinispanClientBehaviorOptions,
InfinispanClusterConfig,
InfinispanServerConfig,
InfinispanSslOptions,
} from '../../types';
export class InfinispanOptionsMapper {
/**
* Parses Infinispan options from the provided configuration path.
*
* @param storeConfigPath - The configuration path for the Infinispan store.
* @param config - The root configuration service to retrieve the Infinispan configuration.
* @param logger - An optional logger service for logging errors and warnings.
* @returns Parsed Infinispan cache store options.
*/
public static parseInfinispanOptions(
storeConfigPath: string,
config: RootConfigService,
logger?: LoggerService,
): InfinispanCacheStoreOptions {
const infinispanConfig = config.getConfig(storeConfigPath);
const parsedOptions: Partial<InfinispanCacheStoreOptions> = {
type: 'infinispan',
};
// Parse Servers & Clusters Configurations
if (infinispanConfig.has('servers')) {
const serversConfig = infinispanConfig.get('servers');
if (Array.isArray(serversConfig)) {
parsedOptions.servers = infinispanConfig.getConfigArray('servers').map(
serverConf =>
({
host: serverConf.getString('host'),
port: serverConf.getNumber('port'),
} as InfinispanServerConfig),
);
} else if (typeof serversConfig === 'object' && serversConfig !== null) {
const serverConf = infinispanConfig.getConfig('servers');
parsedOptions.servers = {
host: serverConf.getString('host'),
port: serverConf.getNumber('port'),
} as InfinispanServerConfig;
} else {
logger?.error(
`Infinispan 'servers' configuration at ${storeConfigPath} must be an object or an array.`,
);
throw new Error(
`Infinispan 'servers' configuration at ${storeConfigPath} is invalid.`,
);
}
} else {
logger?.error(
`Infinispan configuration at ${storeConfigPath} is missing the 'servers' definition.`,
);
throw new Error(
`Infinispan configuration at ${storeConfigPath} must define 'servers'.`,
);
}
// The parsed options block to send to the Infinispan client
// This will be used to configure the Infinispan client behavior.
const behaviorOptions: Partial<InfinispanClientBehaviorOptions> = {};
if (infinispanConfig.has('clusters')) {
const clustersConfig = infinispanConfig.getConfigArray('clusters');
const clusterOptions: InfinispanClusterConfig[] = [];
clustersConfig?.forEach(clusterConf => {
const name = clusterConf.getOptionalString('name');
const cluster: InfinispanClusterConfig = {
...(name && { name }),
servers: clusterConf.getConfigArray('servers').map(serverConf => ({
host: serverConf.getString('host'),
port: serverConf.getNumber('port'),
})),
};
clusterOptions.push(cluster);
behaviorOptions.clusters = clusterOptions;
});
}
// Parse Default Options Start...
if (infinispanConfig.has('version')) {
const clientVersion = infinispanConfig.getString('version');
if (
clientVersion === '2.9' ||
clientVersion === '2.5' ||
clientVersion === '2.2'
) {
behaviorOptions.version = clientVersion;
} else if (clientVersion !== null && clientVersion !== undefined) {
logger?.warn(
`Invalid Infinispan client version "${clientVersion}" in config at ${storeConfigPath}.version. Must be "2.9", "2.5", or "2.2". It will be ignored, and the client may use a default or fail.`,
);
}
}
if (infinispanConfig.has('cacheName')) {
behaviorOptions.cacheName = infinispanConfig.getString('cacheName');
}
const mediaType = infinispanConfig.getOptionalString('mediaType');
if (mediaType === 'text/plain' || mediaType === 'application/json') {
behaviorOptions.mediaType = mediaType;
} else if (mediaType !== null && mediaType !== undefined) {
logger?.warn(
`Invalid Infinispan mediaType "${mediaType}" in config at ${storeConfigPath}.mediaType. Must be "text/plain" | "application/json". It will be ignored, and the client may use a default or fail.`,
);
}
if (infinispanConfig.has('maxRetries')) {
behaviorOptions.maxRetries = infinispanConfig.getNumber('maxRetries');
}
if (infinispanConfig.has('topologyUpdates')) {
behaviorOptions.topologyUpdates =
infinispanConfig.getBoolean('topologyUpdates');
}
// Default Options End...
// Parse Authentication and SSL Options
if (infinispanConfig.has('authentication')) {
const authConfig = infinispanConfig.getConfig('authentication');
const auth: Partial<InfinispanAuthOptions> = {};
if (authConfig.has('enabled')) {
auth.enabled = authConfig.getBoolean('enabled');
}
if (authConfig.has('saslMechanism')) {
auth.saslMechanism = authConfig.getString('saslMechanism');
}
if (authConfig.has('userName')) {
auth.userName = authConfig.getString('userName');
}
if (authConfig.has('password')) {
auth.password = authConfig.getString('password');
}
if (authConfig.has('token')) {
auth.token = authConfig.getString('token');
}
if (authConfig.has('authzid')) {
auth.authzid = authConfig.getString('authzid');
}
behaviorOptions.authentication = auth as InfinispanAuthOptions;
}
if (infinispanConfig.has('ssl')) {
const sslConfig = infinispanConfig.getConfig('ssl');
const ssl: Partial<InfinispanSslOptions> = {};
if (sslConfig.has('enabled')) {
ssl.enabled = sslConfig.getBoolean('enabled');
}
if (sslConfig.has('secureProtocol')) {
ssl.secureProtocol = sslConfig.getString('secureProtocol');
}
if (sslConfig.has('trustCerts')) {
ssl.trustCerts = sslConfig.getStringArray('trustCerts');
}
if (sslConfig.has('sniHostname')) {
ssl.sniHostName = sslConfig.getString('sniHostname');
}
if (sslConfig.has('clientAuth')) {
const clientAuth = infinispanConfig.getConfig('clientAuth');
const clientAuthOpts: Partial<InfinispanClientAuthOptions> = {};
if (clientAuth.has('key')) {
clientAuthOpts.key = clientAuth.getString('key');
}
if (clientAuth.has('passphrase')) {
clientAuthOpts.passphrase = clientAuth.getString('passphrase');
}
if (clientAuth.has('cert')) {
clientAuthOpts.cert = clientAuth.getString('cert');
}
ssl.clientAuth = clientAuthOpts as InfinispanClientAuthOptions;
}
if (sslConfig.has('cryptoStore')) {
const cryptoStore = infinispanConfig.getConfig('cryptoStore');
const cryptoStoreOpts: Partial<InfinispanClientAuthOptions> = {};
if (cryptoStore.has('path')) {
cryptoStoreOpts.key = cryptoStore.getString('path');
}
if (cryptoStore.has('passphrase')) {
cryptoStoreOpts.passphrase = cryptoStore.getString('passphrase');
}
ssl.cryptoStore = cryptoStoreOpts as InfinispanClientAuthOptions;
}
behaviorOptions.ssl = ssl as InfinispanSslOptions;
}
parsedOptions.options = behaviorOptions as InfinispanClientBehaviorOptions;
return parsedOptions as InfinispanCacheStoreOptions;
}
}
+96 -1
View File
@@ -24,6 +24,7 @@ import { RedisClusterOptions, KeyvRedisOptions } from '@keyv/redis';
* @public
*/
export type RedisCacheStoreOptions = {
type: 'redis' | 'valkey';
client?: KeyvRedisOptions;
cluster?: RedisClusterOptions;
};
@@ -33,7 +34,9 @@ export type RedisCacheStoreOptions = {
*
* @public
*/
export type CacheStoreOptions = RedisCacheStoreOptions;
export type CacheStoreOptions =
| RedisCacheStoreOptions
| InfinispanCacheStoreOptions;
/**
* Options given when constructing a {@link CacheManager}.
@@ -56,3 +59,95 @@ export type CacheManagerOptions = {
export function ttlToMilliseconds(ttl: number | HumanDuration): number {
return typeof ttl === 'number' ? ttl : durationToMilliseconds(ttl);
}
/**
* Configuration for a single Infinispan server.
*/
export type InfinispanServerConfig = {
host: string;
port: number;
};
/**
* Options for putting values into Infinispan cache.
*/
export type InfinispanPutOptions = {
lifespan?: string;
maxIdle?: string;
previous?: boolean;
flags?: string[];
};
/**
* SSL/TLS options for the Infinispan client.
*/
export type InfinispanSslOptions = {
enabled: boolean;
secureProtocol?: string;
trustCerts?: string[]; // Array of trusted CA certificates
clientAuth?: InfinispanClientAuthOptions;
cryptoStore?: InfinispanCryptoStoreOptions;
sniHostName?: string;
};
/**
* Authentication options for the Infinispan client.
* This is used for client-side authentication with the Infinispan server.
*/
export type InfinispanClientAuthOptions = {
key?: string;
passphrase?: string;
cert?: string;
};
/**
* Options for the Infinispan client crypto store.
* This is used for storing keys and certificates securely.
*/
export type InfinispanCryptoStoreOptions = {
path?: string;
passphrase?: string;
};
/**
* Authentication options for the Infinispan client.
*/
export type InfinispanAuthOptions = {
enabled: boolean;
saslMechanism?: string;
userName?: string;
password?: string;
token?: string;
authzid?: string;
};
/**
* Options for the Infinispan cache store, designed to be configured
* in app-config.yaml under `backend.cache.infinispan`.
*/
export type InfinispanCacheStoreOptions = {
type: 'infinispan';
servers: InfinispanServerConfig | InfinispanServerConfig[];
options?: InfinispanClientBehaviorOptions;
};
export type InfinispanClusterConfig = {
name?: string;
servers: InfinispanServerConfig[];
};
/**
* Detailed client behavior options for the Infinispan client.
* @public
*/
export type InfinispanClientBehaviorOptions = {
version?: '2.9' | '2.5' | '2.2';
cacheName?: string;
maxRetries?: number;
connectionTimeout?: number;
socketTimeout?: number;
authentication?: InfinispanAuthOptions;
ssl?: InfinispanSslOptions;
mediaType?: 'text/plain' | 'application/json';
topologyUpdates?: boolean;
clusters?: InfinispanClusterConfig[];
};
+1
View File
@@ -69,6 +69,7 @@
"cookie": "^0.7.0",
"express": "^4.17.1",
"fs-extra": "^11.0.0",
"infinispan": "^0.12.0",
"keyv": "^5.2.1",
"knex": "^3.0.0",
"mysql2": "^3.0.0",
+143 -17
View File
@@ -2588,6 +2588,7 @@ __metadata:
git-url-parse: "npm:^15.0.0"
helmet: "npm:^6.0.0"
http-errors: "npm:^2.0.0"
infinispan: "npm:^0.12.0"
is-glob: "npm:^4.0.3"
jose: "npm:^5.0.0"
keyv: "npm:^5.2.1"
@@ -2755,6 +2756,7 @@ __metadata:
cookie: "npm:^0.7.0"
express: "npm:^4.17.1"
fs-extra: "npm:^11.0.0"
infinispan: "npm:^0.12.0"
keyv: "npm:^5.2.1"
knex: "npm:^3.0.0"
mysql2: "npm:^3.0.0"
@@ -25290,6 +25292,15 @@ __metadata:
languageName: node
linkType: hard
"buffer-xor@npm:^2.0.2":
version: 2.0.2
resolution: "buffer-xor@npm:2.0.2"
dependencies:
safe-buffer: "npm:^5.1.1"
checksum: 10/78226fcae9f4a0b4adec69dffc049f26f6bab240dfdd1b3f6fe07c4eb6b90da202ea5c363f98af676156ee39450a06405fddd9e8965f68a5327edcc89dcbe5d0
languageName: node
linkType: hard
"buffer@npm:5.6.0":
version: 5.6.0
resolution: "buffer@npm:5.6.0"
@@ -27845,6 +27856,15 @@ __metadata:
languageName: node
linkType: hard
"default-user-agent@npm:^1.0.0":
version: 1.0.0
resolution: "default-user-agent@npm:1.0.0"
dependencies:
os-name: "npm:~1.0.3"
checksum: 10/b1ef07c8e7de846a66e1e120d7ba11969faa36c8db4af2317f9b64d30e7507d129e3f721c7cc3f531a1719c1ab463d830bf426fbcda87b11defe23689f4d2b60
languageName: node
linkType: hard
"defaults@npm:^1.0.3":
version: 1.0.3
resolution: "defaults@npm:1.0.3"
@@ -28250,6 +28270,13 @@ __metadata:
languageName: node
linkType: hard
"digest-header@npm:^1.0.0":
version: 1.1.0
resolution: "digest-header@npm:1.1.0"
checksum: 10/fadbdda75e1cc650e460c8fe2064f74c43cc005d0eab66cc390dd1ae2678cfb41f69f151323fbd3e059e28c941f1b9adc6ea4dbd9c918cb246f34a5eb8e103f0
languageName: node
linkType: hard
"dir-glob@npm:^3.0.1":
version: 3.0.1
resolution: "dir-glob@npm:3.0.1"
@@ -31150,6 +31177,13 @@ __metadata:
languageName: node
linkType: hard
"form-data-encoder@npm:^1.7.2":
version: 1.9.0
resolution: "form-data-encoder@npm:1.9.0"
checksum: 10/d6a684f22660e4857ef846ad8154c00c0f0e174f3edca24567ab93d9d5b5d765b2951518672db1fccc5e1f91d66bb0d9a54f99dbd9b915d204bc6887c6a0084c
languageName: node
linkType: hard
"form-data@npm:^2.3.2, form-data@npm:^2.5.0":
version: 2.5.1
resolution: "form-data@npm:2.5.1"
@@ -31181,7 +31215,7 @@ __metadata:
languageName: node
linkType: hard
"formdata-node@npm:^4.3.2":
"formdata-node@npm:^4.3.2, formdata-node@npm:^4.3.3":
version: 4.4.1
resolution: "formdata-node@npm:4.4.1"
dependencies:
@@ -31211,6 +31245,18 @@ __metadata:
languageName: node
linkType: hard
"formstream@npm:^1.1.1":
version: 1.5.2
resolution: "formstream@npm:1.5.2"
dependencies:
destroy: "npm:^1.0.4"
mime: "npm:^2.5.2"
node-hex: "npm:^1.0.1"
pause-stream: "npm:~0.0.11"
checksum: 10/d2892fa4756260733db1f7626d3845d9c2294625d3a709bd2eb3a1899e0f17ae38bd2ad3dfd1e1d11d25f1a2789869523b6a9ec8adc9d8f5b3c0d33e58f3d4c9
languageName: node
linkType: hard
"forwarded-parse@npm:2.1.2":
version: 2.1.2
resolution: "forwarded-parse@npm:2.1.2"
@@ -33227,6 +33273,19 @@ __metadata:
languageName: node
linkType: hard
"infinispan@npm:^0.12.0":
version: 0.12.0
resolution: "infinispan@npm:0.12.0"
dependencies:
buffer-xor: "npm:^2.0.2"
log4js: "npm:^6.4.6"
protobufjs: "npm:^7.0.0"
underscore: "npm:^1.13.3"
urllib: "npm:^3.23.0"
checksum: 10/e805772da3304b088293457bf766749f3a6574428bd8724f234a1ceb95bdbbebb36fef6154740da3b21c62a12d8b73b1ff666ce3dc8a18648f77d9416c63e0ae
languageName: node
linkType: hard
"inflight@npm:^1.0.4":
version: 1.0.6
resolution: "inflight@npm:1.0.6"
@@ -36802,7 +36861,7 @@ __metadata:
languageName: node
linkType: hard
"log4js@npm:6.9.1":
"log4js@npm:6.9.1, log4js@npm:^6.4.6":
version: 6.9.1
resolution: "log4js@npm:6.9.1"
dependencies:
@@ -38037,7 +38096,7 @@ __metadata:
languageName: node
linkType: hard
"mime-types@npm:2.1.35, mime-types@npm:^2.1.12, mime-types@npm:^2.1.18, mime-types@npm:^2.1.27, mime-types@npm:^2.1.31, mime-types@npm:~2.1.17, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34":
"mime-types@npm:2.1.35, mime-types@npm:^2.1.12, mime-types@npm:^2.1.18, mime-types@npm:^2.1.27, mime-types@npm:^2.1.31, mime-types@npm:^2.1.35, mime-types@npm:~2.1.17, mime-types@npm:~2.1.19, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34":
version: 2.1.35
resolution: "mime-types@npm:2.1.35"
dependencies:
@@ -38236,7 +38295,7 @@ __metadata:
languageName: node
linkType: hard
"minimist@npm:^1.2.0, minimist@npm:^1.2.3, minimist@npm:^1.2.5, minimist@npm:^1.2.6, minimist@npm:^1.2.8":
"minimist@npm:^1.1.0, minimist@npm:^1.2.0, minimist@npm:^1.2.3, minimist@npm:^1.2.5, minimist@npm:^1.2.6, minimist@npm:^1.2.8":
version: 1.2.8
resolution: "minimist@npm:1.2.8"
checksum: 10/908491b6cc15a6c440ba5b22780a0ba89b9810e1aea684e253e43c4e3b8d56ec1dcdd7ea96dde119c29df59c936cde16062159eae4225c691e19c70b432b6e6f
@@ -39183,6 +39242,13 @@ __metadata:
languageName: node
linkType: hard
"node-hex@npm:^1.0.1":
version: 1.0.1
resolution: "node-hex@npm:1.0.1"
checksum: 10/9053d532859ee7e9653972af77ac7b73edc4f13b9b53d0b96e4045e3ac78ac4460571d4b72ad31e9095be5f7d01e6fd71f268f02ad6029091f8cabae1d4ce4df
languageName: node
linkType: hard
"node-html-markdown@npm:^1.3.0":
version: 1.3.0
resolution: "node-html-markdown@npm:1.3.0"
@@ -40111,6 +40177,18 @@ __metadata:
languageName: node
linkType: hard
"os-name@npm:~1.0.3":
version: 1.0.3
resolution: "os-name@npm:1.0.3"
dependencies:
osx-release: "npm:^1.0.0"
win-release: "npm:^1.0.0"
bin:
os-name: cli.js
checksum: 10/2fc86cc199f8b4992bb00041401c5ab0407e3069e05981f3aa3e5a44cee9b7f22c2b0f5db2c0c1d55656c519884272b5e1e55517358c2e5f728b37dd38f5af78
languageName: node
linkType: hard
"os-tmpdir@npm:~1.0.2":
version: 1.0.2
resolution: "os-tmpdir@npm:1.0.2"
@@ -40118,6 +40196,17 @@ __metadata:
languageName: node
linkType: hard
"osx-release@npm:^1.0.0":
version: 1.1.0
resolution: "osx-release@npm:1.1.0"
dependencies:
minimist: "npm:^1.1.0"
bin:
osx-release: cli.js
checksum: 10/48f442f836e514d08ce73ef786db8d7cf0958e8c64a04548767ddf1081454e323fa3b7b83dcf084ecf70fe304f484e6dab0fe33e80459ac0cf7d15c1bbbe9243
languageName: node
linkType: hard
"outdent@npm:^0.5.0":
version: 0.5.0
resolution: "outdent@npm:0.5.0"
@@ -40848,6 +40937,15 @@ __metadata:
languageName: node
linkType: hard
"pause-stream@npm:~0.0.11":
version: 0.0.11
resolution: "pause-stream@npm:0.0.11"
dependencies:
through: "npm:~2.3"
checksum: 10/1407efadfe814b5c487e4b28d6139cb7e03ee5d25fbb5f89a68f2053e81f05ce6b2bec196eeb3d46ef2c856f785016d14816b0d0e3c3abd1b64311c5c20660dc
languageName: node
linkType: hard
"pause@npm:0.0.1":
version: 0.0.1
resolution: "pause@npm:0.0.1"
@@ -42136,9 +42234,9 @@ __metadata:
languageName: node
linkType: hard
"protobufjs@npm:^7.2.5, protobufjs@npm:^7.2.6, protobufjs@npm:^7.3.0, protobufjs@npm:^7.3.2, protobufjs@npm:^7.4.0":
version: 7.4.0
resolution: "protobufjs@npm:7.4.0"
"protobufjs@npm:^7.0.0, protobufjs@npm:^7.2.5, protobufjs@npm:^7.2.6, protobufjs@npm:^7.3.0, protobufjs@npm:^7.3.2, protobufjs@npm:^7.4.0":
version: 7.5.3
resolution: "protobufjs@npm:7.5.3"
dependencies:
"@protobufjs/aspromise": "npm:^1.1.2"
"@protobufjs/base64": "npm:^1.1.2"
@@ -42152,7 +42250,7 @@ __metadata:
"@protobufjs/utf8": "npm:^1.1.0"
"@types/node": "npm:>=13.7.0"
long: "npm:^5.0.0"
checksum: 10/408423506610f70858d7593632f4a6aa4f05796c90fd632be9b9252457c795acc71aa6d3b54bb7f48a890141728fee4ca3906723ccea6c202ad71f21b3879b8b
checksum: 10/3e412d2e2f799875dcdac1b43508f465a499dd3ffd9d24073669702589ef016529905617a12a967b1a8cfbe803a638b494cc3ed8db035605c7570d1a7fc094c9
languageName: node
linkType: hard
@@ -44819,7 +44917,7 @@ __metadata:
languageName: node
linkType: hard
"semver@npm:^5.5.0, semver@npm:^5.6.0":
"semver@npm:^5.0.1, semver@npm:^5.5.0, semver@npm:^5.6.0":
version: 5.7.2
resolution: "semver@npm:5.7.2"
bin:
@@ -47047,7 +47145,7 @@ __metadata:
languageName: node
linkType: hard
"through@npm:2, through@npm:^2.3.6":
"through@npm:2, through@npm:^2.3.6, through@npm:~2.3":
version: 2.3.8
resolution: "through@npm:2.3.8"
checksum: 10/5da78346f70139a7d213b65a0106f3c398d6bc5301f9248b5275f420abc2c4b1e77c2abc72d218dedc28c41efb2e7c312cb76a7730d04f9c2d37d247da3f4198
@@ -47734,10 +47832,10 @@ __metadata:
languageName: node
linkType: hard
"type-fest@npm:^4.26.1":
version: 4.26.1
resolution: "type-fest@npm:4.26.1"
checksum: 10/b82676194f80af228cb852e320d2ea8381c89d667d2e4d9f2bdfc8f254bccc039c7741a90c53617a4de0c9fdca8265ed18eb0888cd628f391c5c381c33a9f94b
"type-fest@npm:^4.26.1, type-fest@npm:^4.3.1":
version: 4.41.0
resolution: "type-fest@npm:4.41.0"
checksum: 10/617ace794ac0893c2986912d28b3065ad1afb484cad59297835a0807dc63286c39e8675d65f7de08fafa339afcb8fe06a36e9a188b9857756ae1e92ee8bda212
languageName: node
linkType: hard
@@ -48084,7 +48182,7 @@ __metadata:
languageName: node
linkType: hard
"underscore@npm:^1.12.1":
"underscore@npm:^1.12.1, underscore@npm:^1.13.3":
version: 1.13.7
resolution: "underscore@npm:1.13.7"
checksum: 10/1ce3368dbe73d1e99678fa5d341a9682bd27316032ad2de7883901918f0f5d50e80320ccc543f53c1862ab057a818abc560462b5f83578afe2dd8dd7f779766c
@@ -48112,7 +48210,7 @@ __metadata:
languageName: node
linkType: hard
"undici@npm:^5.28.4":
"undici@npm:^5.28.2, undici@npm:^5.28.4":
version: 5.29.0
resolution: "undici@npm:5.29.0"
dependencies:
@@ -48492,6 +48590,25 @@ __metadata:
languageName: node
linkType: hard
"urllib@npm:^3.23.0":
version: 3.27.3
resolution: "urllib@npm:3.27.3"
dependencies:
default-user-agent: "npm:^1.0.0"
digest-header: "npm:^1.0.0"
form-data-encoder: "npm:^1.7.2"
formdata-node: "npm:^4.3.3"
formstream: "npm:^1.1.1"
mime-types: "npm:^2.1.35"
pump: "npm:^3.0.0"
qs: "npm:^6.11.2"
type-fest: "npm:^4.3.1"
undici: "npm:^5.28.2"
ylru: "npm:^1.3.2"
checksum: 10/4d0a5a7afa8ae9697a573f643851f9508cf5c5a1e7d800830870740d0561bddf3599861f228b119e95d870b1dae5fe2e1183c0840ebec001fc93e9fd0b8e1701
languageName: node
linkType: hard
"urlpattern-polyfill@npm:^8.0.0":
version: 8.0.2
resolution: "urlpattern-polyfill@npm:8.0.2"
@@ -49396,6 +49513,15 @@ __metadata:
languageName: node
linkType: hard
"win-release@npm:^1.0.0":
version: 1.1.1
resolution: "win-release@npm:1.1.1"
dependencies:
semver: "npm:^5.0.1"
checksum: 10/8943898cc4badaf8598342d63093e49ae9a64c140cf190e81472d3a8890f3387b8408181412e1b58658fe7777ce5d1e3f02eee4beeaee49909d1d17a72d52fc1
languageName: node
linkType: hard
"winston-transport@npm:^4.5.0, winston-transport@npm:^4.7.0":
version: 4.8.0
resolution: "winston-transport@npm:4.8.0"
@@ -49815,7 +49941,7 @@ __metadata:
languageName: node
linkType: hard
"ylru@npm:^1.2.0":
"ylru@npm:^1.2.0, ylru@npm:^1.3.2":
version: 1.4.0
resolution: "ylru@npm:1.4.0"
checksum: 10/5437f8eb2fb5dd515845c657dde3cecaa9f6bd4c6386d2a5212d3fafe02189c7d8ebfdfc84940a7811607cb3524eb362ce95d3180d355cd5deb610aa8c82c9bc