diff --git a/package-lock.json b/package-lock.json index a1da082bcea..12e1fffa162 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52072,7 +52072,6 @@ "express": "^4.21.1", "express-http-proxy": "^2.0.0", "is-ip": "^5.0.1", - "lodash": "^4.17.21", "mocha": "^10.2.0", "mongodb": "^6.19.0", "mongodb-build-info": "^1.7.2", @@ -65007,7 +65006,6 @@ "express": "^4.21.1", "express-http-proxy": "^2.0.0", "is-ip": "^5.0.1", - "lodash": "^4.17.21", "mocha": "^10.2.0", "mongodb": "^6.19.0", "mongodb-build-info": "^1.7.2", diff --git a/package.json b/package.json index 6c84e549a17..7b79e730573 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "package-compass": "npm run package-compass --workspace=mongodb-compass --", "package-compass-debug": "npm run package-compass-debug --workspace=mongodb-compass --", "package-compass-nocompile": "npm run package-compass-nocompile --workspace=mongodb-compass --", - "start": "npm run start --workspace=mongodb-compass", + "start": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/start.mts", "start-web": "npm run start --workspace=@mongodb-js/compass-web", "test": "lerna run test --concurrency 1 --stream", "test-changed": "lerna run test --stream --concurrency 1 --since origin/HEAD", diff --git a/packages/compass-web/package.json b/packages/compass-web/package.json index 20d24d44a46..34a801cfa74 100644 --- a/packages/compass-web/package.json +++ b/packages/compass-web/package.json @@ -49,7 +49,7 @@ "start": "electron ./scripts/electron-proxy.js", "analyze": "npm run webpack -- --mode production --analyze", "watch": "npm run webpack -- --mode development --watch", - "sync": "node scripts/sync-dist-to-mms.js", + "sync": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/sync-dist-to-mms.mts", "typecheck": "tsc -p tsconfig.json --noEmit", "eslint": "eslint-compass", "prettier": "prettier-compass", @@ -127,7 +127,6 @@ "express": "^4.21.1", "express-http-proxy": "^2.0.0", "is-ip": "^5.0.1", - "lodash": "^4.17.21", "mocha": "^10.2.0", "mongodb": "^6.19.0", "mongodb-build-info": "^1.7.2", diff --git a/packages/compass-web/scripts/sync-dist-to-mms.js b/packages/compass-web/scripts/sync-dist-to-mms.js deleted file mode 100644 index 87ca17a4137..00000000000 --- a/packages/compass-web/scripts/sync-dist-to-mms.js +++ /dev/null @@ -1,102 +0,0 @@ -'use strict'; -const fs = require('fs'); -const path = require('path'); -const child_process = require('child_process'); -const os = require('os'); -const util = require('util'); -const { debounce } = require('lodash'); - -if (!process.env.MMS_HOME) { - throw new Error( - 'Missing required environment variable $MMS_HOME. Make sure you finished the "Cloud Developer Setup" process' - ); -} - -const srcDir = path.resolve(__dirname, '..', 'dist'); - -const destDir = path.dirname( - child_process.execFileSync( - 'node', - ['-e', "console.log(require.resolve('@mongodb-js/compass-web'))"], - { cwd: process.env.MMS_HOME, encoding: 'utf-8' } - ) -); - -const tmpDir = path.join( - os.tmpdir(), - `mongodb-js--compass-web-${Date.now().toString(36)}` -); - -fs.mkdirSync(srcDir, { recursive: true }); - -// Create a copy of current dist that will be overriden by link, we'll restore -// it when we are done -fs.mkdirSync(tmpDir, { recursive: true }); -fs.cpSync(destDir, tmpDir, { recursive: true }); - -const copyDist = debounce( - function () { - fs.cpSync(srcDir, destDir, { recursive: true }); - }, - 1_000, - { - leading: true, - trailing: true, - } -); - -// The existing approach of using `npm / pnpm link` commands doesn't play well -// with webpack that will start to resolve other modules relative to the imports -// from compass-web inevitably causing some modules to resolve from the compass -// monorepo instead of mms one. To work around that we are just watching for any -// file changes in the dist folder and copying them as-is to whatever place -// compass-web was installed in mms node_modules -const distWatcher = fs.watch(srcDir, function () { - copyDist(); -}); - -const webpackWatchProcess = child_process.spawn('npm', ['run', 'watch'], { - stdio: 'inherit', -}); - -const failProofRunner = () => - new (class FailProofRunner extends Array { - append(...fns) { - this.push(...fns); - return this; - } - - run() { - const errors = this.map((f) => { - try { - f(); - } catch (e) { - return e; - } - }).filter((e) => e); - - if (errors.length) { - fs.writeSync( - process.stdout.fd, - util.inspect(errors, { depth: 20 }) + '\n' - ); - } - - return errors.length; - } - })(); - -function cleanup(signalName) { - const errorCount = failProofRunner() - .append(() => distWatcher.close()) - .append(() => webpackWatchProcess.kill(signalName)) - .append(() => fs.cpSync(tmpDir, destDir, { recursive: true })) - .append(() => fs.rmSync(tmpDir, { recursive: true, force: true })) - .run(); - fs.writeSync(process.stdout.fd, 'Exit compass-web sync...\n'); - process.exit(errorCount); -} - -for (const evt of ['SIGINT', 'SIGTERM']) { - process.on(evt, cleanup); -} diff --git a/packages/compass-web/scripts/sync-dist-to-mms.mts b/packages/compass-web/scripts/sync-dist-to-mms.mts new file mode 100644 index 00000000000..1aeeadf7c01 --- /dev/null +++ b/packages/compass-web/scripts/sync-dist-to-mms.mts @@ -0,0 +1,184 @@ +import process from 'node:process'; +import fs, { promises as asyncFs } from 'node:fs'; +import path from 'node:path'; +import child_process from 'node:child_process'; +import os from 'node:os'; +import util from 'node:util'; +import timers from 'node:timers/promises'; + +if (!process.env.MMS_HOME) { + throw new Error( + 'Missing required environment variable $MMS_HOME. Make sure you finished the "Cloud Developer Setup" process' + ); +} + +// Set up early signal handling and cleanup +let devServer: child_process.ChildProcess | undefined; +let distWatcher: fs.FSWatcher | undefined; +let webpackWatchProcess: child_process.ChildProcess | undefined; + +const tmpDir = path.join( + os.tmpdir(), + `mongodb-js--compass-web-${Date.now().toString(36)}` +); +const srcDir = path.resolve(import.meta.dirname, '..', 'dist'); +const destDir = path.dirname( + child_process.execFileSync( + process.execPath, + ['-e', "console.log(require.resolve('@mongodb-js/compass-web'))"], + { cwd: process.env.MMS_HOME, encoding: 'utf-8' } + ) +); + +fs.mkdirSync(srcDir, { recursive: true }); +// Create a copy of current dist that will be overridden by link, we'll restore +// it when we are done +fs.mkdirSync(tmpDir, { recursive: true }); +fs.cpSync(destDir, tmpDir, { recursive: true }); + +const failProofRunner = () => + new (class FailProofRunner extends Array { + append(...fns: any[]) { + this.push(...fns); + return this; + } + + run() { + const errors = this.map((f) => { + try { + f(); + } catch (e) { + return e; + } + }).filter((e) => e); + + if (errors.length) { + fs.writeSync( + process.stdout.fd, + util.inspect(errors, { depth: 20 }) + '\n' + ); + } + + return errors.length; + } + })(); + +function cleanup(signalName: NodeJS.Signals): void { + console.log(`\nReceived ${signalName}, cleaning up...`); + const errorCount = failProofRunner() + .append(() => distWatcher?.close()) + .append(() => webpackWatchProcess?.kill(signalName)) + .append(() => devServer?.kill(signalName)) + .append(() => fs.cpSync(tmpDir, destDir, { recursive: true })) + .append(() => fs.rmSync(tmpDir, { recursive: true, force: true })) + .run(); + fs.writeSync(process.stdout.fd, 'Exit compass-web sync...\n'); + process.exit(errorCount); +} + +// Set up signal handlers immediately +process.on('SIGINT', () => cleanup('SIGINT')); +process.on('SIGTERM', () => cleanup('SIGTERM')); + +async function isDevServerRunning( + port: number, + host: string = '127.0.0.1' +): Promise { + try { + return ( + await fetch(`http://${host}:${port}`, { + method: 'HEAD', + signal: AbortSignal.timeout(3000), + }) + ).ok; + } catch (error) { + return false; + } +} + +if (!(await isDevServerRunning(8081))) { + console.log('mms dev server is not running... launching!'); + + const { engines } = JSON.parse( + await asyncFs.readFile( + path.join(process.env.MMS_HOME, 'package.json'), + 'utf8' + ) + ); + const pnpmVersion = engines.pnpm ?? 'latest'; + + const halfRamMb = Math.min( + Math.floor(os.totalmem() / 2 / 1024 / 1024), + 16384 + ); + // Merge with existing NODE_OPTIONS if present + const existingNodeOptions = process.env.NODE_OPTIONS ?? ''; + const mergedNodeOptions = [ + `--max_old_space_size=${halfRamMb}`, + existingNodeOptions, + ] + .filter(Boolean) + .join(' '); + + devServer = child_process.spawn( + 'npx', + [`pnpm@${pnpmVersion}`, 'run', 'start'], + { + cwd: process.env.MMS_HOME, + env: { + ...process.env, + NODE_OPTIONS: mergedNodeOptions, + npm_config_engine_strict: `${false}`, + }, + stdio: 'inherit', + } + ); + + // Wait for dev server to be ready before proceeding + console.log('Waiting for dev server to start...'); + let retries = 30; // 30 seconds max + while (retries > 0 && !(await isDevServerRunning(8081))) { + await timers.setTimeout(1000); + retries--; + } + + if (retries === 0) { + console.warn('Dev server may not be fully ready, proceeding anyway...'); + } else { + console.log('Dev server is ready!'); + } +} else { + console.log('Skipping running MMS dev server...'); +} + +let oneSec: Promise | null = null; +let pendingCopy = false; + +async function copyDist(): Promise { + // If a copy is already in progress, mark that we need another copy + if (oneSec) { + pendingCopy = true; + return; + } + // Keep copying until there are no more pending requests + do { + pendingCopy = false; + fs.cpSync(srcDir, destDir, { recursive: true }); + oneSec = timers.setTimeout(1000); + await oneSec; + } while (pendingCopy); + + oneSec = null; +} + +// The existing approach of using `npm / pnpm link` commands doesn't play well +// with webpack that will start to resolve other modules relative to the imports +// from compass-web inevitably causing some modules to resolve from the compass +// monorepo instead of mms one. To work around that we are just watching for any +// file changes in the dist folder and copying them as-is to whatever place +// compass-web was installed in mms node_modules +distWatcher = fs.watch(srcDir, () => void copyDist()); + +webpackWatchProcess = child_process.spawn('npm', ['run', 'watch'], { + stdio: 'inherit', +}); diff --git a/scripts/start.md b/scripts/start.md new file mode 100644 index 00000000000..cdaa42a171b --- /dev/null +++ b/scripts/start.md @@ -0,0 +1,32 @@ +# Start Script Help + +Usage: start.mts [-h/--help] [targets... [targetOptions...]] + +## Options + +- `-h, --help` Show this help message + +## Targets + +- `desktop` Start MongoDB Compass Desktop (default if no targets specified) +- `sandbox` Start MongoDB Compass Web Sandbox, useful for UI-only changes +- `sync` Start Cloud Sync, in combination with redirector/redwood can be used to test data explorer changes + - `--no-mms` can be passed to the sync subcommand to not run MMS's dev server. + +**Note:** `sandbox` must be run alone and cannot be combined with other targets. + +## Port Configuration + +The `desktop` and `sandbox` targets both use the same webpack dev server port (4242) by design. When running both targets simultaneously, the sandbox will automatically detect that the desktop target's webpack dev server is already running and will skip starting its own, sharing the same webpack dev server instance. + +Since `sync` must run alone, there are no port conflicts with other targets. + +### Well-Known Ports + +| Port | Service | Target | Description | +| ---- | ---------------------- | -------------------- | ----------------------------------------------- | +| 4242 | Webpack Dev Server | `desktop`, `sandbox` | Serves compiled frontend assets with hot reload | +| 7777 | HTTP Proxy Server | `sandbox` | Express proxy server for Atlas API requests | +| 1337 | WebSocket Proxy Server | `sandbox` | WebSocket proxy for MongoDB connections | +| 8080 | Atlas Local Backend | `sync` | Local MMS backend server (when MMS_HOME is set) | +| 8081 | MMS Dev Server | `sync` | Local MMS development server | diff --git a/scripts/start.mts b/scripts/start.mts new file mode 100644 index 00000000000..e5b323ce077 --- /dev/null +++ b/scripts/start.mts @@ -0,0 +1,268 @@ +import process from 'node:process'; +import child_process from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import timers from 'node:timers/promises'; +import { pipeline } from 'node:stream/promises'; +import { Transform } from 'node:stream'; + +// Get raw arguments after the script name, skipping node and script path +const args = process.argv.slice(2); + +// Check if help flag appears before any target (making it "our" help) +const validTargets = ['desktop', 'sandbox', 'sync']; +const firstTargetIndex = args.findIndex((arg) => validTargets.includes(arg)); +const helpIndex = args.findIndex((arg) => arg === '-h' || arg === '--help'); + +const isOurHelp = + helpIndex !== -1 && (firstTargetIndex === -1 || helpIndex < firstTargetIndex); + +if (isOurHelp) { + console.log( + await fs.readFile(path.join(import.meta.dirname, 'start.md'), 'utf8') + ); + process.exit(0); +} + +// Parse positional arguments and group them by target +const targets = { + desktop: { enabled: false, args: [] as string[] }, + sandbox: { enabled: false, args: [] as string[] }, + sync: { enabled: false, args: [] as string[] }, +}; + +let currentTarget: keyof typeof targets | null = null; + +for (const arg of args) { + if (validTargets.includes(arg as keyof typeof targets)) { + // Switch to new target + currentTarget = arg as keyof typeof targets; + targets[currentTarget].enabled = true; + } else if (currentTarget) { + // Add argument to current target's args (including help flags destined for targets) + targets[currentTarget].args.push(arg); + } + // If arg is not a valid target and no current target, ignore it +} + +// Check for mutually exclusive targets +if ( + targets.sandbox.enabled && + (targets.desktop.enabled || targets.sync.enabled) +) { + console.error('Error: sandbox target must be run alone.'); + console.error( + 'Please run sandbox by itself, not combined with other targets.' + ); + process.exit(1); +} + +// If no targets specified, default to desktop +if ( + !targets.desktop.enabled && + !targets.sandbox.enabled && + !targets.sync.enabled +) { + targets.desktop.enabled = true; +} + +// Check if we need prefixing (more than one target enabled) +const enabledTargets = Object.values(targets).filter((t) => t.enabled); +const usePrefixing = enabledTargets.length > 1; +const startPrefix = usePrefixing ? `start | ` : ``; + +const subProcesses: child_process.ChildProcess[] = []; +let isCleaningUp = false; + +async function cleanup(signal: NodeJS.Signals) { + if (isCleaningUp) return; // Prevent multiple cleanup calls + isCleaningUp = true; + + console.log(`\n${startPrefix}received ${signal}, terminating processes...`); + + // Kill all processes immediately with the received signal + for (const p of subProcesses) { + if (p.pid && p.exitCode === null) { + try { + process.kill(-p.pid, signal); // Kill entire process group + } catch { + p.kill(signal); // Fallback to killing just the process + } + } + } + + // Wait a shorter time for graceful shutdown + await timers.setTimeout(10_000); + + // Force kill any remaining processes + const stillRunning = subProcesses.filter((p) => p.exitCode === null); + if (stillRunning.length > 0) { + console.log( + `${startPrefix}force killing ${stillRunning.length} remaining processes...` + ); + for (const p of stillRunning) { + if (p.pid) { + try { + process.kill(-p.pid, 'SIGKILL'); // Kill entire process group + } catch { + p.kill('SIGKILL'); // Fallback + } + } + } + } + + console.log(`${startPrefix}done.`); + process.exit(signal === 'SIGTERM' ? 0 : 1); +} + +// Handle signals properly even when child processes inherit stdio +process.on('SIGINT', () => cleanup('SIGINT')); +process.on('SIGTERM', () => cleanup('SIGTERM')); + +// Ensure we exit cleanly on uncaught exceptions +process.on('uncaughtException', (err) => { + console.error('Uncaught exception:', err); + cleanup('SIGTERM'); +}); + +process.on('unhandledRejection', (err) => { + console.error('Unhandled rejection:', err); + cleanup('SIGTERM'); +}); + +// Helper function to create a transform stream that prefixes lines +function createPrefixTransform(prefix: string) { + let buffer = ''; + return new Transform({ + transform(chunk, _, callback) { + buffer += chunk.toString(); + const lines = buffer.split('\n'); + // Keep the last line in buffer (might be incomplete) + buffer = lines.pop() || ''; + // Process complete lines + for (const line of lines) { + this.push(`${prefix} | ${line}\n`); + } + callback(); + }, + + flush(callback) { + // Process any remaining data in buffer + if (buffer) { + this.push(`${prefix} | ${buffer}\n`); + } + callback(); + }, + }); +} + +// Helper function to spawn a target process +function spawnTarget( + command: string, + workspace: string, + args: string[], + targetName: string +) { + // Only set color-forcing env vars if user hasn't set color preferences + const colorEnv: Record = {}; + if ( + !process.env.NO_COLOR && + !process.env.FORCE_COLOR && + !process.env.CLICOLOR_FORCE + ) { + colorEnv.FORCE_COLOR = '1'; + colorEnv.CLICOLOR_FORCE = '1'; + } + + // Desktop needs full terminal control for shortcuts, others need piped output for prefixing + const stdio: child_process.StdioOptions = + targetName === 'desktop' && !usePrefixing + ? 'inherit' + : ['inherit', 'pipe', 'pipe']; + + const spawnArgs: Parameters = [ + 'npm', + [ + 'run', + command, + `--workspace=${workspace}`, + ...(args.length ? ['--', ...args] : []), + ], + { + stdio, + env: { ...process.env, ...colorEnv }, + // Create a new process group so we can kill the entire tree + detached: true, + }, + ]; + + const paddedName = targetName.padEnd(8); + console.log(`${startPrefix}${spawnArgs[0]} ${spawnArgs[1].join(' ')}`); + const subProcess = child_process.spawn(...spawnArgs); + + // Only set up output piping if we're not using full inheritance + if (stdio !== 'inherit') { + // Set up stdout pipeline with error handling + if (subProcess.stdout) { + subProcess.stdout.setEncoding('utf-8'); + if (usePrefixing) { + pipeline( + subProcess.stdout, + createPrefixTransform(paddedName), + process.stdout, + { end: false } + ).catch((err) => + console.error(`start | stdout pipeline error:`, err) + ); + } else { + pipeline(subProcess.stdout, process.stdout, { end: false }).catch( + (err) => console.error(`start | stdout pipeline error:`, err) + ); + } + } + + // Set up stderr pipeline with error handling + if (subProcess.stderr) { + subProcess.stderr.setEncoding('utf-8'); + if (usePrefixing) { + pipeline( + subProcess.stderr, + createPrefixTransform(paddedName), + process.stderr, + { end: false } + ).catch((err) => + console.error(`${startPrefix}stderr pipeline error:`, err) + ); + } else { + pipeline(subProcess.stderr, process.stderr, { end: false }).catch( + (err) => console.error(`${startPrefix}stderr pipeline error:`, err) + ); + } + } + } + + return subProcess; +} + +if (targets.desktop.enabled) { + subProcesses.push( + spawnTarget('start', 'mongodb-compass', targets.desktop.args, 'desktop') + ); +} + +if (targets.sync.enabled) { + subProcesses.push( + spawnTarget('sync', '@mongodb-js/compass-web', targets.sync.args, 'sync') + ); +} + +if (targets.sandbox.enabled) { + subProcesses.push( + spawnTarget( + 'start', + '@mongodb-js/compass-web', + targets.sandbox.args, + 'sandbox' + ) + ); +}