fix: refactor API client to support Unix socket connections (#1575)

This commit is contained in:
Eli Bosley
2025-08-13 16:15:15 -04:00
committed by GitHub
parent b3216874fa
commit a2c5d2495f
31 changed files with 2178 additions and 213 deletions

View File

@@ -1,7 +1,7 @@
{
"version": "4.12.0",
"extraOrigins": [],
"sandbox": true,
"ssoSubIds": [],
"plugins": ["unraid-api-plugin-connect"]
}
"version": "4.12.0",
"extraOrigins": [],
"sandbox": true,
"ssoSubIds": [],
"plugins": ["unraid-api-plugin-connect"]
}

View File

@@ -138,6 +138,7 @@
"semver": "7.7.2",
"strftime": "0.10.3",
"systeminformation": "5.27.7",
"undici": "^7.13.0",
"uuid": "11.1.0",
"ws": "8.18.3",
"zen-observable-ts": "1.1.0",

View File

@@ -0,0 +1,74 @@
import { ConfigModule } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { INTERNAL_CLIENT_SERVICE_TOKEN } from '@unraid/shared';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { AdminKeyService } from '@app/unraid-api/cli/admin-key.service.js';
import { CliServicesModule } from '@app/unraid-api/cli/cli-services.module.js';
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
import { InternalGraphQLClientFactory } from '@app/unraid-api/shared/internal-graphql-client.factory.js';
describe('CliServicesModule', () => {
let module: TestingModule;
beforeEach(async () => {
module = await Test.createTestingModule({
imports: [CliServicesModule],
}).compile();
});
afterEach(async () => {
await module?.close();
});
it('should compile the module', () => {
expect(module).toBeDefined();
});
it('should provide CliInternalClientService', () => {
const service = module.get(CliInternalClientService);
expect(service).toBeDefined();
expect(service).toBeInstanceOf(CliInternalClientService);
});
it('should provide AdminKeyService', () => {
const service = module.get(AdminKeyService);
expect(service).toBeDefined();
expect(service).toBeInstanceOf(AdminKeyService);
});
it('should provide InternalGraphQLClientFactory via token', () => {
const factory = module.get(INTERNAL_CLIENT_SERVICE_TOKEN);
expect(factory).toBeDefined();
expect(factory).toBeInstanceOf(InternalGraphQLClientFactory);
});
describe('CliInternalClientService dependencies', () => {
it('should have all required dependencies available', () => {
// This test ensures that CliInternalClientService can be instantiated
// with all its dependencies properly resolved
const service = module.get(CliInternalClientService);
expect(service).toBeDefined();
// Verify the service has its dependencies injected
// The service should be able to create a client without errors
expect(service.getClient).toBeDefined();
expect(service.clearClient).toBeDefined();
});
it('should resolve InternalGraphQLClientFactory dependency via token', () => {
// Explicitly test that the factory is available in the module context via token
const factory = module.get(INTERNAL_CLIENT_SERVICE_TOKEN);
expect(factory).toBeDefined();
expect(factory.createClient).toBeDefined();
});
it('should resolve AdminKeyService dependency', () => {
// Explicitly test that AdminKeyService is available in the module context
const adminKeyService = module.get(AdminKeyService);
expect(adminKeyService).toBeDefined();
expect(adminKeyService.getOrCreateLocalAdminKey).toBeDefined();
});
});
});

View File

