Files
2026-10-22 17:07:08 +08:00

180 lines
7.8 KiB
TypeScript

import {createServer} from 'node:http';
import type {Server as HttpServer} from 'node:http';
import type {Socket as NetSocket} from 'node:net';
import {Server} from 'socket.io';
import type {ControlTransport, ForwardServerOptions} from './types.ts';
import {resolve_options} from './utils/relay_config.ts';
import {create_session_store} from './runtime/session_store.ts';
import type {RelaySessionStore} from './runtime/session_store.ts';
import {install_relay_routes} from './runtime/relay_routes.ts';
import {install_control_routes, report_status} from './runtime/init.ts';
import {mount_http} from './runtime/http_mount.ts';
export interface ForwardServerHandle {
readonly io: Server;
readonly http_server: HttpServer;
readonly store: RelaySessionStore;
readonly forward_id: number;
readonly path: string;
readonly namespace: string;
readonly owns_http_server: boolean;
bind_control(transport: ControlTransport): () => void;
stop(): Promise<void>;
}
let current: ForwardServerHandle | null = null;
let initialization: Promise<ForwardServerHandle> | null = null;
let startup_options: ReturnType<typeof resolve_options> | null = null;
let stopping: Promise<void> | null = null;
export async function start_forward_server(input: ForwardServerOptions): Promise<ForwardServerHandle> {
const options = resolve_options(input);
if (stopping) await stopping;
if (startup_options) {
const same = startup_options.forward_id === options.forward_id && startup_options.key === options.key
&& startup_options.http_server === options.http_server && startup_options.path === options.path
&& startup_options.namespace === options.namespace && startup_options.port === options.port
&& startup_options.host === options.host
&& JSON.stringify(startup_options.allowed_origins) === JSON.stringify(options.allowed_origins)
&& startup_options.allow_no_origin === options.allow_no_origin
&& JSON.stringify(startup_options.limits) === JSON.stringify(options.limits);
if (!same) throw new Error('Relay already initialized with different options; stop it before reconfiguration');
}
if (current) return current;
if (initialization) return initialization;
startup_options = options;
initialization = create_relay(options);
try {
current = await initialization;
return current;
} catch (error) {
startup_options = null;
throw error;
} finally { initialization = null; }
}
async function create_relay(options: ReturnType<typeof resolve_options>): Promise<ForwardServerHandle> {
const owns_http_server = !options.http_server;
const host = options.http_server ?? createServer((_request, response) => {
response.statusCode = 404;
response.end();
});
// Socket.IO owns only this private server in borrowed mode. Its close()
// therefore cannot close the Main HTTP server or unrelated Socket.IO services.
const transport_server = owns_http_server ? host : createServer();
const owned_connections = new Set<NetSocket>();
const track_connection = (socket: NetSocket) => {
owned_connections.add(socket);
socket.once('close', () => owned_connections.delete(socket));
};
if (owns_http_server) host.on('connection', track_connection);
let control: ControlTransport | undefined;
let dispose_control = () => {};
let unmount = () => {};
const io = new Server(transport_server, {
path: options.path,
serveClient: false,
maxHttpBufferSize: options.limits.max_payload_bytes,
connectTimeout: options.limits.auth_timeout_ms,
cors: {origin: options.allowed_origins, credentials: false},
allowRequest: (request, callback) => {
const origin = request.headers.origin;
const permitted = origin === undefined ? options.allow_no_origin : options.allowed_origins.includes(origin);
callback(null, permitted && io.engine.clientsCount < options.limits.max_connections);
},
});
const store = create_session_store({
forward_id: options.forward_id,
key: options.key,
limits: options.limits,
on_status: status => {
try { report_status(control, status); }
catch { options.logger?.log('Unable to report relay session status'); }
},
});
store.set_control_available(false);
const namespace = io.of(options.namespace);
if (options.namespace !== '/') io.of('/').use((_socket, next) => next(new Error('UNAUTHORIZED')));
const dispose_routes = install_relay_routes(namespace, store);
let stop_promise: Promise<void> | null = null;
const handle: ForwardServerHandle = {
io, http_server: host, store, forward_id: options.forward_id,
path: options.path, namespace: options.namespace, owns_http_server,
bind_control(transport) {
if (stop_promise) throw new Error('Relay is stopped');
dispose_control();
control = transport;
const dispose = install_control_routes(transport, store, options.logger);
dispose_control = dispose;
return () => {
if (dispose_control !== dispose) return;
dispose();
dispose_control = () => {};
control = undefined;
};
},
stop() {
if (stop_promise) return stop_promise;
stop_promise = (async () => {
host.off('close', host_closed);
store.close();
dispose_routes();
dispose_control();
control = undefined;
unmount();
let deadline: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
io.close(),
new Promise<never>((_resolve, reject) => {
deadline = setTimeout(() => {
for (const socket of owned_connections) socket.destroy();
reject(new Error('Relay shutdown timed out'));
}, options.limits.close_timeout_ms);
}),
]);
} finally {
clearTimeout(deadline);
host.off('connection', track_connection);
for (const socket of owned_connections) socket.destroy();
owned_connections.clear();
}
})().finally(() => {
if (current === handle) current = null;
startup_options = null;
stopping = null;
});
stopping = stop_promise;
return stop_promise;
},
};
const host_closed = () => { void handle.stop().catch(() => options.logger?.log('Relay shutdown failed')); };
try {
if (options.control) handle.bind_control(options.control);
if (owns_http_server) {
await new Promise<void>((resolve, reject) => {
const on_error = (error: Error) => { host.off('listening', on_listening); reject(error); };
const on_listening = () => { host.off('error', on_error); resolve(); };
host.once('error', on_error);
host.once('listening', on_listening);
host.listen(options.port, options.host);
});
} else unmount = mount_http(host, options.path, transport_server);
host.once('close', host_closed);
return handle;
} catch (error) {
await handle.stop();
throw error;
}
}
export async function stop_forward_server(): Promise<void> {
if (initialization) {
try { await initialization; } catch { return; }
}
if (current) await current.stop();
else if (stopping) await stopping;
}
export type * from './types.ts';