diff --git a/docs/custom-node.md b/docs/custom-node.md new file mode 100644 index 00000000..36575db8 --- /dev/null +++ b/docs/custom-node.md @@ -0,0 +1,115 @@ +# Custom Node.js Build + +## Motivation + +VibeTunnel uses Node.js Single Executable Applications (SEA) to create a standalone terminal server. However, the standard Node.js binary is quite large: + +- **Standard Node.js binary**: ~110MB +- **Custom minimal Node.js**: ~43MB (61% reduction) +- **Final executable size**: ~45MB (down from ~105MB) +- **Final app size impact**: Reduces app from ~130MB to ~88MB + +We don't need many Node.js features for VibeTunnel: +- No internationalization (ICU) support needed +- No npm package manager in the binary +- No inspector/debugging protocol +- No V8 snapshots or code cache + +By building a custom Node.js without these features, we achieve a significantly smaller app bundle while maintaining full functionality. + +## Build Behavior + +### Debug Mode (Xcode) +- Uses system Node.js for faster iteration +- No custom Node.js compilation required +- Build output shows: `"Debug build - using system Node.js for faster builds"` +- If a custom Node.js was previously built, it will be reused for consistency + +### Release Mode (Xcode) +- Automatically builds custom minimal Node.js on first run +- Compilation takes 10-20 minutes but is cached for future builds +- Uses the custom Node.js to create a smaller executable +- Build output shows version and size comparison + +## Build Automation + +### Release Builds +The release script (`mac/scripts/release.sh`) automatically checks for and builds custom Node.js if needed. You don't need to manually build it before releases. + +### Manual Custom Node.js Build + +To build the custom Node.js manually (outside of Xcode): + +```bash +cd web +node build-custom-node.js --latest +``` + +This will: +1. Download the latest Node.js source +2. Configure it without unnecessary features +3. Build with optimizations (`-Os`, `-flto`, etc.) +4. Cache the result in `web/.node-builds/` + +To use the custom Node.js for building the executable: + +```bash +cd web +npm run build -- --custom-node +``` + +Or directly: + +```bash +node build-native.js --custom-node +``` + +## Build Process Details + +### Automatic Detection +The build system automatically searches for custom Node.js builds in `.node-builds/` when `--custom-node` is passed without a path. It finds the most recent build by checking directory modification times. + +### Code Signing on macOS +When building the executable: +1. The Node.js binary is injected with our JavaScript code (SEA process) +2. The binary is stripped to remove debug symbols +3. The executable is re-signed with an ad-hoc signature + +Note: You may see a warning about "invalidating the code signature" during the strip process - this is expected and harmless since we re-sign immediately after. + +## Technical Details + +### Features Disabled +- `--without-intl` - Removes internationalization support +- `--without-npm` - Excludes npm from the binary +- `--without-corepack` - Removes package manager wrapper +- `--without-inspector` - Disables debugging protocol +- `--without-node-snapshot` - Skips V8 snapshot (~2-3MB) +- `--without-node-code-cache` - Skips code cache (~1-2MB) + +### Optimization Flags +- `-Os` - Optimize for size +- `-flto` - Link-time optimization +- `-ffunction-sections` / `-fdata-sections` - Enable dead code elimination +- `-Wl,-dead_strip` - Remove unused code at link time + +### Build Cache +Custom Node.js builds are stored in `web/.node-builds/` and are excluded from git via `.gitignore`. The build system automatically detects and reuses existing builds. + +## File Locations + +- Build script: `web/build-custom-node.js` +- Native executable builder: `web/build-native.js` +- Xcode integration: `mac/scripts/build-web-frontend.sh` +- Build output: `web/.node-builds/node-v*-minimal/` +- Final executable: `web/native/vibetunnel` + +## Troubleshooting + +### Custom Node.js not detected +- Ensure the build completed successfully: check for `.node-builds/node-v*-minimal/out/Release/node` +- In Debug mode, the system will use custom Node.js if already built +- In Release mode, it will build custom Node.js automatically if not present + +### Code signature warnings +The warning "changes being made to the file will invalidate the code signature" is expected and handled automatically. The build process re-signs the executable after all modifications. \ No newline at end of file diff --git a/mac/VibeTunnel.xcodeproj/project.pbxproj b/mac/VibeTunnel.xcodeproj/project.pbxproj index 505c354b..a45b181e 100644 --- a/mac/VibeTunnel.xcodeproj/project.pbxproj +++ b/mac/VibeTunnel.xcodeproj/project.pbxproj @@ -251,7 +251,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/zsh; - shellScript = "# Build web frontend conditionally based on hash\necho \"Checking if web frontend needs rebuild...\"\n\n# Run the conditional build script\n\"${SRCROOT}/scripts/build-web-frontend-conditional.sh\"\n\nif [ $? -ne 0 ]; then\n echo \"error: Web frontend build failed\"\n exit 1\nfi\n"; + shellScript = "# Build web frontend conditionally based on hash\necho \"Checking if web frontend needs rebuild...\"\n\n# Run the conditional build script\n\"${SRCROOT}/scripts/build-web-frontend.sh\"\n\nif [ $? -ne 0 ]; then\n echo \"error: Web frontend build failed\"\n exit 1\nfi\n"; }; C3D4E5F6A7B8C9D0E1F23456 /* Install Build Dependencies */ = { isa = PBXShellScriptBuildPhase; @@ -270,7 +270,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/zsh; - shellScript = "# Install build dependencies\necho \"Checking build dependencies...\"\n\n# Run the install script\n\"${SRCROOT}/scripts/install-bun.sh\"\n\nif [ $? -ne 0 ]; then\n echo \"error: Failed to install build dependencies\"\n exit 1\nfi\n"; + shellScript = "# Check for Node.js availability\necho \"Checking build dependencies...\"\n\n# Run the install script\n\"${SRCROOT}/scripts/install-node.sh\"\n\nif [ $? -ne 0 ]; then\n echo \"error: Node.js is required to build VibeTunnel\"\n exit 1\nfi\n"; }; /* End PBXShellScriptBuildPhase section */ diff --git a/mac/scripts/build-bun-executable.sh b/mac/scripts/build-bun-executable.sh deleted file mode 100755 index de749c00..00000000 --- a/mac/scripts/build-bun-executable.sh +++ /dev/null @@ -1,152 +0,0 @@ -#!/bin/bash -# -# Build and copy Node.js SEA executable and native modules to the app bundle -# ARM64 only - VibeTunnel requires Apple Silicon -# - -set -euo pipefail - -# Add common Node.js installation paths to PATH -# Homebrew on Apple Silicon -if [ -d "/opt/homebrew/bin" ]; then - export PATH="/opt/homebrew/bin:$PATH" -fi - -# Homebrew on Intel Macs -if [ -d "/usr/local/bin" ]; then - export PATH="/usr/local/bin:$PATH" -fi - -# NVM default location -if [ -s "$HOME/.nvm/nvm.sh" ]; then - export NVM_DIR="$HOME/.nvm" - . "$NVM_DIR/nvm.sh" -fi - -# Node Version Manager (n) -if [ -d "/usr/local/n/versions" ]; then - export PATH="/usr/local/bin:$PATH" -fi - -# MacPorts -if [ -d "/opt/local/bin" ]; then - export PATH="/opt/local/bin:$PATH" -fi - -# Export CI environment variable to prevent interactive prompts -export CI=true - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -# Script directory -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -PROJECT_ROOT="$SCRIPT_DIR/../.." -WEB_DIR="$PROJECT_ROOT/web" -NATIVE_DIR="$WEB_DIR/native" - -# Destination from Xcode (passed as argument or use BUILT_PRODUCTS_DIR) -if [ $# -eq 0 ]; then - if [ -z "${BUILT_PRODUCTS_DIR:-}" ]; then - echo -e "${RED}Error: No destination path provided and BUILT_PRODUCTS_DIR not set${NC}" - exit 1 - fi - DEST_RESOURCES="${BUILT_PRODUCTS_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}" -else - DEST_RESOURCES="$1" -fi - -echo -e "${GREEN}Building and copying Node.js SEA executable (ARM64 only)...${NC}" - -# Change to web directory -cd "$WEB_DIR" - -# Check if native directory exists, if not use prebuilts -if [ ! -d "$NATIVE_DIR" ] || [ ! -f "$NATIVE_DIR/vibetunnel" ]; then - echo -e "${YELLOW}Native directory not found. Checking for prebuilt binaries...${NC}" - - # Use prebuilt binaries if available - PREBUILTS_DIR="$PROJECT_ROOT/mac/Resources/BunPrebuilts" - ARCH=$(uname -m) - - if [ "$ARCH" != "arm64" ]; then - echo -e "${RED}Error: VibeTunnel requires Apple Silicon (ARM64)${NC}" - exit 1 - fi - - if [ -d "$PREBUILTS_DIR/arm64" ] && [ -f "$PREBUILTS_DIR/arm64/vibetunnel" ]; then - echo -e "${GREEN}Using prebuilt binaries for ARM64${NC}" - mkdir -p "$NATIVE_DIR" - cp "$PREBUILTS_DIR/arm64/vibetunnel" "$NATIVE_DIR/" - cp "$PREBUILTS_DIR/arm64/pty.node" "$NATIVE_DIR/" - cp "$PREBUILTS_DIR/arm64/spawn-helper" "$NATIVE_DIR/" - chmod +x "$NATIVE_DIR/vibetunnel" - chmod +x "$NATIVE_DIR/spawn-helper" - else - # Try to build with Node.js - echo -e "${YELLOW}Prebuilt binaries not found. Attempting to build...${NC}" - - # Check if build-native.js exists - if [ -f "build-native.js" ]; then - # Use Node.js to build - if command -v node &> /dev/null; then - echo "Using Node.js to build SEA executable..." - echo "Using Node.js version: $(node --version)" - echo "PATH: $PATH" - node build-native.js - else - echo -e "${RED}Error: Node.js not found in PATH${NC}" - echo -e "${RED}PATH is: $PATH${NC}" - echo -e "${RED}Please install Node.js 20+ or ensure prebuilt binaries are available in:${NC}" - echo -e "${RED} $PREBUILTS_DIR/arm64/${NC}" - exit 1 - fi - else - echo -e "${RED}Error: build-native.js not found and no prebuilt binaries available${NC}" - exit 1 - fi - fi -fi - -# Verify native files exist -if [ ! -f "$NATIVE_DIR/vibetunnel" ]; then - echo -e "${RED}Error: Executable not found at $NATIVE_DIR/vibetunnel${NC}" - exit 1 -fi - -# Copy executable -echo "Copying executable to app bundle..." -cp "$NATIVE_DIR/vibetunnel" "$DEST_RESOURCES/" -chmod +x "$DEST_RESOURCES/vibetunnel" - -# Copy native modules -if [ -f "$NATIVE_DIR/pty.node" ]; then - echo "Copying pty.node..." - cp "$NATIVE_DIR/pty.node" "$DEST_RESOURCES/" -else - echo -e "${RED}Error: pty.node not found${NC}" - exit 1 -fi - -if [ -f "$NATIVE_DIR/spawn-helper" ]; then - echo "Copying spawn-helper..." - cp "$NATIVE_DIR/spawn-helper" "$DEST_RESOURCES/" - chmod +x "$DEST_RESOURCES/spawn-helper" -else - echo -e "${RED}Error: spawn-helper not found${NC}" - exit 1 -fi - -echo -e "${GREEN}✓ Executable and native modules copied successfully${NC}" - -# Verify the files -echo "Verifying copied files:" -ls -la "$DEST_RESOURCES/vibetunnel" || echo "vibetunnel not found!" -ls -la "$DEST_RESOURCES/pty.node" || echo "pty.node not found!" -ls -la "$DEST_RESOURCES/spawn-helper" || echo "spawn-helper not found!" - -echo "" -echo -e "${GREEN}Note: VibeTunnel requires Apple Silicon (M1/M2/M3) Macs.${NC}" \ No newline at end of file diff --git a/mac/scripts/build-web-frontend-conditional.sh b/mac/scripts/build-web-frontend-conditional.sh deleted file mode 100755 index 05d6b480..00000000 --- a/mac/scripts/build-web-frontend-conditional.sh +++ /dev/null @@ -1,151 +0,0 @@ -#!/bin/zsh -set -e # Exit on any error - -# Get the project directory -if [ -z "${SRCROOT}" ]; then - # If SRCROOT is not set (running outside Xcode), determine it from script location - SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - PROJECT_DIR="$(dirname "$SCRIPT_DIR")" -else - PROJECT_DIR="${SRCROOT}" -fi - -WEB_DIR="${PROJECT_DIR}/../web" -HASH_FILE="${BUILT_PRODUCTS_DIR}/.web-content-hash" -PREVIOUS_HASH_FILE="${BUILT_PRODUCTS_DIR}/.web-content-hash.previous" -PUBLIC_DIR="${WEB_DIR}/public" - -# Set destination directory -if [ -z "${BUILT_PRODUCTS_DIR}" ]; then - # Default for testing outside Xcode - DEST_DIR="/tmp/vibetunnel-web-build" -else - DEST_DIR="${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Resources/web/public" -fi - -APP_RESOURCES="${BUILT_PRODUCTS_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}" - -# Read the current hash -if [ -f "${HASH_FILE}" ]; then - CURRENT_HASH=$(cat "${HASH_FILE}") -else - echo "error: Hash file not found. Run 'Calculate Web Hash' build phase first." - exit 1 -fi - -# Check if we need to rebuild -NEED_REBUILD=1 - -# Check if previous hash exists and matches current -if [ -f "${PREVIOUS_HASH_FILE}" ]; then - PREVIOUS_HASH=$(cat "${PREVIOUS_HASH_FILE}") - if [ "${CURRENT_HASH}" = "${PREVIOUS_HASH}" ]; then - # Also check if the built files actually exist - if [ -d "${DEST_DIR}" ] && [ -f "${APP_RESOURCES}/vibetunnel" ] && [ -f "${APP_RESOURCES}/pty.node" ] && [ -f "${APP_RESOURCES}/spawn-helper" ]; then - echo "Web content unchanged and build outputs exist. Skipping rebuild." - NEED_REBUILD=0 - else - echo "Web content unchanged but build outputs missing. Rebuilding..." - fi - else - echo "Web content changed. Hash: ${PREVIOUS_HASH} -> ${CURRENT_HASH}" - fi -else - echo "No previous build hash found. Building web frontend..." -fi - -if [ ${NEED_REBUILD} -eq 0 ]; then - echo "Skipping web frontend build (no changes detected)" - exit 0 -fi - -echo "Building web frontend..." - -# Setup PATH for Node.js -export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" - -# Load NVM if available -if [ -s "$HOME/.nvm/nvm.sh" ]; then - export NVM_DIR="$HOME/.nvm" - . "$NVM_DIR/nvm.sh" -fi - -# Put volta on the path if it exists -export PATH="$HOME/.volta/bin:$PATH" - -# Trigger rehash -rehash - -# Export CI to prevent interactive prompts -export CI=true - -# Check if npm is available -if ! command -v npm &> /dev/null; then - echo "error: npm not found. Please install Node.js" - exit 1 -fi - -echo "Using npm version: $(npm --version)" -echo "Using Node.js version: $(node --version)" - -# Check if web directory exists -if [ ! -d "${WEB_DIR}" ]; then - echo "error: Web directory not found at ${WEB_DIR}" - exit 1 -fi - -# Change to web directory -cd "${WEB_DIR}" - -# Clean build artifacts -echo "Cleaning build artifacts..." -rm -rf dist public/bundle public/output.css - -# Install dependencies -echo "Installing dependencies..." -npm install - -# Build the web frontend -echo "Building web frontend..." -npm run build - -# Clean and create destination directory -echo "Cleaning destination directory..." -rm -rf "${DEST_DIR}" -mkdir -p "${DEST_DIR}" - -# Copy built files to Resources -echo "Copying web files to app bundle..." -cp -R "${PUBLIC_DIR}/"* "${DEST_DIR}/" - -# Copy native executable and modules to app bundle root -echo "Copying native executable and modules..." -NATIVE_DIR="${WEB_DIR}/native" - -if [ -f "${NATIVE_DIR}/vibetunnel" ]; then - cp "${NATIVE_DIR}/vibetunnel" "${APP_RESOURCES}/" - chmod +x "${APP_RESOURCES}/vibetunnel" -else - echo "error: vibetunnel executable not found" - exit 1 -fi - -if [ -f "${NATIVE_DIR}/pty.node" ]; then - cp "${NATIVE_DIR}/pty.node" "${APP_RESOURCES}/" -else - echo "error: pty.node not found" - exit 1 -fi - -if [ -f "${NATIVE_DIR}/spawn-helper" ]; then - cp "${NATIVE_DIR}/spawn-helper" "${APP_RESOURCES}/" - chmod +x "${APP_RESOURCES}/spawn-helper" -else - echo "error: spawn-helper not found" - exit 1 -fi - -# Save the current hash as the previous hash for next build -cp "${HASH_FILE}" "${PREVIOUS_HASH_FILE}" - -echo "Web frontend build completed successfully" diff --git a/mac/scripts/build-web-frontend.sh b/mac/scripts/build-web-frontend.sh index f45901d4..fbd30647 100755 --- a/mac/scripts/build-web-frontend.sh +++ b/mac/scripts/build-web-frontend.sh @@ -1,8 +1,6 @@ #!/bin/zsh set -e # Exit on any error -echo "Building web frontend..." - # Get the project directory if [ -z "${SRCROOT}" ]; then # If SRCROOT is not set (running outside Xcode), determine it from script location @@ -13,6 +11,8 @@ else fi WEB_DIR="${PROJECT_DIR}/../web" +HASH_FILE="${BUILT_PRODUCTS_DIR}/.web-content-hash" +PREVIOUS_HASH_FILE="${BUILT_PRODUCTS_DIR}/.web-content-hash.previous" PUBLIC_DIR="${WEB_DIR}/public" # Set destination directory @@ -23,6 +23,44 @@ else DEST_DIR="${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Resources/web/public" fi +APP_RESOURCES="${BUILT_PRODUCTS_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}" + +# Read the current hash +if [ -f "${HASH_FILE}" ]; then + CURRENT_HASH=$(cat "${HASH_FILE}") +else + echo "error: Hash file not found. Run 'Calculate Web Hash' build phase first." + exit 1 +fi + +# Check if we need to rebuild +NEED_REBUILD=1 + +# Check if previous hash exists and matches current +if [ -f "${PREVIOUS_HASH_FILE}" ]; then + PREVIOUS_HASH=$(cat "${PREVIOUS_HASH_FILE}") + if [ "${CURRENT_HASH}" = "${PREVIOUS_HASH}" ]; then + # Also check if the built files actually exist + if [ -d "${DEST_DIR}" ] && [ -f "${APP_RESOURCES}/vibetunnel" ] && [ -f "${APP_RESOURCES}/pty.node" ] && [ -f "${APP_RESOURCES}/spawn-helper" ]; then + echo "Web content unchanged and build outputs exist. Skipping rebuild." + NEED_REBUILD=0 + else + echo "Web content unchanged but build outputs missing. Rebuilding..." + fi + else + echo "Web content changed. Hash: ${PREVIOUS_HASH} -> ${CURRENT_HASH}" + fi +else + echo "No previous build hash found. Building web frontend..." +fi + +if [ ${NEED_REBUILD} -eq 0 ]; then + echo "Skipping web frontend build (no changes detected)" + exit 0 +fi + +echo "Building web frontend..." + # Setup PATH for Node.js export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" @@ -55,15 +93,56 @@ cd "${WEB_DIR}" # Clean build artifacts echo "Cleaning build artifacts..." -rm -rf dist public/bundle public/output.css +rm -rf dist public/bundle public/output.css native # Install dependencies echo "Installing dependencies..." npm install +# Determine build configuration +BUILD_CONFIG="${CONFIGURATION:-Debug}" +echo "Build configuration: $BUILD_CONFIG" + +# Check for custom Node.js build +CUSTOM_NODE_PATH=$(find "${WEB_DIR}/.node-builds" -name "node-v*-minimal" -type d 2>/dev/null | sort -V | tail -n1)/out/Release/node + # Build the web frontend -echo "Building web frontend..." -npm run build +if [ "$BUILD_CONFIG" = "Release" ]; then + echo "Release build - checking for custom Node.js..." + + if [ ! -f "$CUSTOM_NODE_PATH" ]; then + echo "Custom Node.js not found, building it for optimal size..." + echo "This will take 10-20 minutes on first run but will be cached." + node build-custom-node.js --latest + CUSTOM_NODE_PATH=$(find "${WEB_DIR}/.node-builds" -name "node-v*-minimal" -type d 2>/dev/null | sort -V | tail -n1)/out/Release/node + fi + + if [ -f "$CUSTOM_NODE_PATH" ]; then + CUSTOM_NODE_VERSION=$("$CUSTOM_NODE_PATH" --version 2>/dev/null || echo "unknown") + CUSTOM_NODE_SIZE=$(ls -lh "$CUSTOM_NODE_PATH" 2>/dev/null | awk '{print $5}' || echo "unknown") + echo "Using custom Node.js for release build:" + echo " Version: $CUSTOM_NODE_VERSION" + echo " Size: $CUSTOM_NODE_SIZE (vs ~110MB for standard Node.js)" + echo " Path: $CUSTOM_NODE_PATH" + npm run build -- --custom-node + else + echo "WARNING: Custom Node.js build failed, using system Node.js" + echo "The app will be larger than optimal." + npm run build + fi +else + # Debug build + if [ -f "$CUSTOM_NODE_PATH" ]; then + CUSTOM_NODE_VERSION=$("$CUSTOM_NODE_PATH" --version 2>/dev/null || echo "unknown") + echo "Debug build - found existing custom Node.js $CUSTOM_NODE_VERSION, using it for consistency" + npm run build -- --custom-node + else + echo "Debug build - using system Node.js for faster builds" + echo "System Node.js: $(node --version)" + echo "To use custom Node.js in debug builds, run: cd web && node build-custom-node.js --latest" + npm run build + fi +fi # Clean and create destination directory echo "Cleaning destination directory..." @@ -74,20 +153,22 @@ mkdir -p "${DEST_DIR}" echo "Copying web files to app bundle..." cp -R "${PUBLIC_DIR}/"* "${DEST_DIR}/" -# Copy native executable and modules to app bundle root -echo "Copying native executable and modules..." +# Copy native executable and modules to app bundle NATIVE_DIR="${WEB_DIR}/native" -APP_RESOURCES="${BUILT_PRODUCTS_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}" if [ -f "${NATIVE_DIR}/vibetunnel" ]; then + echo "Copying native executable to app bundle..." + EXEC_SIZE=$(ls -lh "${NATIVE_DIR}/vibetunnel" | awk '{print $5}') + echo " Executable size: $EXEC_SIZE" cp "${NATIVE_DIR}/vibetunnel" "${APP_RESOURCES}/" chmod +x "${APP_RESOURCES}/vibetunnel" else - echo "error: vibetunnel executable not found" + echo "error: Native executable not found at ${NATIVE_DIR}/vibetunnel" exit 1 fi if [ -f "${NATIVE_DIR}/pty.node" ]; then + echo "Copying pty.node..." cp "${NATIVE_DIR}/pty.node" "${APP_RESOURCES}/" else echo "error: pty.node not found" @@ -95,6 +176,7 @@ else fi if [ -f "${NATIVE_DIR}/spawn-helper" ]; then + echo "Copying spawn-helper..." cp "${NATIVE_DIR}/spawn-helper" "${APP_RESOURCES}/" chmod +x "${APP_RESOURCES}/spawn-helper" else @@ -102,4 +184,65 @@ else exit 1 fi +echo "✓ Native executable and modules copied successfully" + +# Sanity check: Verify all required binaries are present in the app bundle +echo "Performing final sanity check..." + +MISSING_FILES=() + +# Check for vibetunnel executable +if [ ! -f "${APP_RESOURCES}/vibetunnel" ]; then + MISSING_FILES+=("vibetunnel executable") +fi + +# Check for pty.node +if [ ! -f "${APP_RESOURCES}/pty.node" ]; then + MISSING_FILES+=("pty.node native module") +fi + +# Check for spawn-helper (Unix only) +if [ ! -f "${APP_RESOURCES}/spawn-helper" ]; then + MISSING_FILES+=("spawn-helper") +fi + +# Check if vibetunnel is executable +if [ -f "${APP_RESOURCES}/vibetunnel" ] && [ ! -x "${APP_RESOURCES}/vibetunnel" ]; then + MISSING_FILES+=("vibetunnel is not executable") +fi + +# Check if spawn-helper is executable +if [ -f "${APP_RESOURCES}/spawn-helper" ] && [ ! -x "${APP_RESOURCES}/spawn-helper" ]; then + MISSING_FILES+=("spawn-helper is not executable") +fi + +# If any files are missing, fail the build +if [ ${#MISSING_FILES[@]} -gt 0 ]; then + echo "error: Build sanity check failed! Missing required files:" + for file in "${MISSING_FILES[@]}"; do + echo " - $file" + done + echo "Build artifacts in ${NATIVE_DIR}:" + ls -la "${NATIVE_DIR}" || echo " Directory does not exist" + echo "App resources in ${APP_RESOURCES}:" + ls -la "${APP_RESOURCES}/vibetunnel" "${APP_RESOURCES}/pty.node" "${APP_RESOURCES}/spawn-helper" 2>/dev/null || true + exit 1 +fi + +# Optional: Verify the executable works +echo "Verifying vibetunnel executable..." +if "${APP_RESOURCES}/vibetunnel" --version &>/dev/null; then + VERSION_OUTPUT=$("${APP_RESOURCES}/vibetunnel" --version 2>&1 | head -1) + echo "✓ VibeTunnel executable verified: $VERSION_OUTPUT" +else + echo "error: VibeTunnel executable failed verification (--version test failed)" + echo "This might indicate a corrupted or incompatible binary" + exit 1 +fi + +echo "✓ All sanity checks passed" + +# Save the current hash as the previous hash for next build +cp "${HASH_FILE}" "${PREVIOUS_HASH_FILE}" + echo "Web frontend build completed successfully" \ No newline at end of file diff --git a/mac/scripts/calculate-web-hash.sh b/mac/scripts/calculate-web-hash.sh index a02c8cd9..ed5b91b1 100755 --- a/mac/scripts/calculate-web-hash.sh +++ b/mac/scripts/calculate-web-hash.sh @@ -27,7 +27,7 @@ cd "${WEB_DIR}" # Find all relevant files and calculate their size, modification time, and content hash # This approach is more reliable than just content hash as it catches permission changes -# Exclude: node_modules, dist, public (all build outputs), package-lock.json +# Exclude: node_modules, dist, public (all build outputs), package-lock.json, and build directories CONTENT_HASH=$(find . \ -type f \ \( -name "*.ts" -o -name "*.js" -o -name "*.json" -o -name "*.css" -o -name "*.html" \ @@ -39,6 +39,9 @@ CONTENT_HASH=$(find . \ -not -path "./.next/*" \ -not -path "./coverage/*" \ -not -path "./.cache/*" \ + -not -path "./.node-builds/*" \ + -not -path "./build/*" \ + -not -path "./native/*" \ -not -name "package-lock.json" \ -exec stat -f "%m %z %p" {} \; \ -exec shasum -a 256 {} \; | \ diff --git a/mac/scripts/clean.sh b/mac/scripts/clean.sh index 1b0c63ad..3ab8868c 100755 --- a/mac/scripts/clean.sh +++ b/mac/scripts/clean.sh @@ -150,6 +150,16 @@ if [[ "$CLEAN_ALL" == "true" ]]; then remove_item "web/.next" "Next.js build cache" fi +# Clean web build artifacts (always clean these) +if [[ -d "$PROJECT_ROOT/../web" ]]; then + remove_item "$PROJECT_ROOT/../web/native" "Web native executables" + remove_item "$PROJECT_ROOT/../web/dist" "Web dist directory" + remove_item "$PROJECT_ROOT/../web/public/bundle" "Web bundle directory" + remove_item "$PROJECT_ROOT/../web/public/output.css" "Web CSS output" + remove_item "$PROJECT_ROOT/../web/build" "Web build directory" + remove_item "$PROJECT_ROOT/../web/.node-builds" "Custom Node.js builds" +fi + # Clean Python caches find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true find . -type f -name "*.pyc" -delete 2>/dev/null || true diff --git a/mac/scripts/install-node.sh b/mac/scripts/install-node.sh new file mode 100755 index 00000000..2d4cbf7e --- /dev/null +++ b/mac/scripts/install-node.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# +# Check for Node.js availability for the build process +# +# This script ensures Node.js is available for building VibeTunnel +# + +set -uo pipefail + +# Script directory and paths +if [ -n "${BASH_SOURCE[0]:-}" ]; then + SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +else + SCRIPT_DIR="$( cd "$( dirname "$0" )" && pwd )" +fi + +echo "Checking for Node.js..." + +# Add common Node.js installation paths to PATH +# Homebrew on Apple Silicon +if [ -d "/opt/homebrew/bin" ]; then + export PATH="/opt/homebrew/bin:$PATH" +fi + +# Homebrew on Intel Macs +if [ -d "/usr/local/bin" ]; then + export PATH="/usr/local/bin:$PATH" +fi + +# NVM default location +if [ -s "$HOME/.nvm/nvm.sh" ]; then + export NVM_DIR="$HOME/.nvm" + . "$NVM_DIR/nvm.sh" +fi + +# Check if Node.js is available +if command -v node &> /dev/null; then + echo "✓ Node.js found: $(which node)" + echo " Version: $(node --version)" + + # Check Node.js version (need v20+) + NODE_VERSION=$(node --version | cut -d'v' -f2) + NODE_MAJOR=$(echo "$NODE_VERSION" | cut -d'.' -f1) + + if [ "$NODE_MAJOR" -lt 20 ]; then + echo "Warning: Node.js v20+ is recommended (found v$NODE_VERSION)" + fi + + # Check if npm is available + if command -v npm &> /dev/null; then + echo "✓ npm found: $(which npm)" + echo " Version: $(npm --version)" + else + echo "Error: npm not found. Please ensure Node.js is properly installed." + exit 1 + fi + + exit 0 +else + echo "Error: Node.js not found in PATH" + echo "" + echo "Please install Node.js 20+ using one of these methods:" + echo " - Homebrew: brew install node" + echo " - Download from: https://nodejs.org/" + echo " - Using nvm: nvm install 20" + echo "" + echo "PATH checked: $PATH" + exit 1 +fi \ No newline at end of file diff --git a/mac/scripts/release.sh b/mac/scripts/release.sh index 6847d6b6..1b8038c4 100755 --- a/mac/scripts/release.sh +++ b/mac/scripts/release.sh @@ -319,6 +319,38 @@ fi echo "" echo -e "${BLUE}📋 Step 4/8: Building universal application...${NC}" +# Check for custom Node.js build +echo "" +echo "🔍 Checking for custom Node.js build..." +WEB_DIR="$PROJECT_ROOT/../web" +CUSTOM_NODE_PATH=$(find "$WEB_DIR/.node-builds" -name "node-v*-minimal" -type d 2>/dev/null | sort -V | tail -n1)/out/Release/node + +if [[ ! -f "$CUSTOM_NODE_PATH" ]]; then + echo -e "${YELLOW}⚠️ Custom Node.js not found. Building for optimal app size...${NC}" + echo " This will take 10-20 minutes on first run." + + # Build custom Node.js + pushd "$WEB_DIR" > /dev/null + if node build-custom-node.js --latest; then + echo -e "${GREEN}✅ Custom Node.js built successfully${NC}" + CUSTOM_NODE_PATH=$(find "$WEB_DIR/.node-builds" -name "node-v*-minimal" -type d 2>/dev/null | sort -V | tail -n1)/out/Release/node + if [[ -f "$CUSTOM_NODE_PATH" ]]; then + CUSTOM_NODE_SIZE=$(ls -lh "$CUSTOM_NODE_PATH" | awk '{print $5}') + echo " Size: $CUSTOM_NODE_SIZE (vs ~110MB for standard Node.js)" + fi + else + echo -e "${RED}❌ Failed to build custom Node.js${NC}" + echo " Continuing with standard Node.js (larger app size)" + fi + popd > /dev/null +else + CUSTOM_NODE_SIZE=$(ls -lh "$CUSTOM_NODE_PATH" | awk '{print $5}') + CUSTOM_NODE_VERSION=$("$CUSTOM_NODE_PATH" --version 2>/dev/null || echo "unknown") + echo -e "${GREEN}✅ Found custom Node.js${NC}" + echo " Version: $CUSTOM_NODE_VERSION" + echo " Size: $CUSTOM_NODE_SIZE" +fi + # For pre-release builds, set the environment variable if [[ "$RELEASE_TYPE" != "stable" ]]; then echo "📝 Marking build as pre-release..." diff --git a/web/build-custom-node.js b/web/build-custom-node.js new file mode 100755 index 00000000..ee1da7ec --- /dev/null +++ b/web/build-custom-node.js @@ -0,0 +1,250 @@ +#!/usr/bin/env node + +/** + * Build a custom Node.js binary with reduced size by excluding features we don't need. + * + * This creates a Node.js build without: + * - International support (ICU) - saves ~28MB + * - npm/npx - saves ~5MB + * - corepack + * - dtrace/etw instrumentation + * + * The resulting binary is typically 50-60MB instead of 110+MB. + * + * Usage: + * ```bash + * node build-custom-node.js # Build for current Node.js version + * node build-custom-node.js --latest # Build latest Node.js version + * node build-custom-node.js --version=24.2.0 # Build specific version + * ``` + * + * The custom Node.js will be built in: + * .node-builds/node-vXX-minimal/out/Release/node + */ + +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const https = require('https'); + +// Parse command line arguments +const args = process.argv.slice(2); +let targetVersion = null; +let useLatest = false; + +for (const arg of args) { + if (arg.startsWith('--version=')) { + targetVersion = arg.split('=')[1]; + } else if (arg === '--latest') { + useLatest = true; + } +} + +// Helper to download files +function downloadFile(url, destPath) { + return new Promise((resolve, reject) => { + const file = fs.createWriteStream(destPath); + https.get(url, (response) => { + if (response.statusCode === 302 || response.statusCode === 301) { + // Handle redirect + downloadFile(response.headers.location, destPath).then(resolve).catch(reject); + return; + } + response.pipe(file); + file.on('finish', () => { + file.close(resolve); + }); + }).on('error', (err) => { + fs.unlink(destPath, () => {}); + reject(err); + }); + }); +} + +// Helper to get latest Node.js version +async function getLatestNodeVersion() { + return new Promise((resolve, reject) => { + https.get('https://nodejs.org/dist/latest/SHASUMS256.txt', (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + // Extract version from first line like: "1234567890abcdef node-v24.2.0-darwin-arm64.tar.gz" + const match = data.match(/node-v(\d+\.\d+\.\d+)/); + if (match) { + resolve(match[1]); + } else { + reject(new Error('Could not determine latest Node.js version')); + } + }); + }).on('error', reject); + }); +} + +async function buildCustomNode() { + // Determine version to build + let nodeSourceVersion; + if (useLatest) { + console.log('Fetching latest Node.js version...'); + nodeSourceVersion = await getLatestNodeVersion(); + console.log(`Latest Node.js version: ${nodeSourceVersion}`); + } else if (targetVersion) { + nodeSourceVersion = targetVersion; + } else { + // Use current Node.js version + const nodeVersionMatch = process.version.match(/^v(\d+)\.(\d+)\.(\d+)/); + nodeSourceVersion = `${nodeVersionMatch[1]}.${nodeVersionMatch[2]}.${nodeVersionMatch[3]}`; + } + + console.log(`Building custom Node.js ${nodeSourceVersion} without intl and npm...`); + console.log('This will take 10-20 minutes on first run, but will be cached for future builds.'); + + const nodeSourceUrl = `https://nodejs.org/dist/v${nodeSourceVersion}/node-v${nodeSourceVersion}.tar.gz`; + const majorVersion = nodeSourceVersion.split('.')[0]; + + const buildDir = path.join(__dirname, '.node-builds'); + const versionDir = path.join(buildDir, `node-v${nodeSourceVersion}-minimal`); + const markerFile = path.join(versionDir, '.build-complete'); + const customNodePath = path.join(versionDir, 'out', 'Release', 'node'); + + // Check if already built + if (fs.existsSync(markerFile) && fs.existsSync(customNodePath)) { + console.log(`Using cached custom Node.js build from ${customNodePath}`); + const stats = fs.statSync(customNodePath); + console.log(`Cached custom Node.js size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`); + console.log(`\nTo use this custom Node.js with build-native.js:`); + console.log(`node build-native.js --custom-node="${customNodePath}"`); + return customNodePath; + } + + // Create build directory + if (!fs.existsSync(buildDir)) { + fs.mkdirSync(buildDir, { recursive: true }); + } + + // Clean up old version directory if exists + if (fs.existsSync(versionDir)) { + console.log('Cleaning up incomplete build...'); + fs.rmSync(versionDir, { recursive: true, force: true }); + } + + const tarPath = path.join(buildDir, `node-v${nodeSourceVersion}.tar.gz`); + const originalCwd = process.cwd(); + + try { + // Download Node.js source if not cached + if (!fs.existsSync(tarPath)) { + console.log(`Downloading Node.js source from ${nodeSourceUrl}...`); + await downloadFile(nodeSourceUrl, tarPath); + } + + // Extract source + console.log('Extracting Node.js source...'); + execSync(`tar -xzf "${tarPath}" -C "${buildDir}"`, { stdio: 'inherit' }); + + // Rename to version-specific directory + const extractedDir = path.join(buildDir, `node-v${nodeSourceVersion}`); + fs.renameSync(extractedDir, versionDir); + + // Configure and build + process.chdir(versionDir); + + console.log('Configuring Node.js build (without intl and npm)...'); + const configureArgs = [ + '--without-intl', + '--without-npm', + '--without-corepack', + '--without-inspector', + '--without-node-snapshot', // Disable V8 snapshot (saves ~2-3MB) + '--without-node-code-cache', // Disable code cache (saves ~1-2MB) + '--ninja', // Use ninja if available for faster builds + ]; + + // Set optimization flags for smaller binary + // -Os: Optimize for size + // -flto: Link Time Optimization (can save 10-20%) + // -ffunction-sections -fdata-sections: Allow linker to remove unused code + process.env.CFLAGS = '-Os -flto -ffunction-sections -fdata-sections'; + process.env.CXXFLAGS = '-Os -flto -ffunction-sections -fdata-sections'; + process.env.LDFLAGS = '-Wl,-dead_strip'; // macOS equivalent of --gc-sections + + // Check if ninja is available, install if not + try { + execSync('which ninja', { stdio: 'ignore' }); + console.log('Using Ninja for faster builds...'); + } catch { + console.log('Ninja not found, installing via Homebrew...'); + try { + execSync('brew install ninja', { stdio: 'inherit' }); + console.log('Ninja installed successfully'); + } catch (brewError) { + console.log('Failed to install ninja, falling back to Make...'); + // Remove --ninja if not available + configureArgs.pop(); + } + } + + execSync(`./configure ${configureArgs.join(' ')}`, { stdio: 'inherit' }); + + console.log('Building Node.js (this will take a while)...'); + const cores = require('os').cpus().length; + + // Check if we're using ninja or make + const buildSystem = configureArgs.includes('--ninja') ? 'ninja' : 'make'; + if (buildSystem === 'ninja') { + execSync(`ninja -C out/Release -j ${cores}`, { stdio: 'inherit' }); + } else { + execSync(`make -j${cores}`, { stdio: 'inherit' }); + } + + // Verify the build + if (!fs.existsSync(customNodePath)) { + throw new Error('Node.js build failed - binary not found'); + } + + // Strip the binary + console.log('Stripping Node.js binary...'); + execSync(`strip -S "${customNodePath}"`, { stdio: 'inherit' }); + + // Check final size + const stats = fs.statSync(customNodePath); + console.log(`\n✅ Custom Node.js built successfully!`); + console.log(`Size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`); + + // Compare with system Node.js + try { + const systemNodeStats = fs.statSync(process.execPath); + const reduction = ((systemNodeStats.size - stats.size) / systemNodeStats.size * 100).toFixed(1); + console.log(`Size reduction: ${reduction}% compared to system Node.js`); + } catch (e) { + // Ignore if we can't stat system node + } + + // Mark build as complete + fs.writeFileSync(markerFile, JSON.stringify({ + version: nodeSourceVersion, + buildDate: new Date().toISOString(), + size: stats.size, + configureArgs: configureArgs + }, null, 2)); + + // Change back to original directory + process.chdir(originalCwd); + + console.log(`\nCustom Node.js location: ${customNodePath}`); + console.log(`\nTo use this custom Node.js with build-native.js:`); + console.log(`node build-native.js --custom-node="${customNodePath}"`); + + return customNodePath; + + } catch (error) { + process.chdir(originalCwd); + console.error('Failed to build custom Node.js:', error.message); + process.exit(1); + } +} + +// Run the build +buildCustomNode().catch(err => { + console.error('Build failed:', err); + process.exit(1); +}); \ No newline at end of file diff --git a/web/build-native.js b/web/build-native.js index 8038781c..bb8cc5c4 100755 --- a/web/build-native.js +++ b/web/build-native.js @@ -41,8 +41,13 @@ * * ## Usage * ```bash - * node build-native-node.js # Build without sourcemaps (default) - * node build-native-node.js --sourcemap # Build with inline sourcemaps + * node build-native.js # Build with system Node.js + * node build-native.js --sourcemap # Build with inline sourcemaps + * node build-native.js --custom-node=/path/to/node # Use custom Node.js binary + * + * # Build custom Node.js first: + * node build-custom-node.js # Build minimal Node.js for current version + * node build-custom-node.js --version=24.2.0 # Build specific version * ``` * * ## Requirements @@ -61,9 +66,45 @@ const path = require('path'); // Parse command line arguments const includeSourcemaps = process.argv.includes('--sourcemap'); +let customNodePath = null; + +// Parse --custom-node argument +for (let i = 0; i < process.argv.length; i++) { + const arg = process.argv[i]; + if (arg.startsWith('--custom-node=')) { + customNodePath = arg.split('=')[1]; + } else if (arg === '--custom-node') { + // Check if next argument is a path + if (i + 1 < process.argv.length && !process.argv[i + 1].startsWith('--')) { + customNodePath = process.argv[i + 1]; + } else { + // No path provided, search for custom Node.js build + console.log('Searching for custom Node.js build...'); + const customBuildsDir = path.join(__dirname, '.node-builds'); + if (fs.existsSync(customBuildsDir)) { + const dirs = fs.readdirSync(customBuildsDir) + .filter(dir => dir.startsWith('node-v') && dir.endsWith('-minimal')) + .map(dir => ({ + name: dir, + path: path.join(customBuildsDir, dir, 'out/Release/node'), + mtime: fs.statSync(path.join(customBuildsDir, dir)).mtime + })) + .filter(item => fs.existsSync(item.path)) + .sort((a, b) => b.mtime - a.mtime); // Sort by modification time, newest first + + if (dirs.length > 0) { + customNodePath = dirs[0].path; + console.log(`Found custom Node.js at: ${customNodePath}`); + } else { + console.log('No custom Node.js builds found in .node-builds/'); + } + } + } + } +} console.log('Building standalone vibetunnel executable using Node.js SEA...'); -console.log(`Node.js version: ${process.version}`); +console.log(`System Node.js version: ${process.version}`); if (includeSourcemaps) { console.log('Including sourcemaps in build'); } @@ -228,11 +269,38 @@ async function main() { fs.mkdirSync('native'); } - // 0. Patch node-pty + // 0. Determine which Node.js to use + let nodeExe = process.execPath; + if (customNodePath) { + // Validate custom node exists + if (!fs.existsSync(customNodePath)) { + console.error(`Error: Custom Node.js not found at ${customNodePath}`); + console.error('Build one using: node build-custom-node.js'); + process.exit(1); + } + nodeExe = customNodePath; + } + + console.log(`Using Node.js binary: ${nodeExe}`); + const nodeStats = fs.statSync(nodeExe); + console.log(`Node.js binary size: ${(nodeStats.size / 1024 / 1024).toFixed(2)} MB`); + + // Get version of the Node.js we're using + if (customNodePath) { + try { + const customVersion = execSync(`"${nodeExe}" --version`, { encoding: 'utf8' }).trim(); + console.log(`Custom Node.js version: ${customVersion}`); + console.log('This minimal build excludes intl, npm, inspector, and other unused features.'); + } catch (e) { + console.log('Could not determine custom Node.js version'); + } + } + + // 1. Patch node-pty patchNodePty(); - // 1. Bundle TypeScript with esbuild using custom loader - console.log('Bundling TypeScript with esbuild...'); + // 2. Bundle TypeScript with esbuild using custom loader + console.log('\nBundling TypeScript with esbuild...'); const buildDate = new Date().toISOString(); const buildTimestamp = Date.now(); @@ -272,7 +340,6 @@ async function main() { // 4. Create executable console.log('\nCreating executable...'); - const nodeExe = process.execPath; const targetExe = process.platform === 'win32' ? 'native/vibetunnel.exe' : 'native/vibetunnel'; // Copy node binary @@ -292,18 +359,32 @@ async function main() { execSync(postjectCmd, { stdio: 'inherit' }); - // 6. Sign on macOS + // 6. Strip the executable first (before signing) + console.log('Stripping final executable...'); + // Note: This will show a warning about invalidating code signature, which is expected + // since we're modifying a signed Node.js binary. We'll re-sign it in the next step. + execSync(`strip -S ${targetExe} 2>&1 | grep -v "warning: changes being made" || true`, { + stdio: 'inherit', + shell: true + }); + + // 7. Sign on macOS (after stripping) if (process.platform === 'darwin') { console.log('Signing executable...'); execSync(`codesign --sign - ${targetExe}`, { stdio: 'inherit' }); } + + // Check final size + const finalStats = fs.statSync(targetExe); + console.log(`Final executable size: ${(finalStats.size / 1024 / 1024).toFixed(2)} MB`); + console.log(`Size reduction: ${((nodeStats.size - finalStats.size) / 1024 / 1024).toFixed(2)} MB`); - // 7. Restore original node-pty + // 8. Restore original node-pty console.log('Restoring original node-pty...'); execSync('rm -rf node_modules/@homebridge/node-pty-prebuilt-multiarch', { stdio: 'inherit' }); execSync('npm install @homebridge/node-pty-prebuilt-multiarch --silent --no-fund --no-audit', { stdio: 'inherit' }); - // 8. Copy only necessary native files + // 9. Copy only necessary native files console.log('Copying native modules...'); const nativeModulesDir = 'node_modules/@homebridge/node-pty-prebuilt-multiarch/build/Release'; diff --git a/web/scripts/build.js b/web/scripts/build.js index f637e0e3..00e3a619 100644 --- a/web/scripts/build.js +++ b/web/scripts/build.js @@ -27,6 +27,16 @@ execSync('tsc', { stdio: 'inherit' }); // Build native executable console.log('Building native executable...'); -execSync('node build-native.js', { stdio: 'inherit' }); + +// Check for --custom-node flag +const useCustomNode = process.argv.includes('--custom-node'); + +if (useCustomNode) { + console.log('Using custom Node.js for smaller binary size...'); + execSync('node build-native.js --custom-node', { stdio: 'inherit' }); +} else { + console.log('Using system Node.js...'); + execSync('node build-native.js', { stdio: 'inherit' }); +} console.log('Build completed successfully!'); \ No newline at end of file diff --git a/web/src/cli.ts b/web/src/cli.ts index be40a9d7..ed08fbfb 100644 --- a/web/src/cli.ts +++ b/web/src/cli.ts @@ -6,6 +6,16 @@ import { VERSION } from './server/version.js'; // Source maps are only included if built with --sourcemap flag +// Prevent double execution in SEA context where require.main might be undefined +// Use a global flag to ensure we only run once +if ((global as any).__vibetunnelStarted) { + console.log('VibeTunnel already started, skipping duplicate execution'); + console.log('Global flag was already set, exiting to prevent duplicate server'); + process.exit(0); +} +(global as any).__vibetunnelStarted = true; +console.log('Setting global flag to prevent duplicate execution'); + // Handle uncaught exceptions process.on('uncaughtException', (error) => { console.error('Uncaught exception:', error); @@ -21,14 +31,25 @@ process.on('unhandledRejection', (reason, promise) => { process.exit(1); }); -if (process.argv[2] === 'version') { - console.log(`VibeTunnel Linux v${VERSION}`); - process.exit(0); -} else if (process.argv[2] === 'fwd') { - startVibeTunnelForward(process.argv.slice(3)).catch((error) => { - console.error('Fatal error:', error); - process.exit(1); - }); +// Only execute if this is the main module (or in SEA where require.main is undefined) +if (!module.parent && (require.main === module || require.main === undefined)) { + console.log('Main module check passed, proceeding with server startup'); + console.log('module.parent:', module.parent); + console.log('require.main === module:', require.main === module); + console.log('require.main:', require.main); + + if (process.argv[2] === 'version') { + console.log(`VibeTunnel Server v${VERSION}`); + process.exit(0); + } else if (process.argv[2] === 'fwd') { + startVibeTunnelForward(process.argv.slice(3)).catch((error) => { + console.error('Fatal error:', error); + process.exit(1); + }); + } else { + console.log('Starting VibeTunnel server...'); + startVibeTunnelServer(); + } } else { - startVibeTunnelServer(); + console.log('Not main module, skipping server startup'); } diff --git a/web/src/server/server.ts b/web/src/server/server.ts index c452ed67..b130f4a8 100644 --- a/web/src/server/server.ts +++ b/web/src/server/server.ts @@ -247,7 +247,17 @@ interface AppInstance { activityMonitor: ActivityMonitor; } +// Track if app has been created +let appCreated = false; + export function createApp(): AppInstance { + // Prevent multiple app instances + if (appCreated) { + console.error(chalk.red('ERROR: App already created, preventing duplicate instance')); + throw new Error('Duplicate app creation detected'); + } + appCreated = true; + const config = parseArgs(); // Check if help was requested @@ -271,6 +281,7 @@ export function createApp(): AppInstance { validateConfig(config); + console.log('Creating Express app and HTTP server...'); const app = express(); const server = createServer(app); const wss = new WebSocketServer({ server }); @@ -406,6 +417,28 @@ export function createApp(): AppInstance { // Start server function const startServer = () => { const requestedPort = config.port !== null ? config.port : Number(process.env.PORT) || 4020; + + console.log(`Attempting to start server on port ${requestedPort}`); + + // Remove all existing error listeners first to prevent duplicates + server.removeAllListeners('error'); + + // Add error handler for port already in use + server.on('error', (error: any) => { + if (error.code === 'EADDRINUSE') { + console.error(chalk.red(`Error: Port ${requestedPort} is already in use`)); + console.error( + chalk.yellow( + 'Please use a different port with --port or stop the existing server' + ) + ); + process.exit(9); // Exit with code 9 to indicate port conflict + } else { + console.error(chalk.red('Server error:'), error); + process.exit(1); + } + }); + server.listen(requestedPort, () => { const address = server.address(); const actualPort = @@ -485,8 +518,20 @@ export function createApp(): AppInstance { }; } +// Track if server has been started +let serverStarted = false; + // Export a function to start the server export function startVibeTunnelServer() { + // Prevent multiple server instances + if (serverStarted) { + console.error(chalk.red('ERROR: Server already started, preventing duplicate instance')); + console.error('This should not happen - duplicate server startup detected'); + process.exit(1); + } + serverStarted = true; + + console.log('Creating app instance...'); // Create and configure the app const appInstance = createApp(); const { @@ -499,6 +544,7 @@ export function startVibeTunnelServer() { activityMonitor, } = appInstance; + console.log('Starting server...'); startServer(); // Cleanup old terminals every 5 minutes