@@ -0,0 +1,203 @@
import { ConfigModule, ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import type { InternalGraphQLClientFactory } from '@unraid/shared';
import { ApolloClient } from '@apollo/client/core/index.js';
import { INTERNAL_CLIENT_SERVICE_TOKEN } from '@unraid/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AdminKeyService } from '@app/unraid-api/cli/admin-key.service.js';
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
describe('CliInternalClientService', () => {
let service: CliInternalClientService;
let clientFactory: InternalGraphQLClientFactory;
let adminKeyService: AdminKeyService;
let module: TestingModule;
const mockApolloClient = {
query: vi.fn(),
mutate: vi.fn(),
stop: vi.fn(),
};
beforeEach(async () => {
module = await Test.createTestingModule({
imports: [ConfigModule.forRoot()],
providers: [
CliInternalClientService,
{
provide: INTERNAL_CLIENT_SERVICE_TOKEN,
useValue: {
createClient: vi.fn().mockResolvedValue(mockApolloClient),
},
},
{
provide: AdminKeyService,
useValue: {
getOrCreateLocalAdminKey: vi.fn().mockResolvedValue('test-admin-key'),
},
},
],
}).compile();
service = module.get<CliInternalClientService>(CliInternalClientService);
clientFactory = module.get<InternalGraphQLClientFactory>(INTERNAL_CLIENT_SERVICE_TOKEN);
adminKeyService = module.get<AdminKeyService>(AdminKeyService);
});
afterEach(async () => {
await module?.close();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('dependency injection', () => {
it('should have InternalGraphQLClientFactory injected', () => {
expect(clientFactory).toBeDefined();
expect(clientFactory.createClient).toBeDefined();
});
it('should have AdminKeyService injected', () => {
expect(adminKeyService).toBeDefined();
expect(adminKeyService.getOrCreateLocalAdminKey).toBeDefined();
});
});
describe('getClient', () => {
it('should create a client with getApiKey function', async () => {
const client = await service.getClient();
// The API key is now fetched lazily, not immediately
expect(clientFactory.createClient).toHaveBeenCalledWith({
getApiKey: expect.any(Function),
enableSubscriptions: false,
});
// Verify the getApiKey function works correctly when called
const callArgs = vi.mocked(clientFactory.createClient).mock.calls[0][0];
const apiKey = await callArgs.getApiKey();
expect(apiKey).toBe('test-admin-key');
expect(adminKeyService.getOrCreateLocalAdminKey).toHaveBeenCalled();
expect(client).toBe(mockApolloClient);
});
it('should return cached client on subsequent calls', async () => {
const client1 = await service.getClient();
const client2 = await service.getClient();
expect(client1).toBe(client2);
expect(clientFactory.createClient).toHaveBeenCalledTimes(1);
});
it('should handle errors when getting admin key', async () => {
const error = new Error('Failed to get admin key');
vi.mocked(adminKeyService.getOrCreateLocalAdminKey).mockRejectedValueOnce(error);
// The client creation will succeed, but the API key error happens later
const client = await service.getClient();
expect(client).toBe(mockApolloClient);
// Now test that the getApiKey function throws the expected error
const callArgs = vi.mocked(clientFactory.createClient).mock.calls[0][0];
await expect(callArgs.getApiKey()).rejects.toThrow();
});
});
describe('clearClient', () => {
it('should stop and clear the client', async () => {
// First create a client
await service.getClient();
// Clear the client
service.clearClient();
expect(mockApolloClient.stop).toHaveBeenCalled();
});
it('should handle clearing when no client exists', () => {
// Should not throw when clearing a non-existent client
expect(() => service.clearClient()).not.toThrow();
});
it('should create a new client after clearing', async () => {
// Create initial client
await service.getClient();
// Clear it
service.clearClient();
// Create new client
await service.getClient();
// Should have created client twice
expect(clientFactory.createClient).toHaveBeenCalledTimes(2);
});
});
describe('race condition protection', () => {
it('should prevent stale client resurrection when clearClient() is called during creation', async () => {
let resolveClientCreation!: (client: any) => void;
// Mock createClient to return a controllable promise
const clientCreationPromise = new Promise<any>((resolve) => {
resolveClientCreation = resolve;
});
vi.mocked(clientFactory.createClient).mockReturnValueOnce(clientCreationPromise);
// Start client creation (but don't await yet)
const getClientPromise = service.getClient();
// Clear the client while creation is in progress
service.clearClient();
// Now complete the client creation
resolveClientCreation(mockApolloClient);
// Wait for getClient to complete
const client = await getClientPromise;
// The client should be returned from getClient
expect(client).toBe(mockApolloClient);
// But subsequent getClient calls should create a new client
// because the race condition protection prevented assignment
await service.getClient();
// Should have created a second client, proving the first wasn't assigned
expect(clientFactory.createClient).toHaveBeenCalledTimes(2);
});
it('should handle concurrent getClient calls during race condition', async () => {
let resolveClientCreation!: (client: any) => void;
// Mock createClient to return a controllable promise
const clientCreationPromise = new Promise<any>((resolve) => {
resolveClientCreation = resolve;
});
vi.mocked(clientFactory.createClient).mockReturnValueOnce(clientCreationPromise);
// Start multiple concurrent client creation calls
const getClientPromise1 = service.getClient();
const getClientPromise2 = service.getClient(); // Should wait for first one
// Clear the client while creation is in progress
service.clearClient();
// Complete the client creation
resolveClientCreation(mockApolloClient);
// Both calls should resolve with the same client
const [client1, client2] = await Promise.all([getClientPromise1, getClientPromise2]);
expect(client1).toBe(mockApolloClient);
expect(client2).toBe(mockApolloClient);
// But the client should not be cached due to race condition protection
await service.getClient();
expect(clientFactory.createClient).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -1,9 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ApolloClient, InMemoryCache, NormalizedCacheObject } from '@apollo/client/core/index.js';
import { onError } from '@apollo/client/link/error/index.js';
import { HttpLink } from '@apollo/client/link/http/index.js';
import type { InternalGraphQLClientFactory } from '@unraid/shared';
import { ApolloClient, NormalizedCacheObject } from '@apollo/client/core/index.js';
import { INTERNAL_CLIENT_SERVICE_TOKEN } from '@unraid/shared';
import { AdminKeyService } from '@app/unraid-api/cli/admin-key.service.js';
@@ -11,51 +10,20 @@ import { AdminKeyService } from '@app/unraid-api/cli/admin-key.service.js';
* Internal GraphQL client for CLI commands.
*
* This service creates an Apollo client that queries the local API server
* through IPC, providing access to the same data that external clients would get
* but without needing to parse config files directly.
* with admin privileges for CLI operations.
*/
@Injectable()
export class CliInternalClientService {
private readonly logger = new Logger(CliInternalClientService.name);
private client: ApolloClient<NormalizedCacheObject> | null = null;
private creatingClient: Promise<ApolloClient<NormalizedCacheObject>> | null = null;
constructor(
private readonly configService: ConfigService,
@Inject(INTERNAL_CLIENT_SERVICE_TOKEN)
private readonly clientFactory: InternalGraphQLClientFactory,
private readonly adminKeyService: AdminKeyService
) {}
private PROD_NGINX_PORT = 80;
private getNginxPort() {
return Number(this.configService.get('store.emhttp.nginx.httpPort', this.PROD_NGINX_PORT));
}
/**
* Get the port override from the environment variable PORT. e.g. during development.
* If the port is a socket port, return undefined.
*/
private getNonSocketPortOverride() {
const port = this.configService.get<string | number | undefined>('PORT');
if (!port || port.toString().includes('.sock')) {
return undefined;
}
return Number(port);
}
/**
* Get the API address for HTTP requests.
*/
private getApiAddress(port = this.getNginxPort()) {
const portOverride = this.getNonSocketPortOverride();
if (portOverride) {
return `http://127.0.0.1:${portOverride}/graphql`;
}
if (port !== this.PROD_NGINX_PORT) {
return `http://127.0.0.1:${port}/graphql`;
}
return `http://127.0.0.1/graphql`;
}
/**
* Get the admin API key using the AdminKeyService.
* This ensures the key exists and is available for CLI operations.
@@ -71,49 +39,59 @@ export class CliInternalClientService {
}
}
private async createApiClient(): Promise<ApolloClient<NormalizedCacheObject>> {
const httpUri = this.getApiAddress();
const apiKey = await this.getLocalApiKey();
this.logger.debug('Internal GraphQL URL: %s', httpUri);
const httpLink = new HttpLink({
uri: httpUri,
fetch,
headers: {
Origin: '/var/run/unraid-cli.sock',
'x-api-key': apiKey,
'Content-Type': 'application/json',
},
});
const errorLink = onError(({ networkError }) => {
if (networkError) {
this.logger.warn('[GRAPHQL-CLIENT] NETWORK ERROR ENCOUNTERED %o', networkError);
}
});
return new ApolloClient({
defaultOptions: {
query: {
fetchPolicy: 'no-cache',
},
},
cache: new InMemoryCache(),
link: errorLink.concat(httpLink),
});
}
/**
* Get the default CLI client with admin API key.
* This is for CLI commands that need admin access.
*/
public async getClient(): Promise<ApolloClient<NormalizedCacheObject>> {
// If client already exists, return it
if (this.client) {
return this.client;
}
this.client = await this.createApiClient();
return this.client;
// If another call is already creating the client, wait for it
if (this.creatingClient) {
return await this.creatingClient;
}
// Start creating the client with race condition protection
let creationPromise!: Promise<ApolloClient<NormalizedCacheObject>>;
// eslint-disable-next-line prefer-const
creationPromise = (async () => {
try {
const client = await this.clientFactory.createClient({
getApiKey: () => this.getLocalApiKey(),
enableSubscriptions: false, // CLI doesn't need subscriptions
});
// awaiting *before* checking this.creatingClient is important!
// by yielding to the event loop, it ensures
// `this.creatingClient = creationPromise;` is executed before the next check.
// This prevents race conditions where the client is assigned to the wrong instance.
// Only assign client if this creation is still current
if (this.creatingClient === creationPromise) {
this.client = client;
this.logger.debug('Created CLI internal GraphQL client with admin privileges');
}
return client;
} finally {
// Only clear if this creation is still current
if (this.creatingClient === creationPromise) {
this.creatingClient = null;
}
}
})();
this.creatingClient = creationPromise;
return await creationPromise;
}
public clearClient() {
// Stop the Apollo client to terminate any active processes
this.client?.stop();
this.client = null;
this.creatingClient = null;
}
}

View File

@@ -1,9 +1,11 @@
import { Global, Module } from '@nestjs/common';
import { SocketConfigService } from '@unraid/shared';
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
import { GRAPHQL_PUBSUB_TOKEN } from '@unraid/shared/pubsub/graphql.pubsub.js';
import {
API_KEY_SERVICE_TOKEN,
INTERNAL_CLIENT_SERVICE_TOKEN,
LIFECYCLE_SERVICE_TOKEN,
NGINX_SERVICE_TOKEN,
UPNP_CLIENT_TOKEN,
@@ -15,6 +17,7 @@ import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
import { ApiKeyModule } from '@app/unraid-api/graph/resolvers/api-key/api-key.module.js';
import { NginxModule } from '@app/unraid-api/nginx/nginx.module.js';
import { NginxService } from '@app/unraid-api/nginx/nginx.service.js';
import { InternalGraphQLClientFactory } from '@app/unraid-api/shared/internal-graphql-client.factory.js';
import { upnpClient } from '@app/upnp/helpers.js';
// This is the actual module that provides the global dependencies
@@ -22,6 +25,11 @@ import { upnpClient } from '@app/upnp/helpers.js';
@Module({
imports: [ApiKeyModule, NginxModule],
providers: [
SocketConfigService,
{
provide: INTERNAL_CLIENT_SERVICE_TOKEN,
useClass: InternalGraphQLClientFactory,
},
{
provide: UPNP_CLIENT_TOKEN,
useValue: upnpClient,
@@ -46,10 +54,12 @@ import { upnpClient } from '@app/upnp/helpers.js';
},
],
exports: [
SocketConfigService,
UPNP_CLIENT_TOKEN,
GRAPHQL_PUBSUB_TOKEN,
API_KEY_SERVICE_TOKEN,
NGINX_SERVICE_TOKEN,
INTERNAL_CLIENT_SERVICE_TOKEN,
PrefixedID,
LIFECYCLE_SERVICE_TOKEN,
LifecycleService,

View File

@@ -0,0 +1,228 @@
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { ApolloClient } from '@apollo/client/core/index.js';
import { SocketConfigService } from '@unraid/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { InternalGraphQLClientFactory } from '@app/unraid-api/shared/internal-graphql-client.factory.js';
// Mock the graphql-ws module
vi.mock('graphql-ws', () => ({
createClient: vi.fn(() => ({
dispose: vi.fn(),
on: vi.fn(),
subscribe: vi.fn(),
})),
}));
// Mock undici
vi.mock('undici', () => ({
Agent: vi.fn(() => ({
connect: { socketPath: '/test/socket.sock' },
})),
fetch: vi.fn(() => Promise.resolve({ ok: true })),
}));
describe('InternalGraphQLClientFactory', () => {
let factory: InternalGraphQLClientFactory;
let socketConfig: SocketConfigService;
let configService: ConfigService;
let module: TestingModule;
beforeEach(async () => {
module = await Test.createTestingModule({
providers: [
InternalGraphQLClientFactory,
{
provide: ConfigService,
useValue: {
get: vi.fn(),
},
},
{
provide: SocketConfigService,
useValue: {
isRunningOnSocket: vi.fn(),
getSocketPath: vi.fn(),
getApiAddress: vi.fn(),
getWebSocketUri: vi.fn(),
},
},
],
}).compile();
factory = module.get<InternalGraphQLClientFactory>(InternalGraphQLClientFactory);
socketConfig = module.get<SocketConfigService>(SocketConfigService);
configService = module.get<ConfigService>(ConfigService);
});
afterEach(async () => {
await module?.close();
vi.clearAllMocks();
});
describe('createClient', () => {
it('should throw error when getApiKey is not provided', async () => {
await expect(factory.createClient({ getApiKey: null as any })).rejects.toThrow();
});
it('should create client with Unix socket configuration', async () => {
vi.mocked(socketConfig.isRunningOnSocket).mockReturnValue(true);
vi.mocked(socketConfig.getSocketPath).mockReturnValue('/var/run/unraid-api.sock');
vi.mocked(socketConfig.getWebSocketUri).mockReturnValue(undefined);
const client = await factory.createClient({
getApiKey: async () => 'test-api-key',
enableSubscriptions: false,
});
expect(client).toBeInstanceOf(ApolloClient);
expect(socketConfig.isRunningOnSocket).toHaveBeenCalled();
expect(socketConfig.getSocketPath).toHaveBeenCalled();
});
it('should create client with HTTP configuration', async () => {
vi.mocked(socketConfig.isRunningOnSocket).mockReturnValue(false);
vi.mocked(socketConfig.getApiAddress).mockReturnValue('http://127.0.0.1:3001/graphql');
vi.mocked(socketConfig.getWebSocketUri).mockReturnValue(undefined);
const client = await factory.createClient({
getApiKey: async () => 'test-api-key',
enableSubscriptions: false,
});
expect(client).toBeInstanceOf(ApolloClient);
expect(socketConfig.getApiAddress).toHaveBeenCalledWith('http');
});
it('should create client with WebSocket subscriptions enabled on Unix socket', async () => {
vi.mocked(socketConfig.isRunningOnSocket).mockReturnValue(true);
vi.mocked(socketConfig.getSocketPath).mockReturnValue('/var/run/unraid-api.sock');
vi.mocked(socketConfig.getWebSocketUri).mockReturnValue(
'ws+unix:///var/run/unraid-api.sock:/graphql'
);
const client = await factory.createClient({
getApiKey: async () => 'test-api-key',
enableSubscriptions: true,
});
expect(client).toBeInstanceOf(ApolloClient);
expect(socketConfig.getWebSocketUri).toHaveBeenCalledWith(true);
});
it('should create client with WebSocket subscriptions enabled on TCP', async () => {
vi.mocked(socketConfig.isRunningOnSocket).mockReturnValue(false);
vi.mocked(socketConfig.getApiAddress).mockReturnValue('http://127.0.0.1:3001/graphql');
vi.mocked(socketConfig.getWebSocketUri).mockReturnValue('ws://127.0.0.1:3001/graphql');
const client = await factory.createClient({
getApiKey: async () => 'test-api-key',
enableSubscriptions: true,
});
expect(client).toBeInstanceOf(ApolloClient);
expect(socketConfig.getWebSocketUri).toHaveBeenCalledWith(true);
});
it('should use custom origin when provided', async () => {
vi.mocked(socketConfig.isRunningOnSocket).mockReturnValue(false);
vi.mocked(socketConfig.getApiAddress).mockReturnValue('http://127.0.0.1:3001/graphql');
const client = await factory.createClient({
getApiKey: async () => 'test-api-key',
enableSubscriptions: false,
origin: 'custom-origin',
});
expect(client).toBeInstanceOf(ApolloClient);
// The origin would be set in the HTTP headers, but we can't easily verify that with the mocked setup
});
it('should use default origin when not provided', async () => {
vi.mocked(socketConfig.isRunningOnSocket).mockReturnValue(false);
vi.mocked(socketConfig.getApiAddress).mockReturnValue('http://127.0.0.1:3001/graphql');
const client = await factory.createClient({
getApiKey: async () => 'test-api-key',
enableSubscriptions: false,
});
expect(client).toBeInstanceOf(ApolloClient);
// Default origin should be 'http://localhost'
});
it('should handle subscription disabled even when wsUri is provided', async () => {
vi.mocked(socketConfig.isRunningOnSocket).mockReturnValue(false);
vi.mocked(socketConfig.getApiAddress).mockReturnValue('http://127.0.0.1:3001/graphql');
vi.mocked(socketConfig.getWebSocketUri).mockReturnValue(undefined); // Subscriptions disabled
const client = await factory.createClient({
getApiKey: async () => 'test-api-key',
enableSubscriptions: false,
});
expect(client).toBeInstanceOf(ApolloClient);
expect(socketConfig.getWebSocketUri).toHaveBeenCalledWith(false);
});
});
describe('configuration scenarios', () => {
it('should handle production configuration with Unix socket', async () => {
vi.mocked(socketConfig.isRunningOnSocket).mockReturnValue(true);
vi.mocked(socketConfig.getSocketPath).mockReturnValue('/var/run/unraid-api.sock');
vi.mocked(socketConfig.getWebSocketUri).mockReturnValue(
'ws+unix:///var/run/unraid-api.sock:/graphql'
);
const client = await factory.createClient({
getApiKey: async () => 'production-key',
enableSubscriptions: true,
});
expect(client).toBeInstanceOf(ApolloClient);
expect(socketConfig.isRunningOnSocket).toHaveBeenCalled();
expect(socketConfig.getSocketPath).toHaveBeenCalled();
expect(socketConfig.getWebSocketUri).toHaveBeenCalledWith(true);
});
it('should handle development configuration with TCP port', async () => {
vi.mocked(socketConfig.isRunningOnSocket).mockReturnValue(false);
vi.mocked(socketConfig.getApiAddress).mockReturnValue('http://127.0.0.1:3001/graphql');
vi.mocked(socketConfig.getWebSocketUri).mockReturnValue('ws://127.0.0.1:3001/graphql');
const client = await factory.createClient({
getApiKey: async () => 'dev-key',
enableSubscriptions: true,
});
expect(client).toBeInstanceOf(ApolloClient);
expect(socketConfig.isRunningOnSocket).toHaveBeenCalled();
expect(socketConfig.getApiAddress).toHaveBeenCalledWith('http');
expect(socketConfig.getWebSocketUri).toHaveBeenCalledWith(true);
});
it('should create multiple clients with different configurations', async () => {
vi.mocked(socketConfig.isRunningOnSocket).mockReturnValue(false);
vi.mocked(socketConfig.getApiAddress).mockReturnValue('http://127.0.0.1:3001/graphql');
vi.mocked(socketConfig.getWebSocketUri)
.mockReturnValueOnce(undefined)
.mockReturnValueOnce('ws://127.0.0.1:3001/graphql');
const client1 = await factory.createClient({
getApiKey: async () => 'key1',
enableSubscriptions: false,
});
const client2 = await factory.createClient({
getApiKey: async () => 'key2',
enableSubscriptions: true,
});
expect(client1).toBeInstanceOf(ApolloClient);
expect(client2).toBeInstanceOf(ApolloClient);
expect(client1).not.toBe(client2);
});
});
});

View File

@@ -0,0 +1,168 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { InternalGraphQLClientFactory as IInternalGraphQLClientFactory } from '@unraid/shared';
import { ApolloClient, InMemoryCache, NormalizedCacheObject } from '@apollo/client/core/index.js';
import { setContext } from '@apollo/client/link/context/index.js';
import { split } from '@apollo/client/link/core/index.js';
import { onError } from '@apollo/client/link/error/index.js';
import { HttpLink } from '@apollo/client/link/http/index.js';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions/index.js';
import { getMainDefinition } from '@apollo/client/utilities/index.js';
import { SocketConfigService } from '@unraid/shared';
import { createClient } from 'graphql-ws';
import { Agent, fetch as undiciFetch } from 'undici';
import WebSocket from 'ws';
/**
* Factory service for creating internal GraphQL clients.
*
* This service provides a way for any module to create its own GraphQL client
* with its own API key and configuration. It does NOT provide any default
* API key access - each consumer must provide their own.
*
* This ensures proper security isolation between different modules.
*/
@Injectable()
export class InternalGraphQLClientFactory implements IInternalGraphQLClientFactory {
private readonly logger = new Logger(InternalGraphQLClientFactory.name);
constructor(
private readonly configService: ConfigService,
private readonly socketConfig: SocketConfigService
) {}
/**
* Create a GraphQL client with the provided configuration.
*
* @param options Configuration options
* @param options.getApiKey Function to get the current API key
* @param options.enableSubscriptions Optional flag to enable WebSocket subscriptions
* @param options.origin Optional origin header (defaults to 'http://localhost')
*/
public async createClient(options: {
getApiKey: () => Promise<string>;
enableSubscriptions?: boolean;
origin?: string;
}): Promise<ApolloClient<NormalizedCacheObject>> {
if (!options.getApiKey) {
throw new Error('getApiKey function is required for creating a GraphQL client');
}
const { getApiKey, enableSubscriptions = false, origin = 'http://localhost' } = options;
let httpLink: HttpLink;
// Get WebSocket URI if subscriptions are enabled
const wsUri = this.socketConfig.getWebSocketUri(enableSubscriptions);
if (enableSubscriptions && wsUri) {
this.logger.debug('WebSocket subscriptions enabled: %s', wsUri);
}
if (this.socketConfig.isRunningOnSocket()) {
const socketPath = this.socketConfig.getSocketPath();
this.logger.debug('Creating GraphQL client using Unix socket: %s', socketPath);
const agent = new Agent({
connect: {
socketPath,
},
});
httpLink = new HttpLink({
uri: 'http://localhost/graphql',
fetch: ((uri: any, options: any) => {
return undiciFetch(
uri as string,
{
...options,
dispatcher: agent,
} as any
);
}) as unknown as typeof fetch,
headers: {
Origin: origin,
'Content-Type': 'application/json',
},
});
} else {
const httpUri = this.socketConfig.getApiAddress('http');
this.logger.debug('Creating GraphQL client using HTTP: %s', httpUri);
httpLink = new HttpLink({
uri: httpUri,
fetch,
headers: {
Origin: origin,
'Content-Type': 'application/json',
},
});
}
// Create auth link that dynamically fetches the API key for each request
const authLink = setContext(async (_, { headers }) => {
const apiKey = await getApiKey();
return {
headers: {
...headers,
'x-api-key': apiKey,
},
};
});
const errorLink = onError(({ networkError }) => {
if (networkError) {
this.logger.warn('[GRAPHQL-CLIENT] NETWORK ERROR ENCOUNTERED %o', networkError);
}
});
// If subscriptions are enabled, set up WebSocket link
if (enableSubscriptions && wsUri) {
const wsLink = new GraphQLWsLink(
createClient({
url: wsUri,
connectionParams: async () => {
const apiKey = await getApiKey();
return { 'x-api-key': apiKey };
},
webSocketImpl: WebSocket,
})
);
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink,
httpLink
);
return new ApolloClient({
defaultOptions: {
query: {
fetchPolicy: 'no-cache',
},
mutate: {
fetchPolicy: 'no-cache',
},
},
cache: new InMemoryCache(),
link: errorLink.concat(authLink).concat(splitLink),
});
}
// HTTP-only client
return new ApolloClient({
defaultOptions: {
query: {
fetchPolicy: 'no-cache',
},
},
cache: new InMemoryCache(),
link: errorLink.concat(authLink).concat(httpLink),
});
}
}

View File

@@ -62,6 +62,7 @@
"rxjs": "7.8.2",
"type-fest": "4.41.0",
"typescript": "5.9.2",
"undici": "^7.13.0",
"vitest": "3.2.4",
"ws": "8.18.3",
"zen-observable-ts": "1.1.0"
@@ -97,6 +98,7 @@
"lodash-es": "4.17.21",
"nest-authz": "2.17.0",
"rxjs": "7.8.2",
"undici": "^7.13.0",
"ws": "8.18.3",
"zen-observable-ts": "1.1.0"
}

View File

@@ -0,0 +1,275 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { InternalClientService } from './internal.client.js';
describe('InternalClientService', () => {
let service: InternalClientService;
let clientFactory: any;
let apiKeyService: any;
const mockApolloClient = {
query: vi.fn(),
mutate: vi.fn(),
stop: vi.fn(),
};
beforeEach(() => {
clientFactory = {
createClient: vi.fn().mockResolvedValue(mockApolloClient),
};
apiKeyService = {
getOrCreateLocalApiKey: vi.fn().mockResolvedValue('test-connect-key'),
};
service = new InternalClientService(
clientFactory as any,
apiKeyService as any
);
});
afterEach(() => {
vi.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('getClient', () => {
it('should create a client with Connect API key and subscriptions', async () => {
const client = await service.getClient();
// The API key is now fetched lazily through getApiKey function
expect(clientFactory.createClient).toHaveBeenCalledWith({
getApiKey: expect.any(Function),
enableSubscriptions: true,
});
// Verify the getApiKey function works correctly when called
const callArgs = vi.mocked(clientFactory.createClient).mock.calls[0][0];
const apiKey = await callArgs.getApiKey();
expect(apiKey).toBe('test-connect-key');
expect(apiKeyService.getOrCreateLocalApiKey).toHaveBeenCalled();
expect(client).toBe(mockApolloClient);
});
it('should return cached client on subsequent calls', async () => {
const client1 = await service.getClient();
const client2 = await service.getClient();
expect(client1).toBe(client2);
expect(clientFactory.createClient).toHaveBeenCalledTimes(1);
});
it('should handle concurrent calls correctly', async () => {
// Create a delayed mock to simulate async client creation
let resolveClientCreation: (value: any) => void;
const clientCreationPromise = new Promise((resolve) => {
resolveClientCreation = resolve;
});
vi.mocked(clientFactory.createClient).mockReturnValueOnce(clientCreationPromise);
// Start multiple concurrent calls
const promise1 = service.getClient();
const promise2 = service.getClient();
const promise3 = service.getClient();
// Resolve the client creation
resolveClientCreation!(mockApolloClient);
// Wait for all promises to resolve
const [client1, client2, client3] = await Promise.all([promise1, promise2, promise3]);
// All should return the same client
expect(client1).toBe(mockApolloClient);
expect(client2).toBe(mockApolloClient);
expect(client3).toBe(mockApolloClient);
// createClient should only have been called once
expect(clientFactory.createClient).toHaveBeenCalledTimes(1);
});
it('should handle errors during client creation', async () => {
const error = new Error('Failed to create client');
vi.mocked(clientFactory.createClient).mockRejectedValueOnce(error);
await expect(service.getClient()).rejects.toThrow();
// The in-flight promise should be cleared after error
// A subsequent call should attempt creation again
vi.mocked(clientFactory.createClient).mockResolvedValueOnce(mockApolloClient);
const client = await service.getClient();
expect(client).toBe(mockApolloClient);
expect(clientFactory.createClient).toHaveBeenCalledTimes(2);
});
});
describe('clearClient', () => {
it('should stop and clear the client', async () => {
// First create a client
await service.getClient();
// Clear the client
service.clearClient();
expect(mockApolloClient.stop).toHaveBeenCalled();
});
it('should handle clearing when no client exists', () => {
// Should not throw when clearing a non-existent client
expect(() => service.clearClient()).not.toThrow();
});
it('should create a new client after clearing', async () => {
// Create initial client
await service.getClient();
// Clear it
service.clearClient();
// Reset mock to return a new client
const newMockClient = {
query: vi.fn(),
mutate: vi.fn(),
stop: vi.fn(),
};
vi.mocked(clientFactory.createClient).mockResolvedValueOnce(newMockClient);
// Create new client
const newClient = await service.getClient();
// Should have created client twice total
expect(clientFactory.createClient).toHaveBeenCalledTimes(2);
expect(newClient).toBe(newMockClient);
});
it('should clear in-flight promise when clearing client', async () => {
// Create a delayed mock to simulate async client creation
let resolveClientCreation: (value: any) => void;
const clientCreationPromise = new Promise((resolve) => {
resolveClientCreation = resolve;
});
vi.mocked(clientFactory.createClient).mockReturnValueOnce(clientCreationPromise);
// Start client creation
const promise1 = service.getClient();
// Clear client while creation is in progress
service.clearClient();
// Resolve the original creation
resolveClientCreation!(mockApolloClient);
await promise1;
// Reset mock for new client
const newMockClient = {
query: vi.fn(),
mutate: vi.fn(),
stop: vi.fn(),
};
vi.mocked(clientFactory.createClient).mockResolvedValueOnce(newMockClient);
// Try to get client again - should create a new one
const client = await service.getClient();
expect(client).toBe(newMockClient);
expect(clientFactory.createClient).toHaveBeenCalledTimes(2);
});
it('should handle clearClient during creation followed by new getClient call', async () => {
// Create two delayed mocks to simulate async client creation
let resolveFirstCreation: (value: any) => void;
let resolveSecondCreation: (value: any) => void;
const firstCreationPromise = new Promise((resolve) => {
resolveFirstCreation = resolve;
});
const secondCreationPromise = new Promise((resolve) => {
resolveSecondCreation = resolve;
});
const firstMockClient = {
query: vi.fn(),
mutate: vi.fn(),
stop: vi.fn(),
};
const secondMockClient = {
query: vi.fn(),
mutate: vi.fn(),
stop: vi.fn(),
};
vi.mocked(clientFactory.createClient)
.mockReturnValueOnce(firstCreationPromise)
.mockReturnValueOnce(secondCreationPromise);
// Thread A: Start first client creation
const promiseA = service.getClient();
// Thread B: Clear client while first creation is in progress
service.clearClient();
// Thread C: Start second client creation
const promiseC = service.getClient();
// Resolve first creation (should not set client)
resolveFirstCreation!(firstMockClient);
const clientA = await promiseA;
// Resolve second creation (should set client)
resolveSecondCreation!(secondMockClient);
const clientC = await promiseC;
// Both should return their respective clients
expect(clientA).toBe(firstMockClient);
expect(clientC).toBe(secondMockClient);
// But only the second client should be cached
const cachedClient = await service.getClient();
expect(cachedClient).toBe(secondMockClient);
// Should have created exactly 2 clients
expect(clientFactory.createClient).toHaveBeenCalledTimes(2);
});
it('should handle rapid clear and get cycles correctly', async () => {
// Test rapid clear/get cycles
const clients: any[] = [];
for (let i = 0; i < 3; i++) {
const mockClient = {
query: vi.fn(),
mutate: vi.fn(),
stop: vi.fn(),
};
clients.push(mockClient);
vi.mocked(clientFactory.createClient).mockResolvedValueOnce(mockClient);
}
// Cycle 1: Create and immediately clear
const promise1 = service.getClient();
service.clearClient();
const client1 = await promise1;
expect(client1).toBe(clients[0]);
// Cycle 2: Create and immediately clear
const promise2 = service.getClient();
service.clearClient();
const client2 = await promise2;
expect(client2).toBe(clients[1]);
// Cycle 3: Create and let it complete
const client3 = await service.getClient();
expect(client3).toBe(clients[2]);
// Verify the third client is cached
const cachedClient = await service.getClient();
expect(cachedClient).toBe(clients[2]);
// Should have created exactly 3 clients
expect(clientFactory.createClient).toHaveBeenCalledTimes(3);
});
});
});

View File

@@ -1,135 +1,74 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ApolloClient, InMemoryCache, NormalizedCacheObject } from '@apollo/client/core/index.js';
import { split } from '@apollo/client/link/core/index.js';
import { onError } from '@apollo/client/link/error/index.js';
import { HttpLink } from '@apollo/client/link/http/index.js';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions/index.js';
import { getMainDefinition } from '@apollo/client/utilities/index.js';
import { createClient } from 'graphql-ws';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ApolloClient, NormalizedCacheObject } from '@apollo/client/core/index.js';
import { INTERNAL_CLIENT_SERVICE_TOKEN, type InternalGraphQLClientFactory } from '@unraid/shared';
import { ConnectApiKeyService } from '../authn/connect-api-key.service.js';
/**
* Internal GraphQL "RPC" client.
*
* Unfortunately, there's no simple way to make perform internal gql operations that go through
* all of the validations, filters, authorization, etc. in our setup.
*
* The simplest and most maintainable solution, unfortunately, is to maintain an actual graphql client
* that queries our own graphql server.
*
* This service handles the lifecycle and construction of that client.
* Connect-specific internal GraphQL client.
*
* This uses the shared GraphQL client factory with Connect's API key
* and enables subscriptions for real-time updates.
*/
@Injectable()
export class InternalClientService {
private readonly logger = new Logger(InternalClientService.name);
private client: ApolloClient<NormalizedCacheObject> | null = null;
private clientCreationPromise: Promise<ApolloClient<NormalizedCacheObject>> | null = null;
constructor(
private readonly configService: ConfigService,
@Inject(INTERNAL_CLIENT_SERVICE_TOKEN)
private readonly clientFactory: InternalGraphQLClientFactory,
private readonly apiKeyService: ConnectApiKeyService
) {}
private PROD_NGINX_PORT = 80;
private logger = new Logger(InternalClientService.name);
private client: ApolloClient<NormalizedCacheObject> | null = null;
private getNginxPort() {
return Number(this.configService.get('store.emhttp.nginx.httpPort', this.PROD_NGINX_PORT));
}
/**
* Get the port override from the environment variable PORT. e.g. during development.
* If the port is a socket port, return undefined.
*/
private getNonSocketPortOverride() {
const port = this.configService.get<string | number | undefined>('PORT');
if (!port || port.toString().includes('.sock')) {
return undefined;
}
return Number(port);
}
/**
* Get the API address for the given protocol.
* @param protocol - The protocol to use.
* @param port - The port to use.
* @returns The API address.
*/
private getApiAddress(protocol: 'http' | 'ws', port = this.getNginxPort()) {
const portOverride = this.getNonSocketPortOverride();
if (portOverride) {
return `${protocol}://127.0.0.1:${portOverride}/graphql`;
}
if (port !== this.PROD_NGINX_PORT) {
return `${protocol}://127.0.0.1:${port}/graphql`;
}
return `${protocol}://127.0.0.1/graphql`;
}
private createApiClient({ apiKey }: { apiKey: string }) {
const httpUri = this.getApiAddress('http');
const wsUri = this.getApiAddress('ws');
this.logger.debug('Internal GraphQL URL: %s', httpUri);
const httpLink = new HttpLink({
uri: httpUri,
fetch,
headers: {
Origin: '/var/run/unraid-cli.sock',
'x-api-key': apiKey,
'Content-Type': 'application/json',
},
});
const wsLink = new GraphQLWsLink(
createClient({
url: wsUri,
connectionParams: () => ({ 'x-api-key': apiKey }),
})
);
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' && definition.operation === 'subscription'
);
},
wsLink,
httpLink
);
const errorLink = onError(({ networkError }) => {
if (networkError) {
this.logger.warn('[GRAPHQL-CLIENT] NETWORK ERROR ENCOUNTERED %o', networkError);
}
});
return new ApolloClient({
defaultOptions: {
query: {
fetchPolicy: 'no-cache',
},
mutate: {
fetchPolicy: 'no-cache',
},
},
cache: new InMemoryCache(),
link: errorLink.concat(splitLink),
});
}
public async getClient() {
public async getClient(): Promise<ApolloClient<NormalizedCacheObject>> {
// If client already exists, return it
if (this.client) {
return this.client;
}
const localApiKey = await this.apiKeyService.getOrCreateLocalApiKey();
this.client = this.createApiClient({ apiKey: localApiKey });
return this.client;
// If client creation is in progress, wait for it
if (this.clientCreationPromise) {
return this.clientCreationPromise;
}
// Start client creation and store the promise
const creationPromise = this.createClient();
this.clientCreationPromise = creationPromise;
try {
// Wait for client creation to complete
const client = await creationPromise;
// Only set the client if this is still the current creation promise
// (if clearClient was called, clientCreationPromise would be null)
if (this.clientCreationPromise === creationPromise) {
this.client = client;
}
return client;
} finally {
// Clear the in-flight promise only if it's still ours
if (this.clientCreationPromise === creationPromise) {
this.clientCreationPromise = null;
}
}
}
private async createClient(): Promise<ApolloClient<NormalizedCacheObject>> {
// Create a client with a function to get Connect's API key dynamically
const client = await this.clientFactory.createClient({
getApiKey: () => this.apiKeyService.getOrCreateLocalApiKey(),
enableSubscriptions: true
});
this.logger.debug('Created Connect internal GraphQL client with subscriptions enabled');
return client;
}
public clearClient() {
// Stop the Apollo client to terminate any active processes
this.client?.stop();
this.client = null;
this.clientCreationPromise = null;
}
}

View File

@@ -11,8 +11,7 @@
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"forceConsistentCasingInFileNames": true },
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -5,6 +5,7 @@
"moduleResolution": "nodenext",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
},

View File

@@ -7,6 +7,7 @@
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
},

View File

@@ -11,8 +11,7 @@
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"forceConsistentCasingInFileNames": true },
"include": ["index.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -20,7 +20,8 @@
"scripts": {
"build": "rimraf dist && tsc --project tsconfig.build.json",
"prepare": "npm run build",
"test": "bun test"
"test": "vitest run",
"test:watch": "vitest"
},
"keywords": [],
"author": "Lime Technology, Inc. <unraid.net>",
@@ -31,19 +32,25 @@
"@jsonforms/core": "3.6.0",
"@nestjs/common": "11.1.6",
"@nestjs/graphql": "13.1.0",
"@nestjs/testing": "11.1.5",
"@types/bun": "1.2.20",
"@types/lodash-es": "4.17.12",
"@types/node": "22.17.1",
"@types/ws": "^8.5.13",
"class-validator": "0.14.2",
"graphql": "16.11.0",
"graphql-scalars": "1.24.2",
"graphql-ws": "6.0.6",
"lodash-es": "4.17.21",
"nest-authz": "2.17.0",
"rimraf": "6.0.1",
"type-fest": "4.41.0",
"typescript": "5.9.2"
"typescript": "5.9.2",
"vitest": "3.2.4",
"ws": "^8.18.3"
},
"peerDependencies": {
"@apollo/client": "3.13.9",
"@graphql-tools/utils": "10.9.1",
"@jsonforms/core": "3.6.0",
"@nestjs/common": "11.1.6",
@@ -53,8 +60,11 @@
"class-validator": "0.14.2",
"graphql": "16.11.0",
"graphql-scalars": "1.24.2",
"graphql-ws": "6.0.6",
"lodash-es": "4.17.21",
"nest-authz": "2.17.0",
"rxjs": "7.8.2"
"rxjs": "7.8.2",
"undici": "7.13.0",
"ws": "^8.18.0"
}
}

View File

@@ -1,4 +1,6 @@
export { ApiKeyService } from './services/api-key.js';
export { SocketConfigService } from './services/socket-config.service.js';
export * from './graphql.model.js';
export * from './tokens.js';
export * from './use-permissions.directive.js';
export type { InternalGraphQLClientFactory } from './types/internal-graphql-client.factory.js';

View File

@@ -1,4 +1,4 @@
import { expect, test, describe } from "bun:test";
import { expect, test, describe } from "vitest";
import { mergeSettingSlices, type SettingSlice } from "../settings.js";
describe("mergeSettingSlices element ordering", () => {

View File

@@ -1,4 +1,4 @@
import { expect, test, describe, beforeEach, afterEach } from "bun:test";
import { expect, test, describe, beforeEach, afterEach } from "vitest";
import { Subject } from "rxjs";
import { readFile, writeFile, mkdir, rm } from "node:fs/promises";
import { join } from "node:path";

View File

@@ -0,0 +1,347 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { ConfigService } from '@nestjs/config';
import { ApolloClient } from '@apollo/client/core/index.js';
import { SocketConfigService } from './socket-config.service.js';
// Mock graphql-ws
vi.mock('graphql-ws', () => ({
createClient: vi.fn(() => ({
dispose: vi.fn(),
on: vi.fn(),
subscribe: vi.fn(),
})),
}));
// Mock undici
vi.mock('undici', () => ({
Agent: vi.fn(() => ({
connect: { socketPath: '/test/socket.sock' },
})),
fetch: vi.fn(() => Promise.resolve({ ok: true })),
}));
// Mock factory similar to InternalGraphQLClientFactory
class MockGraphQLClientFactory {
constructor(
private readonly configService: ConfigService,
private readonly socketConfig: SocketConfigService
) {}
async createClient(options: {
apiKey: string;
enableSubscriptions?: boolean;
origin?: string;
}): Promise<ApolloClient<any>> {
if (!options.apiKey) {
throw new Error('API key is required for creating a GraphQL client');
}
// Return a mock Apollo client
const mockClient = {
query: vi.fn(),
mutate: vi.fn(),
stop: vi.fn(),
subscribe: vi.fn(),
watchQuery: vi.fn(),
readQuery: vi.fn(),
writeQuery: vi.fn(),
cache: {
reset: vi.fn(),
},
} as any;
return mockClient;
}
}
// Service that uses the factory pattern (like CliInternalClientService)
class ClientConsumerService {
private client: ApolloClient<any> | null = null;
private wsClient: any = null;
constructor(
private readonly factory: MockGraphQLClientFactory,
private readonly apiKeyProvider: () => Promise<string>,
private readonly options: { enableSubscriptions?: boolean; origin?: string } = {}
) {
// Use default origin if not provided
if (!this.options.origin) {
this.options.origin = 'http://localhost';
}
}
async getClient(): Promise<ApolloClient<any>> {
if (this.client) {
return this.client;
}
const apiKey = await this.apiKeyProvider();
this.client = await this.factory.createClient({
apiKey,
...this.options,
});
return this.client;
}
clearClient() {
// Stop the Apollo client to terminate any active processes
this.client?.stop();
// Clean up WebSocket client if it exists
if (this.wsClient) {
this.wsClient.dispose();
this.wsClient = null;
}
this.client = null;
}
}
describe('InternalGraphQLClient Usage Patterns', () => {
let factory: MockGraphQLClientFactory;
let configService: ConfigService;
let socketConfig: SocketConfigService;
let apiKeyProvider: () => Promise<string>;
let service: ClientConsumerService;
beforeEach(() => {
// Create mock ConfigService
configService = {
get: vi.fn((key, defaultValue) => {
if (key === 'PORT') return '3001';
return defaultValue;
}),
} as any;
// Create SocketConfigService instance
socketConfig = new SocketConfigService(configService);
// Create factory
factory = new MockGraphQLClientFactory(configService, socketConfig);
// Create mock API key provider
apiKeyProvider = vi.fn().mockResolvedValue('test-api-key');
// Create service
service = new ClientConsumerService(factory, apiKeyProvider);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('constructor and initialization', () => {
it('should initialize with default options', () => {
const service = new ClientConsumerService(factory, apiKeyProvider);
expect(service).toBeDefined();
// @ts-ignore - accessing private property for testing
expect(service.options.origin).toBe('http://localhost');
});
it('should initialize with custom options', () => {
const options = {
enableSubscriptions: true,
origin: 'custom-origin',
};
const service = new ClientConsumerService(factory, apiKeyProvider, options);
// @ts-ignore - accessing private property for testing
expect(service.options.enableSubscriptions).toBe(true);
// @ts-ignore - accessing private property for testing
expect(service.options.origin).toBe('custom-origin');
});
});
describe('API key handling', () => {
it('should get API key from provider', async () => {
const client = await service.getClient();
expect(apiKeyProvider).toHaveBeenCalled();
expect(client).toBeDefined();
});
it('should handle API key provider failures gracefully', async () => {
const failingProvider = vi.fn().mockRejectedValue(new Error('API key error'));
const service = new ClientConsumerService(factory, failingProvider);
await expect(service.getClient()).rejects.toThrow('API key error');
});
});
describe('client lifecycle management', () => {
it('should create and cache client on first call', async () => {
const client = await service.getClient();
expect(client).toBeDefined();
expect(client.query).toBeDefined();
expect(apiKeyProvider).toHaveBeenCalledOnce();
// Second call should return cached client
const client2 = await service.getClient();
expect(client2).toBe(client);
expect(apiKeyProvider).toHaveBeenCalledOnce(); // Still only called once
});
it('should clear cached client and stop it', async () => {
const client = await service.getClient();
const stopSpy = vi.spyOn(client, 'stop');
service.clearClient();
expect(stopSpy).toHaveBeenCalled();
// @ts-ignore - accessing private property for testing
expect(service.client).toBeNull();
});
it('should handle clearing when no client exists', () => {
expect(() => service.clearClient()).not.toThrow();
});
it('should create new client after clearing', async () => {
const client1 = await service.getClient();
service.clearClient();
const client2 = await service.getClient();
expect(client2).not.toBe(client1);
expect(apiKeyProvider).toHaveBeenCalledTimes(2);
});
});
describe('configuration scenarios', () => {
it('should handle Unix socket configuration', async () => {
vi.mocked(configService.get).mockImplementation((key, defaultValue) => {
if (key === 'PORT') return '/var/run/unraid-api.sock';
return defaultValue;
});
const socketConfig = new SocketConfigService(configService);
const factory = new MockGraphQLClientFactory(configService, socketConfig);
const service = new ClientConsumerService(factory, apiKeyProvider);
const client = await service.getClient();
expect(client).toBeDefined();
expect(apiKeyProvider).toHaveBeenCalled();
});
it('should handle TCP port configuration', async () => {
vi.mocked(configService.get).mockImplementation((key, defaultValue) => {
if (key === 'PORT') return '3001';
return defaultValue;
});
const client = await service.getClient();
expect(client).toBeDefined();
expect(apiKeyProvider).toHaveBeenCalled();
});
it('should handle WebSocket subscriptions when enabled', async () => {
const options = { enableSubscriptions: true };
const service = new ClientConsumerService(factory, apiKeyProvider, options);
const client = await service.getClient();
expect(client).toBeDefined();
expect(client.subscribe).toBeDefined();
});
});
describe('factory pattern benefits', () => {
it('should allow multiple services to use the same factory', async () => {
const service1 = new ClientConsumerService(factory, apiKeyProvider, {
origin: 'service1',
});
const service2 = new ClientConsumerService(factory, apiKeyProvider, {
origin: 'service2',
});
const client1 = await service1.getClient();
const client2 = await service2.getClient();
expect(client1).toBeDefined();
expect(client2).toBeDefined();
// Each service gets its own client instance
expect(client1).not.toBe(client2);
});
it('should handle different API keys for different services', async () => {
const provider1 = vi.fn().mockResolvedValue('api-key-1');
const provider2 = vi.fn().mockResolvedValue('api-key-2');
const service1 = new ClientConsumerService(factory, provider1);
const service2 = new ClientConsumerService(factory, provider2);
await service1.getClient();
await service2.getClient();
expect(provider1).toHaveBeenCalledOnce();
expect(provider2).toHaveBeenCalledOnce();
});
});
describe('integration scenarios', () => {
it('should handle production scenario with Unix socket and subscriptions', async () => {
vi.mocked(configService.get).mockImplementation((key, defaultValue) => {
if (key === 'PORT') return '/var/run/unraid-api.sock';
if (key === 'store.emhttp.nginx.httpPort') return '80';
return defaultValue;
});
const socketConfig = new SocketConfigService(configService);
const factory = new MockGraphQLClientFactory(configService, socketConfig);
const service = new ClientConsumerService(factory, apiKeyProvider, {
enableSubscriptions: true,
});
const client = await service.getClient();
expect(client).toBeDefined();
});
it('should handle development scenario with TCP port and subscriptions', async () => {
vi.mocked(configService.get).mockImplementation((key, defaultValue) => {
if (key === 'PORT') return '3001';
return defaultValue;
});
const socketConfig = new SocketConfigService(configService);
const factory = new MockGraphQLClientFactory(configService, socketConfig);
const service = new ClientConsumerService(factory, apiKeyProvider, {
enableSubscriptions: true,
});
const client = await service.getClient();
expect(client).toBeDefined();
});
it('should handle multiple client lifecycle operations', async () => {
const client1 = await service.getClient();
expect(client1).toBeDefined();
service.clearClient();
const client2 = await service.getClient();
expect(client2).toBeDefined();
expect(client2).not.toBe(client1);
service.clearClient();
const client3 = await service.getClient();
expect(client3).toBeDefined();
expect(client3).not.toBe(client2);
});
it('should handle WebSocket client cleanup when subscriptions are enabled', async () => {
const mockWsClient = { dispose: vi.fn() };
const service = new ClientConsumerService(factory, apiKeyProvider, {
enableSubscriptions: true,
});
// First create a client
await service.getClient();
// Mock the WebSocket client after it's created
// @ts-ignore - accessing private property for testing
service.wsClient = mockWsClient;
service.clearClient();
expect(mockWsClient.dispose).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,293 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { ConfigService } from '@nestjs/config';
import { SocketConfigService } from './socket-config.service.js';
describe('SocketConfigService', () => {
let service: SocketConfigService;
let configService: ConfigService;
beforeEach(() => {
configService = new ConfigService();
service = new SocketConfigService(configService);
});
afterEach(() => {
// Clean up all spies and mocks after each test
vi.restoreAllMocks();
});
describe('getNginxPort', () => {
it('should return configured nginx port', () => {
vi.spyOn(configService, 'get').mockReturnValue('8080');
const port = service.getNginxPort();
expect(port).toBe(8080);
expect(configService.get).toHaveBeenCalledWith('store.emhttp.nginx.httpPort', 80);
});
it('should return default port when not configured', () => {
vi.spyOn(configService, 'get').mockImplementation((key, defaultValue) => defaultValue);
const port = service.getNginxPort();
expect(port).toBe(80);
});
});
describe('isRunningOnSocket', () => {
it('should return true when PORT contains .sock', () => {
vi.spyOn(configService, 'get').mockReturnValue('/var/run/unraid-api.sock');
expect(service.isRunningOnSocket()).toBe(true);
});
it('should return false when PORT is numeric', () => {
vi.spyOn(configService, 'get').mockReturnValue('3000');
expect(service.isRunningOnSocket()).toBe(false);
});
it('should use default socket path when PORT not set', () => {
// Mock to simulate PORT not being set in configuration
// When PORT returns undefined, ConfigService should use the default value
vi.spyOn(configService, 'get').mockImplementation((key, defaultValue) => {
if (key === 'PORT') {
// Simulate ConfigService behavior: return default when config is undefined
return defaultValue; // This simulates PORT not being in config
}
return defaultValue;
});
expect(service.isRunningOnSocket()).toBe(true);
expect(configService.get).toHaveBeenCalledWith('PORT', '/var/run/unraid-api.sock');
});
});
describe('getSocketPath', () => {
it('should return configured socket path', () => {
const socketPath = '/custom/socket.sock';
vi.spyOn(configService, 'get').mockReturnValue(socketPath);
expect(service.getSocketPath()).toBe(socketPath);
});
it('should return default socket path when not configured', () => {
vi.spyOn(configService, 'get').mockImplementation((key, defaultValue) => defaultValue);
expect(service.getSocketPath()).toBe('/var/run/unraid-api.sock');
});
});
describe('getNumericPort', () => {
it('should return numeric port when configured', () => {
vi.spyOn(configService, 'get').mockReturnValue('3000');
expect(service.getNumericPort()).toBe(3000);
});
it('should return undefined when running on socket', () => {
vi.spyOn(configService, 'get').mockReturnValue('/var/run/unraid-api.sock');
expect(service.getNumericPort()).toBeUndefined();
});
it('should handle string ports correctly', () => {
vi.spyOn(configService, 'get').mockReturnValue('8080');
expect(service.getNumericPort()).toBe(8080);
});
it('should return undefined for non-numeric port values', () => {
vi.spyOn(configService, 'get').mockReturnValue('invalid-port');
expect(service.getNumericPort()).toBeUndefined();
});
it('should return undefined for empty string port', () => {
vi.spyOn(configService, 'get').mockReturnValue('');
expect(service.getNumericPort()).toBeUndefined();
});
it('should return undefined for port with mixed characters', () => {
vi.spyOn(configService, 'get').mockReturnValue('3000abc');
expect(service.getNumericPort()).toBeUndefined();
});
it('should return undefined for port 0', () => {
vi.spyOn(configService, 'get').mockReturnValue('0');
expect(service.getNumericPort()).toBeUndefined();
});
it('should return undefined for negative port', () => {
vi.spyOn(configService, 'get').mockReturnValue('-1');
expect(service.getNumericPort()).toBeUndefined();
});
it('should return undefined for port above 65535', () => {
vi.spyOn(configService, 'get').mockReturnValue('70000');
expect(service.getNumericPort()).toBeUndefined();
});
it('should return valid port 65535', () => {
vi.spyOn(configService, 'get').mockReturnValue('65535');
expect(service.getNumericPort()).toBe(65535);
});
});
describe('getApiAddress', () => {
it('should return HTTP address with numeric port', () => {
vi.spyOn(configService, 'get').mockImplementation((key) => {
if (key === 'PORT') return '3000';
return undefined;
});
expect(service.getApiAddress('http')).toBe('http://127.0.0.1:3000/graphql');
});
it('should return WS address with numeric port', () => {
vi.spyOn(configService, 'get').mockImplementation((key) => {
if (key === 'PORT') return '3000';
return undefined;
});
expect(service.getApiAddress('ws')).toBe('ws://127.0.0.1:3000/graphql');
});
it('should use nginx port when no numeric port configured', () => {
vi.spyOn(configService, 'get').mockImplementation((key, defaultValue) => {
if (key === 'PORT') return '/var/run/unraid-api.sock';
if (key === 'store.emhttp.nginx.httpPort') return '8080';
return defaultValue;
});
expect(service.getApiAddress('http')).toBe('http://127.0.0.1:8080/graphql');
});
it('should omit port when nginx port is default (80)', () => {
vi.spyOn(configService, 'get').mockImplementation((key, defaultValue) => {
if (key === 'PORT') return '/var/run/unraid-api.sock';
if (key === 'store.emhttp.nginx.httpPort') return '80';
return defaultValue;
});
expect(service.getApiAddress('http')).toBe('http://127.0.0.1/graphql');
});
it('should default to http protocol', () => {
vi.spyOn(configService, 'get').mockImplementation((key) => {
if (key === 'PORT') return '3000';
return undefined;
});
expect(service.getApiAddress()).toBe('http://127.0.0.1:3000/graphql');
});
it('should fallback to nginx port when PORT is invalid', () => {
vi.spyOn(configService, 'get').mockImplementation((key, defaultValue) => {
if (key === 'PORT') return 'invalid-port';
if (key === 'store.emhttp.nginx.httpPort') return '8080';
return defaultValue;
});
expect(service.getApiAddress('http')).toBe('http://127.0.0.1:8080/graphql');
});
it('should use default port when PORT is invalid and nginx port is default', () => {
vi.spyOn(configService, 'get').mockImplementation((key, defaultValue) => {
if (key === 'PORT') return 'not-a-number';
if (key === 'store.emhttp.nginx.httpPort') return '80';
return defaultValue;
});
expect(service.getApiAddress('http')).toBe('http://127.0.0.1/graphql');
});
});
describe('getWebSocketUri', () => {
it('should return undefined when subscriptions disabled', () => {
expect(service.getWebSocketUri(false)).toBeUndefined();
});
it('should return ws+unix:// URI when running on socket', () => {
const socketPath = '/var/run/unraid-api.sock';
vi.spyOn(configService, 'get').mockReturnValue(socketPath);
const uri = service.getWebSocketUri(true);
expect(uri).toBe(`ws+unix://${socketPath}:/graphql`);
});
it('should return ws:// URI when running on TCP port', () => {
vi.spyOn(configService, 'get').mockImplementation((key) => {
if (key === 'PORT') return '3000';
return undefined;
});
const uri = service.getWebSocketUri(true);
expect(uri).toBe('ws://127.0.0.1:3000/graphql');
});
it('should handle custom socket paths', () => {
const customSocket = '/custom/path/api.sock';
vi.spyOn(configService, 'get').mockReturnValue(customSocket);
const uri = service.getWebSocketUri(true);
expect(uri).toBe(`ws+unix://${customSocket}:/graphql`);
});
it('should use TCP port for WebSocket when running on TCP port', () => {
// Configure to use TCP port instead of Unix socket
// This naturally causes isRunningOnSocket() to return false
vi.spyOn(configService, 'get').mockImplementation((key, defaultValue) => {
if (key === 'PORT') return '3001'; // TCP port, not a socket
if (key === 'store.emhttp.nginx.httpPort') return '8080';
return defaultValue;
});
const uri = service.getWebSocketUri(true);
// When PORT is numeric, it uses that port directly for WebSocket
expect(uri).toBe('ws://127.0.0.1:3001/graphql');
});
});
describe('integration scenarios', () => {
it('should handle production configuration correctly', () => {
// Production typically runs on Unix socket
vi.spyOn(configService, 'get').mockImplementation((key, defaultValue) => {
if (key === 'PORT') return '/var/run/unraid-api.sock';
if (key === 'store.emhttp.nginx.httpPort') return '80';
return defaultValue;
});
expect(service.isRunningOnSocket()).toBe(true);
expect(service.getSocketPath()).toBe('/var/run/unraid-api.sock');
expect(service.getNumericPort()).toBeUndefined();
expect(service.getApiAddress('http')).toBe('http://127.0.0.1/graphql');
expect(service.getWebSocketUri(true)).toBe('ws+unix:///var/run/unraid-api.sock:/graphql');
});
it('should handle development configuration correctly', () => {
// Development typically runs on TCP port
vi.spyOn(configService, 'get').mockImplementation((key, defaultValue) => {
if (key === 'PORT') return '3001';
return defaultValue;
});
expect(service.isRunningOnSocket()).toBe(false);
expect(service.getNumericPort()).toBe(3001);
expect(service.getApiAddress('http')).toBe('http://127.0.0.1:3001/graphql');
expect(service.getWebSocketUri(true)).toBe('ws://127.0.0.1:3001/graphql');
});
});
});

View File

@@ -0,0 +1,95 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
/**
* Shared service for socket detection and address resolution.
* Used by InternalGraphQLClientFactory and other services that need socket configuration.
*/
@Injectable()
export class SocketConfigService {
private readonly PROD_NGINX_PORT = 80;
constructor(private readonly configService: ConfigService) {}
/**
* Get the nginx port from configuration
*/
getNginxPort(): number {
const port = Number(this.configService.get('store.emhttp.nginx.httpPort', this.PROD_NGINX_PORT));
// Validate the numeric result and fall back to PROD_NGINX_PORT if invalid
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
return this.PROD_NGINX_PORT;
}
return port;
}
/**
* Check if the API is running on a Unix socket
*/
isRunningOnSocket(): boolean {
const port = this.configService.get<string>('PORT', '/var/run/unraid-api.sock');
return port.includes('.sock');
}
/**
* Get the socket path from config
*/
getSocketPath(): string {
return this.configService.get<string>('PORT', '/var/run/unraid-api.sock');
}
/**
* Get the numeric port if not running on socket
*/
getNumericPort(): number | undefined {
const port = this.configService.get<string>('PORT', '/var/run/unraid-api.sock');
if (port.includes('.sock')) {
return undefined;
}
const numericPort = Number(port);
// Check if the conversion resulted in a valid finite number
// Also check for reasonable port range (0 is not a valid port)
if (!Number.isFinite(numericPort) || numericPort <= 0 || numericPort > 65535) {
return undefined;
}
return numericPort;
}
/**
* Get the API address for HTTP or WebSocket requests.
* @param protocol - The protocol to use ('http' or 'ws')
* @returns The full API endpoint URL
*/
getApiAddress(protocol: 'http' | 'ws' = 'http'): string {
const numericPort = this.getNumericPort();
if (numericPort) {
return `${protocol}://127.0.0.1:${numericPort}/graphql`;
}
const nginxPort = this.getNginxPort();
if (nginxPort !== this.PROD_NGINX_PORT) {
return `${protocol}://127.0.0.1:${nginxPort}/graphql`;
}
return `${protocol}://127.0.0.1/graphql`;
}
/**
* Get the WebSocket URI for subscriptions.
* Handles both Unix socket and TCP connections.
* @param enableSubscriptions - Whether subscriptions are enabled
* @returns The WebSocket URI or undefined if subscriptions are disabled
*/
getWebSocketUri(enableSubscriptions: boolean = false): string | undefined {
if (!enableSubscriptions) {
return undefined;
}
if (this.isRunningOnSocket()) {
// For Unix sockets, use the ws+unix:// protocol
// Format: ws+unix://socket/path:/url/path
const socketPath = this.getSocketPath();
return `ws+unix://${socketPath}:/graphql`;
}
return this.getApiAddress('ws');
}
}

View File

@@ -0,0 +1,259 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import WebSocket, { WebSocketServer } from 'ws';
import { createServer } from 'http';
import { unlinkSync, existsSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
describe('WebSocket Unix Socket - Actual Connection Test', () => {
const socketPath = join(tmpdir(), 'test-ws-unix-' + Date.now() + '.sock');
let server: ReturnType<typeof createServer>;
let wss: WebSocketServer;
beforeAll(async () => {
// Clean up any existing socket file
if (existsSync(socketPath)) {
unlinkSync(socketPath);
}
// Create an HTTP server
server = createServer((req, res) => {
res.writeHead(200);
res.end('HTTP server on Unix socket');
});
// Create WebSocket server attached to the HTTP server
wss = new WebSocketServer({ server });
// Handle WebSocket connections
wss.on('connection', (ws, request) => {
console.log('Server: New WebSocket connection on path:', request.url);
// Send welcome message
ws.send(JSON.stringify({ type: 'welcome', message: 'Connected to Unix socket' }));
// Echo messages back
ws.on('message', (data) => {
const message = data.toString();
console.log('Server received:', message);
ws.send(JSON.stringify({ type: 'echo', message }));
});
ws.on('close', () => {
console.log('Server: Client disconnected');
});
});
// Start listening on Unix socket
await new Promise<void>((resolve, reject) => {
server.listen(socketPath, () => {
console.log(`Server listening on Unix socket: ${socketPath}`);
resolve();
});
server.on('error', (err) => {
console.error('Server error:', err);
reject(err);
});
});
});
afterAll(async () => {
// First, close all WebSocket clients gracefully
if (wss && wss.clients) {
const closePromises: Promise<void>[] = [];
for (const client of wss.clients) {
if (client.readyState === WebSocket.OPEN) {
closePromises.push(
new Promise<void>((resolve) => {
client.once('close', () => resolve());
client.close(1000, 'Test ending');
})
);
} else {
client.terminate();
}
}
// Wait for all clients to close
await Promise.all(closePromises);
}
// Close WebSocket server
if (wss) {
await new Promise<void>((resolve) => {
wss.close(() => resolve());
});
}
// Close HTTP server
if (server && server.listening) {
await new Promise<void>((resolve) => {
server.close((err) => {
if (err) console.error('Server close error:', err);
resolve();
});
});
}
// Clean up socket file
try {
if (existsSync(socketPath)) {
unlinkSync(socketPath);
}
} catch (err) {
console.error('Error cleaning up socket file:', err);
}
});
it('should connect to Unix socket using ws+unix:// protocol', async () => {
// This is the exact format the ws library expects for Unix sockets
const wsUrl = `ws+unix://${socketPath}:/`;
console.log('Connecting to:', wsUrl);
const client = new WebSocket(wsUrl);
// Wait for connection and first message
const connected = await new Promise<boolean>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Connection timeout')), 10000);
client.once('open', () => {
console.log('Client: Connected successfully!');
clearTimeout(timeout);
// Connection established - that's what we want to test
resolve(true);
});
client.once('error', (err) => {
console.error('Client error:', err);
clearTimeout(timeout);
reject(err);
});
});
expect(connected).toBe(true);
// Test message exchange
client.send('Test message');
const received = await new Promise<any>((resolve) => {
client.on('message', (data) => {
resolve(JSON.parse(data.toString()));
});
setTimeout(() => resolve(null), 1000);
});
// We should have received something (welcome or echo)
expect(received).toBeTruthy();
// Clean up gracefully
if (client.readyState === WebSocket.OPEN) {
client.close(1000, 'Test complete');
} else {
client.terminate();
}
});
it('should connect with /graphql path like SocketConfigService', async () => {
// Test the exact format that SocketConfigService.getWebSocketUri() returns
const wsUrl = `ws+unix://${socketPath}:/graphql`;
console.log('Testing SocketConfigService format:', wsUrl);
const client = new WebSocket(wsUrl);
await new Promise<void>((resolve, reject) => {
client.on('open', () => {
console.log('Client: Connected to /graphql path');
resolve();
});
client.on('error', reject);
setTimeout(() => reject(new Error('Connection timeout')), 2000);
});
// Connection successful!
expect(client.readyState).toBe(WebSocket.OPEN);
client.close();
});
it('should fail when using regular ws:// to connect to Unix socket', async () => {
// This should fail - attempting to connect to non-existent Unix socket
const nonExistentSocket = `${socketPath}.nonexistent`;
const wsUrl = `ws+unix://${nonExistentSocket}:/test`;
await expect(
new Promise((_, reject) => {
const client = new WebSocket(wsUrl);
client.on('error', reject);
client.on('open', () => reject(new Error('Should not connect')));
})
).rejects.toThrow();
});
it('should work with multiple concurrent connections', async () => {
const clients: WebSocket[] = [];
const numClients = 3;
// Create multiple connections
for (let i = 0; i < numClients; i++) {
const wsUrl = `ws+unix://${socketPath}:/client-${i}`;
const client = new WebSocket(wsUrl);
await new Promise<void>((resolve, reject) => {
client.on('open', () => {
console.log(`Client ${i} connected`);
resolve();
});
client.on('error', reject);
setTimeout(() => reject(new Error('Connection timeout')), 2000);
});
clients.push(client);
}
expect(clients).toHaveLength(numClients);
// The server might have leftover connections from previous tests
expect(wss.clients.size).toBeGreaterThanOrEqual(numClients);
// Clean up all clients gracefully
clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.close(1000, 'Test complete');
} else {
client.terminate();
}
});
});
it('should verify the exact implementation used in BaseInternalClientService', async () => {
// This tests the exact code path in base-internal-client.service.ts
const { createClient } = await import('graphql-ws');
const wsUrl = `ws+unix://${socketPath}:/graphql`;
// This is exactly what BaseInternalClientService does
const wsClient = createClient({
url: wsUrl,
connectionParams: () => ({ 'x-api-key': 'test-key' }),
webSocketImpl: WebSocket,
retryAttempts: 0,
lazy: true, // Use lazy mode to prevent immediate connection
on: {
error: (err) => {
// Suppress connection errors in test
const message = err instanceof Error ? err.message : String(err);
console.log('GraphQL client error (expected in test):', message);
},
},
});
// The client should be created without errors
expect(wsClient).toBeDefined();
expect(wsClient.dispose).toBeDefined();
// Clean up immediately - don't wait for connection
await wsClient.dispose();
});
});

View File

@@ -2,3 +2,4 @@ export const UPNP_CLIENT_TOKEN = 'UPNP_CLIENT';
export const API_KEY_SERVICE_TOKEN = 'ApiKeyService';
export const LIFECYCLE_SERVICE_TOKEN = 'LifecycleService';
export const NGINX_SERVICE_TOKEN = 'NginxService';
export const INTERNAL_CLIENT_SERVICE_TOKEN = 'InternalClientService';

View File

@@ -0,0 +1,13 @@
import type { ApolloClient, NormalizedCacheObject } from '@apollo/client/core/index.js';
/**
* Interface for the internal GraphQL client factory.
* The actual implementation is provided by the API package through dependency injection.
*/
export interface InternalGraphQLClientFactory {
createClient(options: {
getApiKey: () => Promise<string>;
enableSubscriptions?: boolean;
origin?: string;
}): Promise<ApolloClient<NormalizedCacheObject>>;
}

View File

@@ -1,4 +1,4 @@
import { expect, test, describe, beforeEach } from "bun:test";
import { expect, test, describe, beforeEach } from "vitest";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { ConfigDefinition } from "../config-definition.js";

View File

@@ -1,4 +1,4 @@
import { expect, test, describe, beforeEach, afterEach } from "bun:test";
import { expect, test, describe, beforeEach, afterEach } from "vitest";
import { readFile, writeFile, mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";

View File

@@ -1,4 +1,4 @@
import { expect, test, describe } from "bun:test";
import { expect, test, describe } from "vitest";
import { getPrefixedSortedKeys } from "../key-order.js";

57
pnpm-lock.yaml generated
View File

@@ -295,6 +295,9 @@ importers:
systeminformation:
specifier: 5.27.7
version: 5.27.7
undici:
specifier: ^7.13.0
version: 7.13.0
unraid-api-plugin-connect:
specifier: workspace:*
version: link:../packages/unraid-api-plugin-connect
@@ -615,6 +618,9 @@ importers:
typescript:
specifier: 5.9.2
version: 5.9.2
undici:
specifier: ^7.13.0
version: 7.13.0
vitest:
specifier: 3.2.4
version: 3.2.4(@types/node@22.17.1)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.1)
@@ -706,6 +712,9 @@ importers:
packages/unraid-shared:
dependencies:
'@apollo/client':
specifier: 3.13.9
version: 3.13.9(@types/react@19.0.8)(graphql-ws@6.0.6(crossws@0.3.5)(graphql@16.11.0)(ws@8.18.3))(graphql@16.11.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(subscriptions-transport-ws@0.11.0(graphql@16.11.0))
'@nestjs/config':
specifier: 4.0.2
version: 4.0.2(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(rxjs@7.8.2)
@@ -715,6 +724,9 @@ importers:
rxjs:
specifier: 7.8.2
version: 7.8.2
undici:
specifier: 7.13.0
version: 7.13.0
devDependencies:
'@graphql-tools/utils':
specifier: 10.9.1
@@ -728,6 +740,9 @@ importers:
'@nestjs/graphql':
specifier: 13.1.0
version: 13.1.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(graphql@16.11.0)(reflect-metadata@0.1.14)(ts-morph@24.0.0)
'@nestjs/testing':
specifier: 11.1.5
version: 11.1.5(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))
'@types/bun':
specifier: 1.2.20
version: 1.2.20(@types/react@19.0.8)
@@ -737,6 +752,9 @@ importers:
'@types/node':
specifier: 22.17.1
version: 22.17.1
'@types/ws':
specifier: ^8.5.13
version: 8.18.1
class-validator:
specifier: 0.14.2
version: 0.14.2
@@ -746,6 +764,9 @@ importers:
graphql-scalars:
specifier: 1.24.2
version: 1.24.2(graphql@16.11.0)
graphql-ws:
specifier: 6.0.6
version: 6.0.6(crossws@0.3.5)(graphql@16.11.0)(ws@8.18.3)
lodash-es:
specifier: 4.17.21
version: 4.17.21
@@ -761,6 +782,12 @@ importers:
typescript:
specifier: 5.9.2
version: 5.9.2
vitest:
specifier: 3.2.4
version: 3.2.4(@types/node@22.17.1)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.1)
ws:
specifier: ^8.18.3
version: 8.18.3
plugin:
dependencies:
@@ -3704,6 +3731,19 @@ packages:
'@nestjs/common': ^10.0.0 || ^11.0.0
'@nestjs/core': ^10.0.0 || ^11.0.0
'@nestjs/testing@11.1.5':
resolution: {integrity: sha512-ZYRYF750SefmuIo7ZqPlHDcin1OHh6My0OkOfGEFjrD9mJ0vMVIpwMTOOkpzCfCcpqUuxeHBuecpiIn+NLrQbQ==}
peerDependencies:
'@nestjs/common': ^11.0.0
'@nestjs/core': ^11.0.0
'@nestjs/microservices': ^11.0.0
'@nestjs/platform-express': ^11.0.0
peerDependenciesMeta:
'@nestjs/microservices':
optional: true
'@nestjs/platform-express':
optional: true
'@nestjs/testing@11.1.6':
resolution: {integrity: sha512-srYzzDNxGvVCe1j0SpTS9/ix75PKt6Sn6iMaH1rpJ6nj2g8vwNrhK0CoJJXvpCYgrnI+2WES2pprYnq8rAMYHA==}
peerDependencies:
@@ -12774,8 +12814,8 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici@7.10.0:
resolution: {integrity: sha512-u5otvFBOBZvmdjWLVW+5DAc9Nkq8f24g0O9oY7qw2JVIF1VocIFoyz9JFkuVOS2j41AufeO0xnlweJ2RLT8nGw==}
undici@7.13.0:
resolution: {integrity: sha512-l+zSMssRqrzDcb3fjMkjjLGmuiiK2pMIcV++mJaAc9vhjSGpvM7h43QgP+OAMb1GImHmbPyG2tBXeuyG5iY4gA==}
engines: {node: '>=20.18.1'}
unenv@2.0.0-rc.18:
@@ -16375,6 +16415,11 @@ snapshots:
'@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2)
cron: 4.3.0
'@nestjs/testing@11.1.5(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))':
dependencies:
'@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2)
tslib: 2.8.1
'@nestjs/testing@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2))':
dependencies:
'@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2)
@@ -23951,7 +23996,7 @@ snapshots:
glob-to-regexp: 0.4.1
sharp: 0.33.5
stoppable: 1.1.0
undici: 7.10.0
undici: 7.13.0
workerd: 1.20250803.0
ws: 8.18.0
youch: 4.1.0-beta.10
@@ -26165,7 +26210,7 @@ snapshots:
tinyexec: 1.0.1
tinyglobby: 0.2.14
ts-morph: 26.0.0
undici: 7.10.0
undici: 7.13.0
vue-metamorph: 3.3.3(eslint@9.33.0(jiti@2.5.1))
zod: 3.25.76
transitivePeerDependencies:
@@ -27026,7 +27071,7 @@ snapshots:
undici-types@6.21.0: {}
undici@7.10.0: {}
undici@7.13.0: {}
unenv@2.0.0-rc.18:
dependencies:
@@ -27597,7 +27642,7 @@ snapshots:
expect-type: 1.2.1
magic-string: 0.30.17
pathe: 2.0.3
picomatch: 4.0.2
picomatch: 4.0.3
std-env: 3.9.0
tinybench: 2.9.0
tinyexec: 0.3.2

View File

@@ -0,0 +1,14 @@
// Common component exports
export * from './accordion/index.js';
export * from './badge/index.js';
export * from './button/index.js';
export * from './dialog/index.js';
export * from './dropdown-menu/index.js';
export * from './loading/index.js';
export * from './popover/index.js';
export * from './scroll-area/index.js';
export * from './sheet/index.js';
export * from './stepper/index.js';
export * from './tabs/index.js';
export * from './toast/index.js';
export * from './tooltip/index.js';

View File

@@ -0,0 +1,8 @@
// Form component exports
export * from './combobox/index.js';
export * from './input/index.js';
export * from './label/index.js';
export * from './lightswitch/index.js';
export * from './number/index.js';
export * from './select/index.js';
export * from './switch/index.js';