/* eslint-disable unicorn/no-process-exit */ import { existsSync } from 'fs'; import type { WriteStream } from 'tty'; import type { IComponentsManagerBuilderOptions } from 'componentsjs'; import { ComponentsManager } from 'componentsjs'; import { readJSON } from 'fs-extra'; import yargs from 'yargs'; import { LOG_LEVELS } from '../logging/LogLevel'; import { getLoggerFor } from '../logging/LogUtil'; import { createErrorMessage, isError } from '../util/errors/ErrorUtil'; import { InternalServerError } from '../util/errors/InternalServerError'; import { joinFilePath, resolveAssetPath, resolveModulePath } from '../util/PathUtil'; import type { App } from './App'; import type { CliExtractor } from './cli/CliExtractor'; import type { CliResolver } from './CliResolver'; import { listSingleThreadedComponents } from './cluster/SingleThreaded'; import type { ShorthandResolver } from './variables/ShorthandResolver'; import type { CliArgv, Shorthand, VariableBindings } from './variables/Types'; const DEFAULT_CONFIG = resolveModulePath('config/default.json'); const DEFAULT_CLI_RESOLVER = 'urn:solid-server-app-setup:default:CliResolver'; const DEFAULT_APP = 'urn:solid-server:default:App'; const CORE_CLI_PARAMETERS = { config: { type: 'array', alias: 'c', default: [ DEFAULT_CONFIG ], requiresArg: true }, loggingLevel: { type: 'string', alias: 'l', default: 'info', requiresArg: true, choices: LOG_LEVELS }, mainModulePath: { type: 'string', alias: 'm', requiresArg: true }, } as const; const ENV_VAR_PREFIX = 'CSS'; /** * Parameters that can be used to instantiate the server through code. */ export interface AppRunnerInput { /** * Properties that will be used when building the Components.js manager. * Sets `typeChecking` to false by default as the server components will result in errors otherwise. */ loaderProperties: IComponentsManagerBuilderOptions; /** * Path to the server config file(s). */ config: string | string[]; /** * Values to apply to the Components.js variables. * These are the variables CLI values will be converted to. * The keys should be the variable URIs. * E.g.: `{ 'urn:solid-server:default:variable:rootFilePath': '.data' }`. */ variableBindings?: VariableBindings; /** * CLI argument names and their corresponding values. * E.g.: `{ rootFilePath: '.data' }`. * Abbreviated parameter names can not be used, so `{ f: '.data' }` would not work. * * In case both `shorthand` and `variableBindings` have entries that would result in a value for the same variable, * `variableBindings` has priority. */ shorthand?: Shorthand; /** * An array containing CLI arguments passed to start the process. * Entries here have the lowest priority for assigning values to variables. */ argv?: string[]; } /** * A class that can be used to instantiate and start a server based on a Component.js configuration. */ export class AppRunner { private readonly logger = getLoggerFor(this); /** * Starts the server with a given config. * * @param input - All values necessary to configure the server. */ public async run(input: AppRunnerInput): Promise { const app = await this.create(input); await app.start(); } /** * Returns an App object, created with the given config, that can start and stop the Solid server. * * @param input - All values necessary to configure the server. */ public async create(input: AppRunnerInput): Promise { const loaderProperties = { typeChecking: false, ...input.loaderProperties, }; // Potentially expand file paths as needed const configs = (Array.isArray(input.config) ? input.config : [ input.config ]).map(resolveAssetPath); let componentsManager: ComponentsManager; try { componentsManager = await this.createComponentsManager(loaderProperties, configs); } catch (error: unknown) { this.resolveError(`Could not build the config files from ${configs.join(',')}`, error); } const cliResolver = await this.createCliResolver(componentsManager as ComponentsManager); let extracted: Shorthand = {}; if (input.argv) { extracted = await this.extractShorthand(cliResolver.cliExtractor, input.argv); } const parsedVariables = await this.resolveShorthand(cliResolver.shorthandResolver, { ...extracted, ...input.shorthand, }); // Create the application using the translated variable values. // `variableBindings` override those resolved from the `shorthand` input. return this.createApp(componentsManager as ComponentsManager, { ...parsedVariables, ...input.variableBindings }); } /** * Starts the server as a command-line application. * Will exit the process on failure. * * Made non-async to lower the risk of unhandled promise rejections. * This is only relevant when this is used to start as a Node.js application on its own, * if you use this as part of your code you probably want to use the async version. * * @param argv - Input parameters. * @param argv.argv - Command line arguments. * @param argv.stderr - Stream that should be used to output errors before the logger is enabled. */ public runCliSync({ argv, stderr = process.stderr }: { argv?: CliArgv; stderr?: WriteStream }): void { this.runCli(argv).catch((error): never => { stderr.write(createErrorMessage(error)); process.exit(1); }); } /** * Starts the server as a command-line application. * * @param argv - Command line arguments. */ public async runCli(argv?: CliArgv): Promise { const app = await this.createCli(argv); try { await app.start(); } catch (error: unknown) { this.logger.error(`Could not start the server: ${createErrorMessage(error)}`); this.resolveError('Could not start the server', error); } } /** * Returns an App object, created by parsing the Command line arguments, that can start and stop the Solid server. * Will exit the process on failure. * * @param argv - Command line arguments. */ public async createCli(argv: CliArgv = process.argv): Promise { // Parse only the core CLI arguments needed to load the configuration let yargv = yargs(argv.slice(2)) .usage('node ./bin/server.js [args]') .options(CORE_CLI_PARAMETERS) // We disable help here as it would only show the core parameters .help(false) // We also read from environment variables .env(ENV_VAR_PREFIX); const settings = await this.getPackageSettings(); if (typeof settings !== 'undefined') { yargv = yargv.default(settings); } const params = await yargv.parse(); const loaderProperties: IComponentsManagerBuilderOptions = { mainModulePath: resolveAssetPath(params.mainModulePath), logLevel: params.loggingLevel, }; return this.create({ loaderProperties, config: params.config as string[], argv, shorthand: settings, }); } /** * Retrieves settings from package.json or configuration file when * part of an npm project. * @returns The settings defined in the configuration file */ public async getPackageSettings(): Promise> { // Only try and retrieve config file settings if there is a package.json in the // scope of the current directory const packageJsonPath = joinFilePath(process.cwd(), 'package.json'); if (!existsSync(packageJsonPath)) { return; } // First see if there is a dedicated .json configuration file const cssConfigPath = joinFilePath(process.cwd(), '.community-solid-server.config.json'); if (existsSync(cssConfigPath)) { return readJSON(cssConfigPath) as Promise>; } // Next see if there is a dedicated .js file const cssConfigPathJs = joinFilePath(process.cwd(), '.community-solid-server.config.js'); if (existsSync(cssConfigPathJs)) { return import(cssConfigPathJs) as Promise>; } // Finally try and read from the config.community-solid-server // field in the root package.json const pkg = await readJSON(packageJsonPath) as { config?: Record }; if (typeof pkg.config?.['community-solid-server'] === 'object') { return pkg.config['community-solid-server'] as Record; } } /** * Creates the Components Manager that will be used for instantiating. */ public async createComponentsManager( loaderProperties: IComponentsManagerBuilderOptions, configs: string[], ): Promise> { const componentsManager = await ComponentsManager.build(loaderProperties); for (const config of configs) { await componentsManager.configRegistry.register(config); } return componentsManager; } /** * Instantiates the {@link CliResolver}. */ private async createCliResolver(componentsManager: ComponentsManager): Promise { try { // Create a CliResolver, which combines a CliExtractor and a VariableResolver return await componentsManager.instantiate(DEFAULT_CLI_RESOLVER, {}); } catch (error: unknown) { this.resolveError(`Could not create the CLI resolver`, error); } } /** * Uses the {@link CliExtractor} to convert the CLI args to a {@link Shorthand} object. */ private async extractShorthand(cliExtractor: CliExtractor, argv: CliArgv): Promise { try { // Convert CLI args to CLI bindings return await cliExtractor.handleSafe(argv); } catch (error: unknown) { this.resolveError(`Could not parse the CLI parameters`, error); } } /** * Uses the {@link ShorthandResolver} to convert {@link Shorthand} to {@link VariableBindings} . */ private async resolveShorthand(shorthandResolver: ShorthandResolver, shorthand: Shorthand): Promise { try { // Convert CLI bindings into variable bindings return await shorthandResolver.handleSafe(shorthand); } catch (error: unknown) { this.resolveError(`Could not resolve the shorthand values`, error); } } /** * The second Components.js instantiation, * where the App is created and started using the variable mappings. */ private async createApp(componentsManager: ComponentsManager, variables: Record): Promise { let app: App; // Create the app try { app = await componentsManager.instantiate(DEFAULT_APP, { variables }); } catch (error: unknown) { this.resolveError(`Could not create the server`, error); } // Ensure thread safety if (!app.clusterManager.isSingleThreaded()) { const violatingClasses = await listSingleThreadedComponents(componentsManager); if (violatingClasses.length > 0) { const verb = violatingClasses.length > 1 ? 'are' : 'is'; const detailedError = new InternalServerError( `[${violatingClasses.join(', ')}] ${verb} not threadsafe and should not be run in multithreaded setups!`, ); this.resolveError('Cannot run a singlethreaded-only component in a multithreaded setup!', detailedError); } } return app; } /** * Throws a new error that provides additional information through the extra message. * Also appends the stack trace to the message. * This is needed for errors that are thrown before the logger is created as we can't log those the standard way. */ private resolveError(message: string, error: unknown): never { const errorMessage = `${message}\n${isError(error) ? error.stack : createErrorMessage(error)}\n`; throw new Error(errorMessage); } }