import * as fs from 'node:fs'; import * as http from 'node:http'; import * as net from 'node:net'; import * as path from 'node:path'; import * as tls from 'node:tls'; import type { AddressInfo } from 'node:net'; import type { Duplex } from 'node:stream'; import type { ILoadedMatrixPackageEnvironment } from './package-environment.js'; import type { IResolvedRunnerTransportPlan } from './runner-transport.js'; import type { ILoadedMatrixWebappManifest } from './webapp-manifest.js'; import { jwtCredentialsFromCredentialsRef } from '@open-matrix/nats-auth'; export interface IStandaloneWebappServerOptions { readonly loadedEnvironment: ILoadedMatrixPackageEnvironment; readonly webapp: ILoadedMatrixWebappManifest; readonly runnerTransport: IResolvedRunnerTransportPlan; } export interface IStandaloneWebappServerHandle { readonly port: number; readonly baseUrl: string; readonly webapp: ILoadedMatrixWebappManifest; readonly bootstrapPath: '/api/bootstrap'; readonly natsWsPath: '/nats-ws'; shutdown(): Promise; } interface IMimeTypeMap { readonly [key: string]: string; } const MIME_TYPES: IMimeTypeMap = { '.css': 'text/css; charset=utf-8', '.html': 'text/html; charset=utf-8', '.ico': 'image/x-icon', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.map': 'application/json; charset=utf-8', '.png': 'image/png', '.svg': 'image/svg+xml', '.txt': 'text/plain; charset=utf-8', }; export async function startStandaloneWebappServer( options: IStandaloneWebappServerOptions, ): Promise { const httpConfig = options.loadedEnvironment.environment.http; if (httpConfig?.enabled !== true || typeof httpConfig.port !== 'number' || httpConfig.port <= 0) { throw new Error( `Package environment "${options.loadedEnvironment.envName}" must enable http.port for --serve mode`, ); } if (!fs.existsSync(options.webapp.distDir)) { throw new Error( `Built webapp dist directory not found for ${options.webapp.packageName}: ${options.webapp.distDir}`, ); } const entryPath = path.join(options.webapp.distDir, options.webapp.entryFile); if (!fs.existsSync(entryPath)) { throw new Error( `Built webapp entry file not found for ${options.webapp.packageName}: ${entryPath}`, ); } if (!options.runnerTransport.wsUrl) { throw new Error( `Runner transport for ${options.webapp.packageName} does not expose wsUrl required for /nats-ws proxy`, ); } const runtimeRoot = options.runnerTransport.root; const brandingDir = resolveBrandingDir(options.loadedEnvironment.packageDir); const server = http.createServer((req, res) => { void handleRequest(req, res, { entryPath, brandingDir, runtimeRoot, webapp: options.webapp, loadedEnvironment: options.loadedEnvironment, runnerTransport: options.runnerTransport, }).catch((error) => { res.statusCode = 500; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error), })); }); }); server.on('upgrade', (req, socket, head) => { if ((req.url ?? '').split('?')[0] !== '/nats-ws') { socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); socket.destroy(); return; } proxyWebSocketUpgrade(req, socket, head, options.runnerTransport.wsUrl!); }); await new Promise((resolve, reject) => { server.once('error', reject); server.listen(httpConfig.port, '127.0.0.1', () => { server.off('error', reject); resolve(); }); }); const address = server.address(); if (!address || typeof address !== 'object') { throw new Error(`Standalone webapp server for ${options.webapp.packageName} did not expose a TCP address`); } const port = (address as AddressInfo).port; return { port, baseUrl: `http://127.0.0.1:${port}`, webapp: options.webapp, bootstrapPath: '/api/bootstrap', natsWsPath: '/nats-ws', async shutdown(): Promise { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); }, }; } async function handleRequest( req: http.IncomingMessage, res: http.ServerResponse, options: { readonly entryPath: string; readonly brandingDir: string | null; readonly runtimeRoot: string; readonly webapp: ILoadedMatrixWebappManifest; readonly loadedEnvironment: ILoadedMatrixPackageEnvironment; readonly runnerTransport: IResolvedRunnerTransportPlan; }, ): Promise { const method = req.method ?? 'GET'; const requestUrl = new URL(req.url ?? '/', 'http://127.0.0.1'); const pathname = requestUrl.pathname; const isNatsAuthRequest = pathname === '/api/nats-jwt' && method === 'POST'; if (method !== 'GET' && method !== 'HEAD' && !isNatsAuthRequest) { res.statusCode = 405; res.setHeader('Allow', pathname === '/api/nats-jwt' ? 'GET, HEAD, POST' : 'GET, HEAD'); res.end(); return; } if (pathname === '/api/bootstrap') { const payload = buildBootstrapPayload(req, options.runtimeRoot, options.runnerTransport, options.loadedEnvironment); res.statusCode = 200; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); writeBody(res, method, JSON.stringify(payload)); return; } if (pathname === '/api/auth/me') { res.statusCode = 200; res.setHeader('Content-Type', 'application/json; charset=utf-8'); writeBody(res, method, JSON.stringify({ authenticated: true, principal: { id: 'local-runner', displayName: 'Local Runner', email: null, }, session: { sub: 'local-runner', email: null, exp: null, localClient: true, }, })); return; } if (pathname === '/api/config') { res.statusCode = 200; res.setHeader('Content-Type', 'application/json; charset=utf-8'); writeBody(res, method, JSON.stringify({ root: options.runtimeRoot, environment: options.loadedEnvironment.environment.name, transport: { mode: options.runnerTransport.mode, url: options.runnerTransport.url, wsUrl: options.runnerTransport.wsUrl ?? null, }, })); return; } if (pathname === '/api/nats-jwt') { res.statusCode = 200; res.setHeader('Content-Type', 'application/json; charset=utf-8'); writeBody(res, method, JSON.stringify(buildNatsAuthPayload( options.runnerTransport, options.loadedEnvironment.packageDir, ))); return; } if (pathname === '/healthz') { res.statusCode = 200; res.setHeader('Content-Type', 'application/json; charset=utf-8'); writeBody(res, method, JSON.stringify({ ok: true, ready: true, phase: 'ready', root: options.runtimeRoot, })); return; } if (pathname === '/nats-ws') { res.statusCode = 426; res.setHeader('Content-Type', 'text/plain; charset=utf-8'); writeBody(res, method, 'Upgrade Required'); return; } if (pathname === '/favicon.ico' || pathname === '/manifest.json' || pathname.startsWith('/branding/')) { const assetPath = resolveBrandingAssetPath(pathname, options.brandingDir); if (!assetPath) { res.statusCode = 404; res.end(); return; } await serveFile(res, method, assetPath); return; } const resolvedPath = resolveStaticAssetPath(pathname, options.webapp, options.entryPath); if (!resolvedPath) { res.statusCode = 404; res.end(); return; } await serveFile(res, method, resolvedPath); } function buildBootstrapPayload( req: http.IncomingMessage, runtimeRoot: string, runnerTransport: IResolvedRunnerTransportPlan, loadedEnvironment: ILoadedMatrixPackageEnvironment, ): Record { const hostHeader = Array.isArray(req.headers.host) ? req.headers.host[0] : req.headers.host; const host = hostHeader || `127.0.0.1:${loadedEnvironment.environment.http?.port ?? 0}`; const protocol = 'ws:'; const wsUrl = `${protocol}//${host}/nats-ws`; const hasCredentialsRef = typeof runnerTransport.credentialsRef === 'string' && runnerTransport.credentialsRef.trim().length > 0; const authorityContext = { assetHostRoot: runtimeRoot, platformServiceRoot: runtimeRoot, sessionAuthorityRoot: runtimeRoot, selectedAuthorityRoot: runtimeRoot, runtimeInstanceRoot: null, isAuthenticated: true, isPlatformAdmin: false, selectableAuthorityRoots: [runtimeRoot], }; return { authorityContext, natsWsUrl: wsUrl, root: runtimeRoot, authorityRoot: runtimeRoot, assetHostRoot: runtimeRoot, platformServiceRoot: runtimeRoot, sessionAuthorityRoot: runtimeRoot, selectedAuthorityRoot: runtimeRoot, runtimeInstanceRoot: null, selectableAuthorityRoots: [runtimeRoot], isPlatformAdmin: false, busRoot: runtimeRoot, platformRoot: runtimeRoot, role: 'client', addressRoot: runtimeRoot, provisioned: true, authorityReady: true, authenticated: true, principal: { principalId: 'local-runner', displayName: 'Local Runner', }, natsOwnership: { mode: runnerTransport.mode, source: 'package-environment:nats', }, federation: { connected: false, hubUrl: null, source: 'none', }, transportPlan: { protocolVersion: 1, kind: 'nats', wsMode: 'same-origin-proxy', wsPath: '/nats-ws', authMode: hasCredentialsRef ? 'jwt' : 'anonymous', authEndpoint: hasCredentialsRef ? '/api/nats-jwt' : null, busAuthMode: 'none', sessionMode: 'local-client', }, sources: { root: 'package-environment:runtime.root', authorityRoot: 'package-environment:runtime.root', assetHostRoot: 'package-environment:runtime.root', platformServiceRoot: 'package-environment:runtime.root', sessionAuthorityRoot: 'package-environment:runtime.root', selectedAuthorityRoot: 'package-environment:runtime.root', runtimeInstanceRoot: 'none', platformRoot: 'package-environment:runtime.root', addressRoot: 'package-environment:runtime.root', browserWsPath: 'transportPlan.wsPath', transportAuthMode: hasCredentialsRef ? 'credentials-ref' : 'standalone-runner', busToken: 'none', sessionIdentity: 'local-client', federation: 'none', }, }; } function buildNatsAuthPayload( runnerTransport: IResolvedRunnerTransportPlan, baseDir: string, ): Record { const credentials = jwtCredentialsFromCredentialsRef( runnerTransport.credentialsRef, baseDir, `matrix-browser-${process.pid}`, ); if (!credentials) { return { mode: 'anonymous' }; } return { mode: 'jwt', jwt: credentials.jwt, seed: credentials.seed, }; } function resolveStaticAssetPath( pathname: string, webapp: ILoadedMatrixWebappManifest, entryPath: string, ): string | null { const decodedPath = safeDecodePath(pathname); if (!decodedPath) { return null; } const appBasePath = `/apps/${webapp.appName}`; const cleanPath = decodedPath === appBasePath || decodedPath.startsWith(`${appBasePath}/`) ? decodedPath.slice(appBasePath.length) || '/' : decodedPath; if (cleanPath === '/') { return entryPath; } const relativePath = cleanPath.replace(/^\/+/, ''); const distRoot = path.resolve(webapp.distDir); const resolved = path.resolve(webapp.distDir, relativePath); if (resolved.startsWith(distRoot) && fs.existsSync(resolved) && fs.statSync(resolved).isFile()) { return resolved; } if (!path.extname(relativePath)) { return entryPath; } return null; } function resolveBrandingAssetPath(pathname: string, brandingDir: string | null): string | null { if (!brandingDir) { return null; } let assetPath: string; if (pathname === '/favicon.ico') { assetPath = path.join(brandingDir, 'favicon.ico'); } else if (pathname === '/manifest.json') { assetPath = path.join(brandingDir, 'manifest.json'); } else { const relativePath = safeDecodePath(pathname.slice('/branding/'.length)); if (!relativePath) { return null; } assetPath = path.join(brandingDir, relativePath); } const resolved = path.resolve(assetPath); if (!resolved.startsWith(path.resolve(brandingDir)) || !fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) { return null; } return resolved; } async function serveFile( res: http.ServerResponse, method: string, filePath: string, ): Promise { const stat = await fs.promises.stat(filePath); const ext = path.extname(filePath).toLowerCase(); res.statusCode = 200; res.setHeader('Content-Type', MIME_TYPES[ext] ?? 'application/octet-stream'); res.setHeader('Content-Length', String(stat.size)); res.setHeader('Cache-Control', 'no-cache'); if (method === 'HEAD') { res.end(); return; } const stream = fs.createReadStream(filePath); await new Promise((resolve, reject) => { stream.once('error', reject); res.once('error', reject); res.once('finish', resolve); stream.pipe(res); }); } function writeBody( res: http.ServerResponse, method: string, body: string, ): void { res.setHeader('Content-Length', Buffer.byteLength(body)); if (method === 'HEAD') { res.end(); return; } res.end(body); } function proxyWebSocketUpgrade( req: http.IncomingMessage, socket: Duplex, head: Buffer, targetOrigin: string, ): void { let target: URL; try { target = new URL(targetOrigin); } catch { socket.write('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); socket.destroy(); return; } if (!['ws:', 'wss:', 'http:', 'https:'].includes(target.protocol)) { socket.write('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); socket.destroy(); return; } const port = target.port ? Number(target.port) : (target.protocol === 'wss:' || target.protocol === 'https:' ? 443 : 80); const upstream: Duplex = (target.protocol === 'wss:' || target.protocol === 'https:' ? tls.connect(port, target.hostname, { servername: target.hostname }) : net.connect(port, target.hostname)); upstream.once('connect', () => { const forwardedPath = buildProxyPath(target, req.url); let rawRequest = `GET ${forwardedPath} HTTP/${req.httpVersion}\r\n`; rawRequest += `Host: ${target.host}\r\n`; for (const [key, value] of Object.entries(req.headers)) { const lowerKey = key.toLowerCase(); if (lowerKey === 'host' || lowerKey === 'origin' || value === undefined) { continue; } const values = Array.isArray(value) ? value : [value]; for (const headerValue of values) { rawRequest += `${key}: ${headerValue}\r\n`; } } rawRequest += '\r\n'; upstream.write(rawRequest); if (head.length > 0) { upstream.write(head); } socket.pipe(upstream); upstream.pipe(socket); }); upstream.once('error', () => { socket.destroy(); }); socket.once('error', () => { upstream.destroy(); }); } function buildProxyPath(target: URL, rawUrl: string | undefined): string { const incoming = new URL(rawUrl ?? '/', 'http://matrix.local'); const basePath = target.pathname === '/' ? '' : target.pathname.replace(/\/$/, ''); if (basePath.length > 0) { return `${basePath}${incoming.search}`; } return `${incoming.pathname}${incoming.search}`; } function resolveBrandingDir(packageDir: string): string | null { const brandingDir = path.join(packageDir, 'branding'); return fs.existsSync(path.join(brandingDir, 'manifest.json')) ? brandingDir : null; } function safeDecodePath(pathname: string): string | null { try { const decoded = decodeURIComponent(pathname); if (decoded.includes('\0')) { return null; } return decoded; } catch { return null; } }