#!/usr/bin/env node /* * Copyright 2025 the original author or authors. *

* Licensed under the Moderne Source Available License (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *

* https://docs.moderne.io/licensing/moderne-source-available-license *

* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import * as rpc from "vscode-jsonrpc/node"; import {RewriteRpc} from "./rewrite-rpc"; import {chunkedJsonDecoder} from "./message-decoder"; import {chunkedJsonEncoder} from "./message-encoder"; import * as fs from "fs"; import {Command} from 'commander'; import {dir} from 'tmp-promise'; import {DependencyWorkspace} from "../javascript/dependency-workspace"; // Include all languages you want this server to support. import "../text"; import "../json"; import "../yaml"; import "../java"; import "../javascript"; // Not possible to set the stack size when executing from npx for security reasons require('v8').setFlagsFromString('--stack-size=8000'); function initPyroscope(logger: rpc.Logger): any { // Strip trailing slashes: the SDK builds the ingest URL as `${serverAddress}/ingest`, // so a trailing slash produces `//ingest`, which the server normalizes via redirect — // and undici downgrades the redirected POST to a GET, silently dropping all profiles. const server = (process.env.PYROSCOPE_SERVER_ADDRESS || '').replace(/\/+$/, ''); if (!server) { return undefined; } let Pyroscope: any; try { Pyroscope = require('@pyroscope/nodejs'); } catch { logger.warn('PYROSCOPE_SERVER_ADDRESS set but @pyroscope/nodejs not installed; profiling disabled'); return undefined; } const tags: Record = {runtime: 'node'}; for (const pair of (process.env.PYROSCOPE_TAGS || '').split(',')) { const eq = pair.indexOf('='); if (eq > 0) { tags[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim(); } } Pyroscope.init({ appName: process.env.PYROSCOPE_APPLICATION_NAME || 'modcli', serverAddress: server, tags, wall: { collectCpuTime: true, }, }); Pyroscope.start(); return Pyroscope; } interface ProgramOptions { logFile?: string; metricsCsv?: string; traceRpcMessages?: boolean; batchSize?: number; recipeInstallDir?: string; profile?: boolean; } async function main() { const program = new Command(); program .option('--port ', 'port number') .option('--log-file ', 'log file path') .option('--metrics-csv ', 'metrics CSV output path') .option('--trace-rpc-messages', 'trace RPC messages at the protocol level') .option('--batch-size [size]', 'sets the batch size (default is 200)', s => parseInt(s, 10), 1000) .option('--recipe-install-dir ', 'Recipe installation directory (default is a temporary directory)') .parse(); const options = program.opts() as ProgramOptions; let recipeCleanup: (() => Promise) | undefined; let recipeInstallDir: string; if (!options.recipeInstallDir) { const {path, cleanup} = await dir({unsafeCleanup: true}); recipeCleanup = cleanup; recipeInstallDir = path; } else { recipeInstallDir = options.recipeInstallDir; } // Single graceful-shutdown path used by SIGINT, SIGTERM, and connection // close. The connection-close path is what catches parent SIGKILL: when // the host JVM is killed, our stdin closes, vscode-jsonrpc fires onClose, // and we exit. Without it, Pyroscope (or any other ref-holder) can keep // the event loop alive and orphan this process. let pyroscope: any; let shuttingDown = false; const shutdown = async () => { if (shuttingDown) return; shuttingDown = true; try { if (pyroscope) { try { await pyroscope.stop(); } catch (e: any) { // best-effort flush; nothing to do if it fails during shutdown } } if (recipeCleanup) { await recipeCleanup(); } DependencyWorkspace.cleanupOldWorkspaces(); } finally { process.exit(0); } }; process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); const log = options.logFile ? fs.createWriteStream(options.logFile, {flags: 'a'}) : undefined; const logger: rpc.Logger = { error: (msg: string) => log && log.write(`[js error] ${msg}\n`), warn: (msg: string) => log && log.write(`[js warn] ${msg}\n`), info: (msg: string) => log && log.write(`[js info] ${msg}\n`), // The RPC Tracer configured below itself writes to this "log" level for every message it sends or receives, // because the Tracer type has a log method on it that matches this signature. log: (msg: string) => log && options.traceRpcMessages && log.write(`[js trace] ${msg}\n`) }; pyroscope = initPyroscope(logger); // Create the connection with the custom logger const connection = rpc.createMessageConnection( // Parse incoming messages straight from their bytes: a large inbound message (e.g. a tree // carrying RecipesThatMadeChanges markers, whose recipe descriptors repeat per changed file) // overflows V8's ~512 MB string limit in the default `JSON.parse(buffer.toString())` decoder, // which drops the message and hangs the caller until it times out. new rpc.StreamMessageReader(process.stdin, {contentTypeDecoder: chunkedJsonDecoder}), // Serialize outgoing messages without materializing the whole document as one JS string: // a large PrepareRecipe response (a deep recipe tree) overflows the same limit in the // default `Buffer.from(JSON.stringify(msg))` encoder. new rpc.StreamMessageWriter(process.stdout, {contentTypeEncoder: chunkedJsonEncoder}), logger ); if (options.traceRpcMessages) { await connection.trace(rpc.Trace.Verbose, logger).catch((err: Error) => { // Handle any unexpected errors during trace configuration logger.error(`Failed to set trace: ${err}`); }); } else { await connection.trace(rpc.Trace.Off, {} as rpc.Tracer); } connection.onError(err => { logger.error(`error: ${err}`); }); connection.onClose(() => { logger.info(`connection closed`); void shutdown(); }) connection.onDispose(() => { logger.info(`connection disposed`); }); // log uncaught exceptions process.on('uncaughtException', (error) => { logger.error('Fatal error:' + error.message); process.exit(8); }); new RewriteRpc(connection, { batchSize: options.batchSize, logger: logger, metricsCsv: options.metricsCsv, recipeInstallDir: recipeInstallDir }); } main().catch(console.error);