523 lines
16 KiB
TypeScript
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;
|
|
}
|
|
}
|