523 lines
16 KiB
TypeScript

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<void>;
}
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<IStandaloneWebappServerHandle> {
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<void>((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<void> {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
},
};
}
async function handleRequest(
req: http.IncomingMessage,
res: http.ServerResponse<http.IncomingMessage>,
options: {
readonly entryPath: string;
readonly brandingDir: string | null;
readonly runtimeRoot: string;
readonly webapp: ILoadedMatrixWebappManifest;
readonly loadedEnvironment: ILoadedMatrixPackageEnvironment;
readonly runnerTransport: IResolvedRunnerTransportPlan;
},
): Promise<void> {
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<string, unknown> {
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<string, unknown> {
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<http.IncomingMessage>,
method: string,
filePath: string,
): Promise<void> {
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<void>((resolve, reject) => {
stream.once('error', reject);
res.once('error', reject);
res.once('finish', resolve);
stream.pipe(res);
});
}
function writeBody(
res: http.ServerResponse<http.IncomingMessage>,
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;
}
}