import express from 'express'; import type { Express } from 'express'; import { childLogger, loggerTest, type LogDataPretty, type Logger } from "@foxxmd/logging"; import bodyParser from 'body-parser'; import { stripIndents } from "common-tags"; import session from 'express-session'; import type { PassThrough } from "node:stream"; import passport from 'passport'; import path from "path"; import qs from 'qs'; import ViteExpress from "vite-express"; import { getRoot } from "../ioc.ts"; import { parseBool } from "../utils.ts"; import { getAddress } from "../utils/NetworkUtils.ts"; import { setupApi } from "./api.ts"; import type ScrobbleSources from '../sources/ScrobbleSources.ts'; import type ScrobbleClients from '../scrobblers/ScrobbleClients.ts'; import { qsOptions } from '../../core/Atomic.ts'; import { projectRootDir } from "../common/infrastructure/Atomic.ts"; import { createTypedRouter } from "@minisylar/express-typed-router"; export interface ServerArgs { sources: ScrobbleSources clients: ScrobbleClients } export interface ServerOptions { logger?: Logger appLoggerStream?: PassThrough initialOutput?: LogDataPretty[] testMode?: boolean } export const initServer = async (args: ServerArgs, opts: ServerOptions = {}): Promise<[Express, ReturnType]> => { const app = express(); app.set('query parser', (str: string) => qs.parse(str, qsOptions)); const { sources, clients } = args; const { logger: parentLogger = loggerTest, } = opts; const logger = childLogger(parentLogger, 'API'); const router = createTypedRouter().onValidationFailure((info) => { const {req, ...rest} = info; logger.warn(rest, 'Request failed validation'); }); try { const root = getRoot(); // pathname the app is mounted under (e.g. '/myapp', or '/' at root) // decided at runtime from BASE_URL, not baked in at build time. See // vite.config.ts (relative asset base) and src/client/App.tsx (router mode). const local = root.get('localUrl'); const localDefined = root.get('hasDefinedBaseUrl'); const basePath = localDefined && local.pathname !== '/' ? local.pathname : '/'; // mount API routes under the same prefix the frontend's relative // `api/...` fetches resolve against app.use(basePath, router.getRouter()); app.use(bodyParser.json()); app.use( bodyParser.urlencoded({ extended: true, }) ); //app.use(express.static(buildDir)); router.use(session({secret: 'keyboard cat', resave: false, saveUninitialized: false})); app.use(session({secret: 'keyboard cat', resave: false, saveUninitialized: false})); app.use(passport.initialize()); app.use(passport.session()); if(root.get('disableWeb')) { logger.warn('API and Dashboard have been DISABLED. Note that any ingress sources (Webscrobbler, Listenbrainz/Lastfm Endpoint Sources, etc...) will be unusable'); // app is returned but never listens return [app, router]; } setupApi({app, router, scrobbleSources: sources, scrobbleClients: clients}, {...opts, logger}); if(opts.testMode === true) { return [app, router]; } const isProd = root.get('isProd'); const port = root.get('port'); const addy = getAddress(); const addresses: string[] = []; let dockerHint = ''; if (parseBool(process.env.IS_DOCKER) && addy.v4 !== undefined && addy.v4.includes('172')) { dockerHint = stripIndents` --- HINT --- MS is likely being run in a container with BRIDGE networking which means the above addresses are not accessible from outside this container. To ensure the container is accessible make sure you have mapped the *container* port ${port} to a *host* port. https://docs.multi-scrobbler.app/installation/?dockerSetting=networking#recommended-settings The container will then be accessible at http://HOST_MACHINE_IP:HOST_PORT${localDefined ? ` (or ${local} since you defined this!)` : ''} --- HINT --- `; } for (const [k, v] of Object.entries(addy)) { if (v !== undefined) { switch (k) { case 'host': case 'v4': addresses.push(`---> ${k === 'host' ? 'Local'.padEnd(14, ' ') : 'Network'.padEnd(14, ' ')} http://${v}:${port}`); break; case 'v6': addresses.push(`---> Network (IPv6) http://[${v}]:${port}`); } } } app.use(path.resolve(basePath, 'docs'), express.static(path.resolve(projectRootDir, `./docsite/build`))); const viteExpressOptions: Parameters[0] = { mode: isProd ? 'production' : 'development', inlineViteConfig: { base: basePath }, // The build's asset/API references are relative (see vite.config.ts // and the client's api/... calls); a relative URL resolves against // the *current document path* so a nested // client route (e.g. /components/3) would otherwise break them. // pins resolution to the actual mount root for every // route depth. window.__MS_RUNTIME__ tells react-router the same // mount path so it can set `basename` -- neither can be a // build-time constant since the same build serves every // deployment (root or subpath). transformer: (html) => { const baseHref = basePath === '/' ? '/' : `${basePath}/`; return html.replace( '', `` ); } }; if(!isProd) { const conf = await ViteExpress.getViteConfig(); viteExpressOptions.inlineViteConfig = { ...viteExpressOptions.inlineViteConfig, ...conf, // conf.base is the frontend's relative asset base ('./'), not // the Express mount prefix -- keep our runtime-computed one base: basePath }; } ViteExpress.config(viteExpressOptions); try { ViteExpress.listen(app, port, () => { const start = stripIndents`\n Server started: ${addresses.join('\n')}${dockerHint !== '' ? `\n${dockerHint}` : ''}` logger.info(start); if(localDefined) { logger.info(`User-defined base URL for UI and redirect URLs (spotify, deezer, lastfm): ${local}`) } }).on('error', (err) => { throw new Error('Server encountered unrecoverable error', {cause: err}); }); return [app, router]; } catch (e) { throw new Error('Server encountered unrecoverable error', {cause: e}); } } catch (e) { throw new Error('Server crashed with uncaught exception', {cause: e}); } } type ViteConfig = { root: string; base: string; build: { outDir: string; }; server?: { hmr?: boolean; }; };