diff --git a/cloud-server/Dockerfile b/cloud-server/Dockerfile new file mode 100644 index 0000000..04d5e7e --- /dev/null +++ b/cloud-server/Dockerfile @@ -0,0 +1,48 @@ +# DataConnect Cloud Connector Runtime +# Extends n.eko chromium with our API server and connector automation. +# +# Build: docker build -f cloud-server/Dockerfile -t data-connect-cloud . +# Run: docker compose up + +FROM ghcr.io/m1k1o/neko/chromium:3.0 + +# Install Node.js for our API server +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Layer-cached dependency install +COPY cloud-server/package.json cloud-server/package-lock.json* ./cloud-server/ +COPY playwright-runner/package.json playwright-runner/package-lock.json* ./playwright-runner/ +RUN cd cloud-server && npm ci --ignore-scripts +RUN cd playwright-runner && npm ci --ignore-scripts + +# Source & build +COPY connectors/ ./connectors/ +COPY cloud-server/ ./cloud-server/ +COPY playwright-runner/ ./playwright-runner/ +RUN cd cloud-server && npm run build + +# Pre-built frontend +COPY dist/ ./dist/ + +# Supervisord configs for our services +COPY cloud-server/supervisord/*.conf /etc/neko/supervisord/ + +# Override Chromium config and launcher script +COPY cloud-server/neko/chromium.conf /etc/neko/supervisord/chromium.conf +COPY cloud-server/neko/start-chromium.sh /usr/local/bin/start-chromium.sh +RUN chmod +x /usr/local/bin/start-chromium.sh +COPY cloud-server/neko/policies.json /etc/chromium/policies/managed/policies.json +# Override xorg.conf to add portrait modes for mobile viewports +COPY cloud-server/neko/xorg.conf /etc/neko/xorg.conf + +# Clean stale Chromium profile locks on startup (different container hostname) +RUN echo '#!/bin/sh\nrm -f /home/neko/.config/chromium/SingletonLock /home/neko/.config/chromium/SingletonSocket /home/neko/.config/chromium/SingletonCookie\nexec "$@"' > /usr/local/bin/cleanup-locks.sh && chmod +x /usr/local/bin/cleanup-locks.sh +ENTRYPOINT ["/usr/local/bin/cleanup-locks.sh"] +CMD ["/usr/bin/supervisord", "-c", "/etc/neko/supervisord.conf"] + +VOLUME ["/home/neko/.config/chromium"] +EXPOSE 3000 diff --git a/cloud-server/deploy/docker-compose.prod.yml b/cloud-server/deploy/docker-compose.prod.yml new file mode 100644 index 0000000..f5651de --- /dev/null +++ b/cloud-server/deploy/docker-compose.prod.yml @@ -0,0 +1,28 @@ +services: + data-connect-cloud: + image: gcr.io/corsali-development/data-connect-cloud + ports: + - "3000:3000" + - "8080:8080" + - "59000:59000/udp" + - "59000:59000/tcp" + volumes: + - profile-data:/data + shm_size: "2gb" + restart: unless-stopped + environment: + - NODE_ENV=production + - AUTH_TOKEN=${AUTH_TOKEN:-happyfriday} + - NEKO_SERVER_BIND=0.0.0.0:8080 + - NEKO_LEGACY=true + - NEKO_WEBRTC_UDPMUX=59000 + - NEKO_WEBRTC_TCPMUX=59000 + - NEKO_WEBRTC_ICELITE=1 + - NEKO_WEBRTC_NAT1TO1=${PUBLIC_IP} + - NEKO_MEMBER_PROVIDER=noauth + - NEKO_SESSION_IMPLICIT_HOSTING=true + - NEKO_IMPLICITCONTROL=true + - NEKO_DESKTOP_SCREEN=1280x720@30 + +volumes: + profile-data: diff --git a/cloud-server/deploy/gcp-deploy.sh b/cloud-server/deploy/gcp-deploy.sh new file mode 100755 index 0000000..230d204 --- /dev/null +++ b/cloud-server/deploy/gcp-deploy.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# Deploy data-connect-cloud to a GCP Compute Engine VM. +# +# Usage: +# ./cloud-server/deploy/gcp-deploy.sh [create|update|ssh|teardown] +# +# Prerequisites: +# - gcloud CLI authenticated +# - Docker image built locally or in GCR +# +# The script creates a VM, installs Docker, pushes the image to GCR, +# pulls it on the VM, and runs it with docker compose. + +set -euo pipefail + +PROJECT="corsali-development" +ZONE="us-central1-a" +VM_NAME="data-connect-cloud" +MACHINE_TYPE="e2-medium" +IMAGE_NAME="gcr.io/${PROJECT}/data-connect-cloud" +FIREWALL_TAG="data-connect-cloud" +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" + +log() { echo "[$(date '+%H:%M:%S')] $*"; } + +create_firewall_rules() { + log "Creating firewall rules..." + + # API + n.eko HTTP + WebRTC + gcloud compute firewall-rules create allow-${FIREWALL_TAG} \ + --project="$PROJECT" \ + --direction=INGRESS \ + --action=ALLOW \ + --rules=tcp:3000,tcp:8080,tcp:59000,udp:59000 \ + --target-tags="$FIREWALL_TAG" \ + --source-ranges=0.0.0.0/0 \ + --description="data-connect-cloud: API (3000), n.eko (8080), WebRTC (59000)" \ + 2>/dev/null || log "Firewall rule already exists, skipping." +} + +create_vm() { + log "Creating VM ${VM_NAME}..." + + gcloud compute instances create "$VM_NAME" \ + --project="$PROJECT" \ + --zone="$ZONE" \ + --machine-type="$MACHINE_TYPE" \ + --tags="$FIREWALL_TAG" \ + --image-family=ubuntu-2404-lts-amd64 \ + --image-project=ubuntu-os-cloud \ + --boot-disk-size=30GB \ + --scopes=storage-ro \ + --metadata=startup-script='#!/bin/bash + if ! command -v docker &>/dev/null; then + curl -fsSL https://get.docker.com | sh + usermod -aG docker $(logname || echo ubuntu) + fi + ' + + log "Waiting for VM to be ready..." + sleep 30 + + # Wait for Docker to be installed + for i in $(seq 1 12); do + if gcloud compute ssh "$VM_NAME" --project="$PROJECT" --zone="$ZONE" \ + --command="docker --version" 2>/dev/null; then + break + fi + log "Waiting for Docker install... (attempt $i/12)" + sleep 10 + done + + create_firewall_rules + log "VM created. External IP:" + gcloud compute instances describe "$VM_NAME" \ + --project="$PROJECT" --zone="$ZONE" \ + --format="get(networkInterfaces[0].accessConfigs[0].natIP)" +} + +build_and_push() { + log "Building and pushing Docker image..." + cd "$REPO_ROOT" + docker build -f cloud-server/Dockerfile -t "$IMAGE_NAME" . + docker push "$IMAGE_NAME" +} + +deploy_to_vm() { + local EXTERNAL_IP + EXTERNAL_IP=$(gcloud compute instances describe "$VM_NAME" \ + --project="$PROJECT" --zone="$ZONE" \ + --format="get(networkInterfaces[0].accessConfigs[0].natIP)") + + log "Deploying to VM (IP: ${EXTERNAL_IP})..." + + # Copy the production compose file + gcloud compute scp \ + "${REPO_ROOT}/cloud-server/deploy/docker-compose.prod.yml" \ + "${VM_NAME}:~/docker-compose.yml" \ + --project="$PROJECT" --zone="$ZONE" + + # Pull and run on the VM + gcloud compute ssh "$VM_NAME" --project="$PROJECT" --zone="$ZONE" --command=" + sudo gcloud auth configure-docker gcr.io --quiet && + sudo docker pull ${IMAGE_NAME} && + sudo docker compose down 2>/dev/null || true + sudo sh -c 'export PUBLIC_IP=${EXTERNAL_IP} && docker compose up -d' && + sleep 5 && + sudo docker compose logs --tail=20 + " + + log "Deployed! Access at:" + log " API: http://${EXTERNAL_IP}:3000" + log " Neko: http://${EXTERNAL_IP}:8080" +} + +do_ssh() { + gcloud compute ssh "$VM_NAME" --project="$PROJECT" --zone="$ZONE" +} + +do_teardown() { + log "Tearing down VM ${VM_NAME}..." + gcloud compute instances delete "$VM_NAME" \ + --project="$PROJECT" --zone="$ZONE" --quiet + log "VM deleted." +} + +case "${1:-}" in + create) + create_vm + build_and_push + deploy_to_vm + ;; + update) + build_and_push + deploy_to_vm + ;; + ssh) + do_ssh + ;; + teardown) + do_teardown + ;; + *) + echo "Usage: $0 [create|update|ssh|teardown]" + echo " create - Create VM, build image, deploy" + echo " update - Rebuild image and redeploy" + echo " ssh - SSH into the VM" + echo " teardown - Delete the VM" + exit 1 + ;; +esac diff --git a/cloud-server/neko/chromium.conf b/cloud-server/neko/chromium.conf new file mode 100644 index 0000000..2ce8adb --- /dev/null +++ b/cloud-server/neko/chromium.conf @@ -0,0 +1,22 @@ +[program:chromium] +environment=HOME="/home/%(ENV_USER)s",USER="%(ENV_USER)s",DISPLAY="%(ENV_DISPLAY)s" +command=/usr/local/bin/start-chromium.sh +stopsignal=INT +autorestart=true +priority=800 +user=%(ENV_USER)s +stdout_logfile=/var/log/neko/chromium.log +stdout_logfile_maxbytes=100MB +stdout_logfile_backups=10 +redirect_stderr=true + +[program:openbox] +environment=HOME="/home/%(ENV_USER)s",USER="%(ENV_USER)s",DISPLAY="%(ENV_DISPLAY)s" +command=/usr/bin/openbox --config-file /etc/neko/openbox.xml +autorestart=true +priority=300 +user=%(ENV_USER)s +stdout_logfile=/var/log/neko/openbox.log +stdout_logfile_maxbytes=100MB +stdout_logfile_backups=10 +redirect_stderr=true diff --git a/cloud-server/neko/policies.json b/cloud-server/neko/policies.json new file mode 100644 index 0000000..d494176 --- /dev/null +++ b/cloud-server/neko/policies.json @@ -0,0 +1,29 @@ +{ + "AutofillAddressEnabled": false, + "AutofillCreditCardEnabled": false, + "BrowserSignin": 0, + "DefaultCookiesSetting": 1, + "DefaultNotificationsSetting": 2, + "DeveloperToolsAvailability": 1, + "EditBookmarksEnabled": false, + "FullscreenAllowed": true, + "IncognitoModeAvailability": 1, + "SyncDisabled": true, + "AutoplayAllowed": true, + "BrowserAddPersonEnabled": false, + "BrowserGuestModeEnabled": false, + "DefaultPopupsSetting": 2, + "VideoCaptureAllowed": true, + "AllowFileSelectionDialogs": false, + "PromptForDownloadLocation": false, + "BookmarkBarEnabled": false, + "PasswordManagerEnabled": false, + "BrowserLabsEnabled": false, + "URLAllowlist": [ + "file:///home/neko/Downloads" + ], + "URLBlocklist": [ + "file://*", + "chrome://policy" + ] +} diff --git a/cloud-server/neko/start-chromium.sh b/cloud-server/neko/start-chromium.sh new file mode 100644 index 0000000..5a74464 --- /dev/null +++ b/cloud-server/neko/start-chromium.sh @@ -0,0 +1,18 @@ +#!/bin/sh +exec /usr/bin/chromium \ + --window-position=0,0 \ + --display="${DISPLAY}" \ + --user-data-dir=/home/neko/.config/chromium \ + --no-first-run \ + --no-sandbox \ + --start-maximized \ + --kiosk \ + --force-dark-mode \ + --disable-file-system \ + --disable-gpu \ + --disable-software-rasterizer \ + --disable-dev-shm-usage \ + --remote-debugging-port=9222 \ + --remote-debugging-address=0.0.0.0 \ + --remote-allow-origins=* \ + ${CHROMIUM_MOBILE_FLAGS} diff --git a/cloud-server/neko/xorg.conf b/cloud-server/neko/xorg.conf new file mode 100644 index 0000000..96e73e2 --- /dev/null +++ b/cloud-server/neko/xorg.conf @@ -0,0 +1,119 @@ +Section "ServerFlags" + Option "DontVTSwitch" "true" + Option "AllowMouseOpenFail" "true" + Option "PciForceNone" "true" + Option "AutoEnableDevices" "false" + Option "AutoAddDevices" "false" +EndSection + +Section "InputDevice" + Identifier "dummy_mouse" + Option "CorePointer" "true" + Driver "void" +EndSection + +Section "InputDevice" + Identifier "dummy_keyboard" + Option "CoreKeyboard" "true" + Driver "void" +EndSection + +Section "InputDevice" + Identifier "dummy_touchscreen" + Option "SendCoreEvents" "On" + Option "SocketName" "/tmp/xf86-input-neko.sock" + Driver "neko" +EndSection + +Section "Device" + Identifier "dummy_videocard" + Driver "dummy" + Option "ConstantDPI" "true" + VideoRam 1024000 +EndSection + +Section "Monitor" + Identifier "dummy_monitor" + HorizSync 5.0 - 1000.0 + VertRefresh 5.0 - 200.0 + + # === Landscape modes (from upstream) === + + # 1920x1080 @ 60.00 Hz (GTF) hsync: 67.08 kHz; pclk: 172.80 MHz + Modeline "1920x1080_60.00" 172.80 1920 2040 2248 2576 1080 1081 1084 1118 -HSync +Vsync + # 1280x720 @ 60.00 Hz (GTF) hsync: 44.76 kHz; pclk: 74.48 MHz + Modeline "1280x720_60.00" 74.48 1280 1336 1472 1664 720 721 724 746 -HSync +Vsync + # 1152x648 @ 60.00 Hz (GTF) hsync: 40.26 kHz; pclk: 59.91 MHz + Modeline "1152x648_60.00" 59.91 1152 1200 1320 1488 648 649 652 671 -HSync +Vsync + # 1024x576 @ 60.00 Hz (GTF) hsync: 35.82 kHz; pclk: 47.00 MHz + Modeline "1024x576_60.00" 47.00 1024 1064 1168 1312 576 577 580 597 -HSync +Vsync + # 960x720 @ 60.00 Hz (GTF) hsync: 44.76 kHz; pclk: 55.86 MHz + Modeline "960x720_60.00" 55.86 960 1008 1104 1248 720 721 724 746 -HSync +Vsync + # 800x600 @ 60.00 Hz (GTF) hsync: 37.32 kHz; pclk: 38.22 MHz + Modeline "800x600_60.00" 38.22 800 832 912 1024 600 601 604 622 -HSync +Vsync + + # 2560x1440 @ 30.00 Hz (GTF) hsync: 43.95 kHz; pclk: 146.27 MHz + Modeline "2560x1440_30.00" 146.27 2560 2680 2944 3328 1440 1441 1444 1465 -HSync +Vsync + # 1920x1080 @ 30.00 Hz (GTF) hsync: 32.97 kHz; pclk: 80.18 MHz + Modeline "1920x1080_30.00" 80.18 1920 1984 2176 2432 1080 1081 1084 1099 -HSync +Vsync + # 1368x768 @ 30.00 Hz (GTF) hsync: 23.46 kHz; pclk: 38.85 MHz + Modeline "1368x768_30.00" 38.85 1368 1376 1512 1656 768 769 772 782 -HSync +Vsync + # 1280x720 @ 30.00 Hz (GTF) hsync: 21.99 kHz; pclk: 33.78 MHz + Modeline "1280x720_30.00" 33.78 1280 1288 1408 1536 720 721 724 733 -HSync +Vsync + # 1152x648 @ 30.00 Hz (GTF) hsync: 19.80 kHz; pclk: 26.93 MHz + Modeline "1152x648_30.00" 26.93 1152 1144 1256 1360 648 649 652 660 -HSync +Vsync + # 1024x576 @ 30.00 Hz (GTF) hsync: 17.61 kHz; pclk: 20.85 MHz + Modeline "1024x576_30.00" 20.85 1024 1008 1104 1184 576 577 580 587 -HSync +Vsync + # 960x720 @ 30.00 Hz (GTF) hsync: 21.99 kHz; pclk: 25.33 MHz + Modeline "960x720_30.00" 25.33 960 960 1056 1152 720 721 724 733 -HSync +Vsync + # 800x600 @ 30.00 Hz (GTF) hsync: 18.33 kHz; pclk: 17.01 MHz + Modeline "800x600_30.00" 17.01 800 792 864 928 600 601 604 611 -HSync +Vsync + + # 3840x2160 @ 25.00 Hz (GTF) hsync: 54.77 kHz; pclk: 278.70 MHz + Modeline "3840x2160_25.00" 278.70 3840 4056 4464 5088 2160 2161 2164 2191 -HSync +Vsync + # 2560x1440 @ 24.96 Hz (CVT) hsync: 36.53 kHz; pclk: 119.25 MHz + Modeline "2560x1440_25.00" 119.25 2560 2656 2912 3264 1440 1443 1448 1464 -Hsync +Vsync + # 1920x1080 @ 25.00 Hz (GTF) hsync: 27.40 kHz; pclk: 64.88 MHz + Modeline "1920x1080_25.00" 64.88 1920 1952 2144 2368 1080 1081 1084 1096 -HSync +Vsync + # 1600x900 @ 24.97 Hz (CVT) hsync: 22.88 kHz; pclk: 45.75 MHz + Modeline "1600x900_25.00" 45.75 1600 1648 1800 2000 900 903 908 916 -Hsync +Vsync + # 1368x768 @ 24.89 Hz (CVT) hsync: 19.51 kHz; pclk: 33.25 MHz + Modeline "1368x768_25.00" 33.25 1368 1408 1536 1704 768 771 781 784 -Hsync +Vsync + + # === Portrait modes (for mobile/tablet viewports) === + + # 720x1280 @ 30.00 Hz (CVT) — HD portrait (9:16) + Modeline "720x1280_30.00" 30.41 720 752 824 928 1280 1283 1293 1304 -Hsync +Vsync + # 1080x1920 @ 30.00 Hz (CVT) — FHD portrait (9:16) + Modeline "1080x1920_30.00" 67.91 1080 1128 1240 1400 1920 1923 1933 1949 -Hsync +Vsync + # 768x1024 @ 30.00 Hz (CVT) — iPad portrait (3:4) + Modeline "768x1024_30.00" 24.46 768 792 872 976 1024 1027 1037 1044 -Hsync +Vsync + # 480x960 @ 30.00 Hz (CVT) — small mobile portrait (1:2) + Modeline "480x960_30.00" 14.71 480 496 544 608 960 963 973 978 -Hsync +Vsync + # 800x1600 @ 30.00 Hz (GTF) hsync: 48.84 kHz; pclk: 51.58 MHz + Modeline "800x1600_30.00" 51.58 800 840 928 1056 1600 1601 1604 1628 -HSync +Vsync + # 800x1600 @ 24.92 Hz (CVT) hsync: 40.53 kHz; pclk: 41.50 MHz + Modeline "800x1600_25.00" 41.50 800 832 912 1024 1600 1603 1613 1626 -Hsync +Vsync + # 1440x2560 @ 25.00 Hz (CVT) — QHD portrait (9:16) + Modeline "1440x2560_25.00" 119.25 1440 1504 1648 1856 2560 2563 2573 2592 -Hsync +Vsync +EndSection + +Section "Screen" + Identifier "dummy_screen" + Device "dummy_videocard" + Monitor "dummy_monitor" + DefaultDepth 24 + SubSection "Display" + Viewport 0 0 + Depth 24 + Modes "1920x1080_60.00" "1280x720_60.00" "1152x648_60.00" "1024x576_60.00" "960x720_60.00" "800x600_60.00" "2560x1440_30.00" "1920x1080_30.00" "1368x768_30.00" "1280x720_30.00" "1152x648_30.00" "1024x576_30.00" "960x720_30.00" "800x600_30.00" "3840x2160_25.00" "2560x1440_25.00" "1920x1080_25.00" "1600x900_25.00" "1368x768_25.00" "720x1280_30.00" "1080x1920_30.00" "768x1024_30.00" "480x960_30.00" "800x1600_30.00" "800x1600_25.00" "1440x2560_25.00" + EndSubSection +EndSection + +Section "ServerLayout" + Identifier "dummy_layout" + Screen "dummy_screen" + InputDevice "dummy_mouse" + InputDevice "dummy_keyboard" + InputDevice "dummy_touchscreen" "CorePointer" +EndSection diff --git a/cloud-server/package-lock.json b/cloud-server/package-lock.json new file mode 100644 index 0000000..1743fa6 --- /dev/null +++ b/cloud-server/package-lock.json @@ -0,0 +1,1178 @@ +{ + "name": "@data-connect/cloud-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@data-connect/cloud-server", + "version": "0.1.0", + "dependencies": { + "express": "^4.21.2", + "http-proxy-middleware": "^3.0.5", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/express": "^5.0.0", + "@types/node": "^22.13.5", + "@types/ws": "^8.5.14", + "typescript": "^5.7.3" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-proxy": { + "version": "1.17.17", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", + "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-middleware": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz", + "integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==", + "license": "MIT", + "dependencies": { + "@types/http-proxy": "^1.17.15", + "debug": "^4.3.6", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.3", + "is-plain-object": "^5.0.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/http-proxy-middleware/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-middleware/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/cloud-server/package.json b/cloud-server/package.json new file mode 100644 index 0000000..92c76a7 --- /dev/null +++ b/cloud-server/package.json @@ -0,0 +1,23 @@ +{ + "name": "@data-connect/cloud-server", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/server.js", + "dev": "tsc --watch", + "test": "tsc && node --test dist/__tests__/" + }, + "dependencies": { + "express": "^4.21.2", + "http-proxy-middleware": "^3.0.5", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/express": "^5.0.0", + "@types/node": "^22.13.5", + "@types/ws": "^8.5.14", + "typescript": "^5.7.3" + } +} diff --git a/cloud-server/src/__tests__/auth.test.ts b/cloud-server/src/__tests__/auth.test.ts new file mode 100644 index 0000000..0f62113 --- /dev/null +++ b/cloud-server/src/__tests__/auth.test.ts @@ -0,0 +1,29 @@ +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert/strict"; + +// We need to import the module fresh for each test context since auth +// generates a token at module load. For now, test the module as loaded. +import { getAuthToken, validateToken } from "../auth.js"; + +describe("auth", () => { + it("getAuthToken returns a non-empty string", () => { + const token = getAuthToken(); + assert.ok(token.length > 0); + }); + + it("getAuthToken returns the same token on repeated calls", () => { + assert.equal(getAuthToken(), getAuthToken()); + }); + + it("validateToken returns true for valid token", () => { + assert.ok(validateToken(getAuthToken())); + }); + + it("validateToken returns false for invalid token", () => { + assert.equal(validateToken("wrong-token"), false); + }); + + it("validateToken returns false for undefined", () => { + assert.equal(validateToken(undefined), false); + }); +}); diff --git a/cloud-server/src/__tests__/invoke.test.ts b/cloud-server/src/__tests__/invoke.test.ts new file mode 100644 index 0000000..25acc5e --- /dev/null +++ b/cloud-server/src/__tests__/invoke.test.ts @@ -0,0 +1,82 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import express from "express"; +import { getAuthToken } from "../auth.js"; +import invokeRoutes from "../routes/invoke.js"; + +describe("invoke routes", () => { + let server: http.Server; + let baseUrl: string; + + before(async () => { + const app = express(); + app.use(express.json()); + app.use("/api/invoke", invokeRoutes); + server = http.createServer(app); + await new Promise((resolve) => { + server.listen(0, () => resolve()); + }); + const addr = server.address() as { port: number }; + baseUrl = `http://localhost:${addr.port}`; + }); + + after(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + it("returns 404 for unknown commands", async () => { + const res = await fetch(`${baseUrl}/api/invoke/nonexistent_command`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + assert.equal(res.status, 404); + const body = await res.json(); + assert.ok(body.error.includes("Unknown command")); + }); + + it("check_platforms returns an array", async () => { + const res = await fetch(`${baseUrl}/api/invoke/check_platforms`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(Array.isArray(body)); + }); + + it("get_server_status returns running true", async () => { + const res = await fetch(`${baseUrl}/api/invoke/get_server_status`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.running, true); + }); + + it("get_app_config returns default config", async () => { + const res = await fetch(`${baseUrl}/api/invoke/get_app_config`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(body.storageProvider); + }); + + it("load_runs returns an array", async () => { + const res = await fetch(`${baseUrl}/api/invoke/load_runs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(Array.isArray(body)); + }); +}); diff --git a/cloud-server/src/auth.ts b/cloud-server/src/auth.ts new file mode 100644 index 0000000..401f5d4 --- /dev/null +++ b/cloud-server/src/auth.ts @@ -0,0 +1,32 @@ +import crypto from "node:crypto"; +import type { Request, Response, NextFunction } from "express"; + +const AUTH_TOKEN = + process.env.AUTH_TOKEN || crypto.randomBytes(32).toString("hex"); + +export function getAuthToken(): string { + return AUTH_TOKEN; +} + +/** Express middleware: require `?token=` query param or `Authorization: Bearer` header. */ +export function authMiddleware( + req: Request, + res: Response, + next: NextFunction, +): void { + const queryToken = req.query.token as string | undefined; + const headerToken = req.headers.authorization?.replace("Bearer ", ""); + const token = queryToken || headerToken; + + if (token === AUTH_TOKEN) { + next(); + return; + } + + res.status(401).json({ error: "Unauthorized" }); +} + +/** Validate a token string (used for WebSocket upgrade). */ +export function validateToken(token: string | undefined): boolean { + return token === AUTH_TOKEN; +} diff --git a/cloud-server/src/cdp-relay.ts b/cloud-server/src/cdp-relay.ts new file mode 100644 index 0000000..401372b --- /dev/null +++ b/cloud-server/src/cdp-relay.ts @@ -0,0 +1,405 @@ +import { WebSocketServer, WebSocket } from "ws"; +import type { Server } from "node:http"; +import { validateToken } from "./auth.js"; + +const CHROMIUM_PORT = 9222; + +/** + * CDP screencast relay. + * + * Connects to a Chromium page target via CDP, starts Page.startScreencast, + * and forwards JPEG frames to connected frontend clients. + * Receives mouse/keyboard events from clients and translates + * them to CDP Input.dispatch*Event calls. + * + * Uses polling to find the connector's page target (skips about:blank + * and chrome:// pages). Retries for up to 15 seconds to handle the case + * where the screencast client connects before the connector creates its page. + */ + +export function setupCdpRelay(server: Server): void { + const wss = new WebSocketServer({ noServer: true }); + + server.on("upgrade", (req, socket, head) => { + const url = new URL(req.url || "/", `http://${req.headers.host}`); + if (url.pathname !== "/ws/screencast") return; + + const token = + url.searchParams.get("token") || + req.headers.authorization?.replace("Bearer ", ""); + + if (!validateToken(token || undefined)) { + socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); + socket.destroy(); + return; + } + + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit("connection", ws, req); + }); + }); + + wss.on("connection", (ws) => { + handleScreencastClient(ws); + }); +} + +interface PageTarget { + id: string; + type: string; + url: string; + webSocketDebuggerUrl: string; +} + +/** Fetch page targets from Chromium's CDP HTTP endpoint. */ +async function getPageTargets(): Promise { + const res = await fetch(`http://127.0.0.1:${CHROMIUM_PORT}/json`); + const targets = (await res.json()) as PageTarget[]; + return targets.filter( + (t) => + t.type === "page" && + t.url !== "about:blank" && + !t.url.startsWith("chrome://"), + ); +} + +/** + * Poll for a non-default page target. Retries up to maxWaitMs to allow + * the connector time to create and navigate its page. + */ +async function waitForConnectorPage(maxWaitMs = 15_000): Promise { + const start = Date.now(); + + while (Date.now() - start < maxWaitMs) { + const pages = await getPageTargets(); + // If there are multiple pages, prefer the last one (most recently created). + // If there's only one non-chrome page, use it. + if (pages.length > 0) { + const target = pages[pages.length - 1]; + console.log( + `[cdp-relay] Found page target: ${target.url} (${pages.length} pages total)`, + ); + return target; + } + await new Promise((r) => setTimeout(r, 500)); + } + + throw new Error("No page target found after waiting"); +} + +async function handleScreencastClient(client: WebSocket): Promise { + let cdp: WebSocket | null = null; + let nextId = 1; + const pending = new Map< + number, + { resolve: (v: unknown) => void; reject: (e: Error) => void } + >(); + + function sendCdp( + method: string, + params: Record = {}, + ): Promise { + return new Promise((resolve, reject) => { + if (!cdp || cdp.readyState !== WebSocket.OPEN) { + reject(new Error("CDP not connected")); + return; + } + const id = nextId++; + pending.set(id, { resolve, reject }); + cdp.send(JSON.stringify({ id, method, params })); + }); + } + + try { + const target = await waitForConnectorPage(); + + // The webSocketDebuggerUrl from Chromium may use 0.0.0.0 — normalize to 127.0.0.1 + const wsUrl = target.webSocketDebuggerUrl.replace( + "ws://0.0.0.0:", + "ws://127.0.0.1:", + ); + console.log(`[cdp-relay] Connecting to: ${wsUrl}`); + cdp = new WebSocket(wsUrl); + + await new Promise((resolve, reject) => { + cdp!.once("open", resolve); + cdp!.once("error", reject); + }); + + console.log("[cdp-relay] Connected, starting screencast"); + + cdp.on("message", (raw) => { + const msg = JSON.parse(raw.toString()); + + if (msg.id && pending.has(msg.id)) { + const { resolve, reject } = pending.get(msg.id)!; + pending.delete(msg.id); + if (msg.error) reject(new Error(msg.error.message)); + else resolve(msg.result); + return; + } + + if (msg.method === "Page.screencastFrame") { + const { data, metadata, sessionId } = msg.params; + if (client.readyState === WebSocket.OPEN) { + client.send( + JSON.stringify({ + type: "frame", + data, + width: metadata.deviceWidth, + height: metadata.deviceHeight, + }), + ); + } + sendCdp("Page.screencastFrameAck", { sessionId }).catch(() => {}); + } + }); + + cdp.on("close", () => { + console.log("[cdp-relay] CDP connection closed"); + if (client.readyState === WebSocket.OPEN) { + client.send(JSON.stringify({ type: "cdp-disconnected" })); + client.close(); + } + }); + + await sendCdp("Page.startScreencast", { + format: "jpeg", + quality: 80, + maxWidth: 1280, + maxHeight: 720, + }); + + console.log("[cdp-relay] Screencast started"); + + client.on("message", (raw) => { + let msg: Record; + try { + msg = JSON.parse(raw.toString()); + } catch { + return; + } + + switch (msg.type) { + case "mouse": + handleMouseEvent(sendCdp, msg); + break; + case "keyboard": + handleKeyboardEvent(sendCdp, msg); + break; + case "stop-screencast": + sendCdp("Page.stopScreencast").catch(() => {}); + break; + } + }); + + client.on("close", () => { + console.log("[cdp-relay] Client disconnected"); + sendCdp("Page.stopScreencast").catch(() => {}); + cdp?.close(); + }); + } catch (err) { + const message = + err instanceof Error ? err.message : "CDP connection failed"; + console.error("[cdp-relay] Error:", message); + if (client.readyState === WebSocket.OPEN) { + client.send(JSON.stringify({ type: "error", message })); + client.close(); + } + cdp?.close(); + } +} + +type CdpSend = ( + method: string, + params: Record, +) => Promise; + +function handleMouseEvent( + sendCdp: CdpSend, + msg: Record, +): void { + const action = msg.action as string; + const x = msg.x as number; + const y = msg.y as number; + const button = (msg.button as string) || "left"; + + const cdpButton = + button === "right" ? "right" : button === "middle" ? "middle" : "left"; + + switch (action) { + case "click": + sendCdp("Input.dispatchMouseEvent", { + type: "mousePressed", + x, + y, + button: cdpButton, + clickCount: 1, + }) + .then(() => + sendCdp("Input.dispatchMouseEvent", { + type: "mouseReleased", + x, + y, + button: cdpButton, + clickCount: 1, + }), + ) + .catch(() => {}); + break; + case "mousemove": + sendCdp("Input.dispatchMouseEvent", { + type: "mouseMoved", + x, + y, + }).catch(() => {}); + break; + case "mousedown": + sendCdp("Input.dispatchMouseEvent", { + type: "mousePressed", + x, + y, + button: cdpButton, + clickCount: 1, + }).catch(() => {}); + break; + case "mouseup": + sendCdp("Input.dispatchMouseEvent", { + type: "mouseReleased", + x, + y, + button: cdpButton, + clickCount: 1, + }).catch(() => {}); + break; + case "scroll": + sendCdp("Input.dispatchMouseEvent", { + type: "mouseWheel", + x, + y, + deltaX: (msg.deltaX as number) || 0, + deltaY: (msg.deltaY as number) || 0, + }).catch(() => {}); + break; + } +} + +/** + * Key definitions ported from Puppeteer's USKeyboardLayout. + * Maps key names to the CDP fields required by Input.dispatchKeyEvent. + * The critical field is `keyCode` (windowsVirtualKeyCode) — without it, + * special keys like Backspace and Delete are silently ignored by Chrome. + */ +interface KeyDef { + keyCode: number; + code: string; + key: string; + text?: string; +} + +const KEY_DEFINITIONS: Record = { + Backspace: { keyCode: 8, code: "Backspace", key: "Backspace" }, + Tab: { keyCode: 9, code: "Tab", key: "Tab" }, + Enter: { keyCode: 13, code: "Enter", key: "Enter", text: "\r" }, + Escape: { keyCode: 27, code: "Escape", key: "Escape" }, + Delete: { keyCode: 46, code: "Delete", key: "Delete" }, + ArrowUp: { keyCode: 38, code: "ArrowUp", key: "ArrowUp" }, + ArrowDown: { keyCode: 40, code: "ArrowDown", key: "ArrowDown" }, + ArrowLeft: { keyCode: 37, code: "ArrowLeft", key: "ArrowLeft" }, + ArrowRight: { keyCode: 39, code: "ArrowRight", key: "ArrowRight" }, + Home: { keyCode: 36, code: "Home", key: "Home" }, + End: { keyCode: 35, code: "End", key: "End" }, + PageUp: { keyCode: 33, code: "PageUp", key: "PageUp" }, + PageDown: { keyCode: 34, code: "PageDown", key: "PageDown" }, + Insert: { keyCode: 45, code: "Insert", key: "Insert" }, + F1: { keyCode: 112, code: "F1", key: "F1" }, + F2: { keyCode: 113, code: "F2", key: "F2" }, + F3: { keyCode: 114, code: "F3", key: "F3" }, + F4: { keyCode: 115, code: "F4", key: "F4" }, + F5: { keyCode: 116, code: "F5", key: "F5" }, + F6: { keyCode: 117, code: "F6", key: "F6" }, + F7: { keyCode: 118, code: "F7", key: "F7" }, + F8: { keyCode: 119, code: "F8", key: "F8" }, + F9: { keyCode: 120, code: "F9", key: "F9" }, + F10: { keyCode: 121, code: "F10", key: "F10" }, + F11: { keyCode: 122, code: "F11", key: "F11" }, + F12: { keyCode: 123, code: "F12", key: "F12" }, +}; + +function handleKeyboardEvent( + sendCdp: CdpSend, + msg: Record, +): void { + const action = msg.action as string; + const key = msg.key as string; + const code = msg.code as string | undefined; + const modifiers = (msg.modifiers ?? {}) as Record; + + // CDP modifier flags bitfield: 1=Alt, 2=Ctrl, 4=Meta, 8=Shift + const modifierFlags = + (modifiers.alt ? 1 : 0) | + (modifiers.ctrl ? 2 : 0) | + (modifiers.meta ? 4 : 0) | + (modifiers.shift ? 8 : 0); + + const hasModifier = modifiers.ctrl || modifiers.meta || modifiers.alt; + const def = KEY_DEFINITIONS[key]; + + switch (action) { + case "keyDown": + if (def) { + // Special key: use rawKeyDown with windowsVirtualKeyCode (required by CDP) + sendCdp("Input.dispatchKeyEvent", { + type: def.text ? "keyDown" : "rawKeyDown", + key: def.key, + code: def.code, + windowsVirtualKeyCode: def.keyCode, + nativeVirtualKeyCode: def.keyCode, + text: def.text ?? "", + unmodifiedText: def.text ?? "", + modifiers: modifierFlags, + }).catch(() => {}); + } else if (hasModifier) { + // Modifier combo (Ctrl+A, Ctrl+C, etc.): dispatch as rawKeyDown + sendCdp("Input.dispatchKeyEvent", { + type: "rawKeyDown", + key, + code, + windowsVirtualKeyCode: key.length === 1 ? key.toUpperCase().charCodeAt(0) : 0, + modifiers: modifierFlags, + }).catch(() => {}); + } else if (key.length === 1) { + // Printable character: use insertText (avoids double-char bugs) + sendCdp("Input.insertText", { text: key }).catch(() => {}); + } + break; + case "keyUp": + if (def) { + sendCdp("Input.dispatchKeyEvent", { + type: "keyUp", + key: def.key, + code: def.code, + windowsVirtualKeyCode: def.keyCode, + nativeVirtualKeyCode: def.keyCode, + modifiers: modifierFlags, + }).catch(() => {}); + } else if (hasModifier) { + sendCdp("Input.dispatchKeyEvent", { + type: "keyUp", + key, + code, + windowsVirtualKeyCode: key.length === 1 ? key.toUpperCase().charCodeAt(0) : 0, + modifiers: modifierFlags, + }).catch(() => {}); + } + break; + case "type": { + // Paste: insert entire string at once + const text = msg.text as string; + if (text) { + sendCdp("Input.insertText", { text }).catch(() => {}); + } + break; + } + } +} diff --git a/cloud-server/src/events.ts b/cloud-server/src/events.ts new file mode 100644 index 0000000..ec4d593 --- /dev/null +++ b/cloud-server/src/events.ts @@ -0,0 +1,57 @@ +import { WebSocketServer, WebSocket } from "ws"; +import type { Server } from "node:http"; +import { validateToken } from "./auth.js"; + +/** + * Event relay — mirrors Tauri's event system over WebSocket. + * + * The playwright-runner child process emits JSON lines on stdout. + * This module forwards those events to connected frontend clients + * at `/ws/events`. + */ + +const clients = new Set(); + +export function setupEventRelay(server: Server): void { + const wss = new WebSocketServer({ noServer: true }); + + server.on("upgrade", (req, socket, head) => { + const url = new URL(req.url || "/", `http://${req.headers.host}`); + if (url.pathname !== "/ws/events") return; + + const token = + url.searchParams.get("token") || + req.headers.authorization?.replace("Bearer ", ""); + + if (!validateToken(token || undefined)) { + socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); + socket.destroy(); + return; + } + + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit("connection", ws, req); + }); + }); + + wss.on("connection", (ws) => { + clients.add(ws); + ws.on("close", () => clients.delete(ws)); + }); +} + +/** + * Broadcast an event to all connected frontend clients. + * Called by the invoke handlers when the playwright-runner emits events. + */ +export function broadcastEvent( + event: string, + payload: unknown, +): void { + const msg = JSON.stringify({ event, payload }); + for (const ws of clients) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(msg); + } + } +} diff --git a/cloud-server/src/routes/invoke.ts b/cloud-server/src/routes/invoke.ts new file mode 100644 index 0000000..04b0f4a --- /dev/null +++ b/cloud-server/src/routes/invoke.ts @@ -0,0 +1,635 @@ +import { Router, type Request, type Response } from "express"; +import fs from "node:fs"; +import path from "node:path"; +import { spawn, execSync, type ChildProcess } from "node:child_process"; +import readline from "node:readline"; +import { broadcastEvent } from "../events.js"; + +const router = Router(); + +const NEKO_ORIGIN = process.env.NEKO_ORIGIN || "http://localhost:8080"; + +interface ConnectorProcess { + child: ChildProcess; + runId: string; + status: string; +} + +const connectorProcesses = new Map(); + +// import.meta.dirname = cloud-server/dist/routes/ at runtime +// Resolve paths relative to the repo root (/app in Docker) +const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); + +const CONNECTORS_DIR = + process.env.CONNECTORS_DIR || path.join(REPO_ROOT, "connectors"); + +const DATA_DIR = + process.env.DATA_DIR || path.join(process.env.HOME || "/data", "data-connect"); + +const PLAYWRIGHT_RUNNER = + process.env.PLAYWRIGHT_RUNNER || + path.join(REPO_ROOT, "playwright-runner/index.cjs"); + +function timestamp(): string { + return new Date().toISOString(); +} + +function sanitizePathComponent(input: string): string { + let sanitized = input.replace(/[/\\\0]/g, ""); + sanitized = sanitized.replace(/\.\./g, ""); + sanitized = sanitized.trim(); + return sanitized || "unknown"; +} + +async function checkPlatforms(): Promise { + return getPlatforms(); +} + +function getPlatforms(): unknown[] { + const platforms: unknown[] = []; + const dirs = [CONNECTORS_DIR]; + + const userDir = path.join( + process.env.HOME || "/root", + ".dataconnect", + "connectors", + ); + if (fs.existsSync(userDir)) dirs.push(userDir); + + for (const dir of dirs) { + if (!fs.existsSync(dir)) continue; + const companies = fs.readdirSync(dir, { withFileTypes: true }); + for (const companyEntry of companies) { + if (!companyEntry.isDirectory()) continue; + const companyDir = path.join(dir, companyEntry.name); + const files = fs.readdirSync(companyDir); + for (const file of files) { + if (!file.endsWith(".json") || file === "connector.json") continue; + const filePath = path.join(companyDir, file); + try { + const content = JSON.parse(fs.readFileSync(filePath, "utf-8")); + const filename = path.basename(file, ".json"); + platforms.push({ + id: content.id || `${filename}-001`, + company: content.company || companyEntry.name, + name: content.name, + filename, + description: content.description, + isUpdated: false, + logoURL: filename, + needsConnection: true, + connectURL: content.connectURL, + connectSelector: content.connectSelector, + exportFrequency: content.exportFrequency, + vectorize_config: content.vectorize_config, + runtime: content.runtime, + }); + } catch { + // skip unparseable metadata + } + } + } + } + + return platforms; +} + +function startConnector(body: Record): void { + const runId = body.runId as string; + const platformId = body.platformId as string; + const filename = body.filename as string; + const company = body.company as string; + const name = body.name as string; + const connectUrl = body.connectUrl as string; + + if (connectorProcesses.has(runId)) { + throw new Error(`Run ${runId} is already active`); + } + + const companyLower = company.toLowerCase(); + let connectorPath = ""; + + const userDir = path.join( + process.env.HOME || "/root", + ".dataconnect", + "connectors", + ); + const userPath = path.join(userDir, companyLower, `${filename}.js`); + if (fs.existsSync(userPath)) { + connectorPath = userPath; + } else { + connectorPath = path.join(CONNECTORS_DIR, companyLower, `${filename}.js`); + } + + if (!fs.existsSync(connectorPath)) { + throw new Error(`Connector script not found: ${connectorPath}`); + } + + const env: Record = { ...process.env as Record }; + // Always set CDP_ENDPOINT so playwright-runner connects to the container's Chromium. + // Use http:// — Playwright's connectOverCDP discovers the WS debugger URL automatically. + env.CDP_ENDPOINT = process.env.CDP_ENDPOINT || "http://127.0.0.1:9222"; + + const child = spawn("node", [PLAYWRIGHT_RUNNER], { + stdio: ["pipe", "pipe", "pipe"], + env, + }); + + const proc: ConnectorProcess = { child, runId, status: "STARTING" }; + connectorProcesses.set(runId, proc); + + broadcastEvent("run-started", { + runId, + platformId, + company, + name, + runtime: "playwright", + }); + + const rl = readline.createInterface({ input: child.stdout! }); + rl.on("line", (line) => { + let msg: Record; + try { + msg = JSON.parse(line); + } catch { + return; + } + + const msgType = msg.type as string; + + switch (msgType) { + case "ready": { + const runCmd = JSON.stringify({ + type: "run", + runId, + connectorPath, + url: connectUrl, + headless: false, + }); + child.stdin!.write(runCmd + "\n"); + broadcastEvent("connector-status", { + runId, + status: { type: "STARTED", message: "Authorizing..." }, + timestamp: timestamp(), + }); + break; + } + case "log": + broadcastEvent("connector-log", { + runId, + message: msg.message, + timestamp: timestamp(), + }); + break; + case "status": { + const status = msg.status; + if (typeof status === "string") { + proc.status = status; + broadcastEvent("connector-status", { + runId, + status: { type: status }, + timestamp: timestamp(), + }); + } else { + proc.status = (status as Record)?.type as string || "UNKNOWN"; + broadcastEvent("connector-status", { + runId, + status, + timestamp: timestamp(), + }); + } + break; + } + case "result": + broadcastEvent("export-complete", { + runId, + platformId, + company, + name, + data: msg.data, + timestamp: timestamp(), + }); + break; + case "error": + broadcastEvent("connector-log", { + runId, + message: `Error: ${msg.message}`, + timestamp: timestamp(), + }); + break; + case "data": + broadcastEvent("connector-data", { + runId, + key: msg.key, + value: msg.value, + timestamp: timestamp(), + }); + break; + } + }); + + const stderrRl = readline.createInterface({ input: child.stderr! }); + stderrRl.on("line", (line) => { + console.log(`[playwright:${runId}] ${line}`); + }); + + child.on("exit", () => { + connectorProcesses.delete(runId); + broadcastEvent("connector-status", { + runId, + status: { type: "STOPPED", message: "Process ended" }, + timestamp: timestamp(), + }); + // Auto-reset after every run so next user gets a clean slate + if (connectorProcesses.size === 0) { + resetSession(); + } + }); +} + +function resetSession(): void { + // Clear exported data + const exportDir = path.join(DATA_DIR, "exported_data"); + if (fs.existsSync(exportDir)) { + fs.rmSync(exportDir, { recursive: true, force: true }); + } + const exportsDir = path.join(DATA_DIR, "exports"); + if (fs.existsSync(exportsDir)) { + fs.rmSync(exportsDir, { recursive: true, force: true }); + } + // Clear Chromium profile and restart via supervisord + const profileDir = "/home/neko/.config/chromium"; + if (fs.existsSync(profileDir)) { + fs.rmSync(profileDir, { recursive: true, force: true }); + } + try { + execSync("supervisorctl restart chromium", { timeout: 10000 }); + } catch { /* may not have perms, chromium will auto-restart */ } +} + +function stopConnector(body: Record): void { + const runId = body.runId as string; + const proc = connectorProcesses.get(runId); + if (!proc) return; + + try { + proc.child.stdin!.write(JSON.stringify({ type: "quit" }) + "\n"); + } catch { + // stdin may be closed + } + + setTimeout(() => { + try { + proc.child.kill("SIGTERM"); + } catch { + // already dead + } + }, 3000); +} + +function getConnectorStatus(body: Record): unknown { + const runId = body.runId as string; + const proc = connectorProcesses.get(runId); + if (!proc) return { running: false, status: null }; + return { running: true, status: proc.status }; +} + +function writeExportData(body: Record): string { + const runId = body.runId as string; + const platformId = body.platformId as string; + const company = sanitizePathComponent(body.company as string); + const name = sanitizePathComponent( + (body.name as string) || platformId, + ); + const data = body.data as string; + const ts = Math.floor(Date.now() / 1000); + + const dir = path.join(DATA_DIR, "exported_data", company, name, runId); + fs.mkdirSync(dir, { recursive: true }); + + const content = JSON.parse(data); + const exportData = { company, name, run_id: runId, timestamp: ts, content }; + const filePath = path.join(dir, `${platformId}_${ts}.json`); + fs.writeFileSync(filePath, JSON.stringify(exportData, null, 2)); + + return filePath; +} + +function loadRuns(): unknown[] { + const dataDir = path.join(DATA_DIR, "exported_data"); + if (!fs.existsSync(dataDir)) return []; + + const runs: Array> = []; + + for (const companyEntry of readdirSafe(dataDir)) { + const companyPath = path.join(dataDir, companyEntry); + if (!fs.statSync(companyPath).isDirectory()) continue; + + for (const platformEntry of readdirSafe(companyPath)) { + const platformPath = path.join(companyPath, platformEntry); + if (!fs.statSync(platformPath).isDirectory()) continue; + + for (const runEntry of readdirSafe(platformPath)) { + const runPath = path.join(platformPath, runEntry); + if (!fs.statSync(runPath).isDirectory()) continue; + + const jsonFiles = readdirSafe(runPath).filter((f) => + f.endsWith(".json"), + ); + if (jsonFiles.length === 0) continue; + + let latestFile = jsonFiles[0]; + let latestTs = 0; + for (const f of jsonFiles) { + const parts = path.basename(f, ".json").split("_"); + const ts = parseInt(parts[parts.length - 1], 10); + if (ts > latestTs) { + latestTs = ts; + latestFile = f; + } + } + + try { + const content = JSON.parse( + fs.readFileSync(path.join(runPath, latestFile), "utf-8"), + ); + const startDate = + latestTs > 0 + ? new Date(latestTs * 1000).toISOString() + : new Date().toISOString(); + + runs.push({ + id: runEntry, + platformId: platformEntry, + filename: platformEntry, + company: companyEntry, + name: content.name || platformEntry, + startDate, + endDate: startDate, + status: "success", + exportPath: runPath, + itemsExported: content.itemsExported ?? null, + itemLabel: content.itemLabel ?? null, + syncedToPersonalServer: content.syncedToPersonalServer ?? null, + }); + } catch { + // skip unparseable files + } + } + } + } + + runs.sort((a, b) => + (b.startDate as string).localeCompare(a.startDate as string), + ); + return runs; +} + +function readdirSafe(dir: string): string[] { + try { + return fs.readdirSync(dir); + } catch { + return []; + } +} + +function getServerStatus(): unknown { + return { running: true, port: null }; +} + +function getAppConfig(): unknown { + const configPath = path.join( + process.env.HOME || "/root", + ".dataconnect", + "config.json", + ); + if (!fs.existsSync(configPath)) { + return { + storageProvider: "local", + serverMode: "cloud", + selfHostedUrl: null, + }; + } + return JSON.parse(fs.readFileSync(configPath, "utf-8")); +} + +function setAppConfig(body: Record): void { + const configDir = path.join( + process.env.HOME || "/root", + ".dataconnect", + ); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, "config.json"), + JSON.stringify(body.config, null, 2), + ); +} + +function loadRunExportData(body: Record): unknown { + const exportPath = body.exportPath as string; + if (!exportPath) return null; + const dataPath = path.join(exportPath, "export.json"); + if (!fs.existsSync(dataPath)) return null; + return JSON.parse(fs.readFileSync(dataPath, "utf-8")); +} + +function loadLatestSourceExportPreview(body: Record): unknown { + const company = sanitizePathComponent(body.company as string); + const platformName = sanitizePathComponent(body.name as string); + const scope = body.scope ? sanitizePathComponent(body.scope as string) : null; + const baseDir = path.join(DATA_DIR, "exports", company, platformName); + if (!fs.existsSync(baseDir)) return null; + const runs = readdirSafe(baseDir).sort().reverse(); + for (const run of runs) { + const exportFile = scope + ? path.join(baseDir, run, scope, "export.json") + : path.join(baseDir, run, "export.json"); + if (fs.existsSync(exportFile)) { + const data = JSON.parse(fs.readFileSync(exportFile, "utf-8")); + return { ...data, exportPath: path.dirname(exportFile) }; + } + } + return null; +} + +function loadLatestSourceExportFull(body: Record): unknown { + const company = sanitizePathComponent(body.company as string); + const platformName = sanitizePathComponent(body.name as string); + const scope = body.scope ? sanitizePathComponent(body.scope as string) : null; + const baseDir = path.join(DATA_DIR, "exports", company, platformName); + if (!fs.existsSync(baseDir)) return null; + const runs = readdirSafe(baseDir).sort().reverse(); + for (const run of runs) { + const exportFile = scope + ? path.join(baseDir, run, scope, "export.json") + : path.join(baseDir, run, "export.json"); + if (fs.existsSync(exportFile)) { + return fs.readFileSync(exportFile, "utf-8"); + } + } + return null; +} + +function deleteExportedRun(body: Record): unknown { + const exportPath = body.exportPath as string; + if (!exportPath) return { ok: false }; + if (fs.existsSync(exportPath)) { + fs.rmSync(exportPath, { recursive: true, force: true }); + } + return { ok: true }; +} + +const COMMAND_MAP: Record< + string, + (body: Record) => unknown +> = { + check_platforms: () => checkPlatforms(), + get_platforms: () => getPlatforms(), + start_connector_run: (b) => { + startConnector(b); + return { ok: true }; + }, + stop_connector: (b) => { + stopConnector(b); + return { ok: true }; + }, + get_connector_status: (b) => getConnectorStatus(b), + write_export_data: (b) => writeExportData(b), + load_runs: () => loadRuns(), + get_server_status: () => getServerStatus(), + get_personal_server_status: () => ({ running: false, port: null }), + get_app_config: () => getAppConfig(), + set_app_config: (b) => { + setAppConfig(b); + return { ok: true }; + }, + check_connected_platforms: () => ({}), + start_personal_server: () => { + throw new Error("Personal server is not available in cloud mode"); + }, + stop_personal_server: () => ({ ok: true }), + check_browser_available: () => ({ + installed: true, + version: "chromium", + needs_download: false, + }), + download_browser: () => ({ ok: true }), + get_user_data_path: () => process.env.DATA_DIR || "/data", + get_log_path: () => + path.join(process.env.DATA_DIR || "/data", "logs", "cloud-server.log"), + open_folder: () => ({ ok: true }), + open_platform_export_folder: () => ({ ok: true }), + test_nodejs: () => ({ success: true, version: process.version }), + debug_connector_paths: () => ({ + connectors_dir: CONNECTORS_DIR, + data_dir: DATA_DIR, + playwright_runner: PLAYWRIGHT_RUNNER, + }), + set_screen_resolution: async (b) => { + const { width, height } = b as { width: number; height: number }; + + // Login to n.eko to get a session token + const loginRes = await fetch(`${NEKO_ORIGIN}/api/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: "user", password: "x" }), + }); + if (!loginRes.ok) throw new Error("Failed to login to n.eko"); + const { token: nekoToken } = (await loginRes.json()) as { token: string }; + const authHeaders = { + "Content-Type": "application/json", + Authorization: `Bearer ${nekoToken}`, + }; + + // Get available configurations and find the closest match + const cfgRes = await fetch( + `${NEKO_ORIGIN}/api/room/screen/configurations`, + { headers: authHeaders }, + ); + if (!cfgRes.ok) throw new Error("Failed to get screen configurations"); + const configs = (await cfgRes.json()) as { + width: number; + height: number; + rate: number; + }[]; + + // Score each config: prefer matching aspect ratio and closest area + const targetArea = width * height; + const targetRatio = width / height; + let best = configs[0]; + let bestScore = Infinity; + for (const cfg of configs) { + const ratioErr = Math.abs(cfg.width / cfg.height - targetRatio); + const areaErr = + Math.abs(cfg.width * cfg.height - targetArea) / targetArea; + const score = ratioErr * 2 + areaErr; + if (score < bestScore) { + bestScore = score; + best = cfg; + } + } + + const res = await fetch(`${NEKO_ORIGIN}/api/room/screen`, { + method: "POST", + headers: authHeaders, + body: JSON.stringify(best), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Failed to set resolution (${res.status}): ${text}`); + } + return res.json(); + }, + list_browser_sessions: () => [], + clear_browser_session: () => ({ ok: true }), + check_connector_updates: () => [], + download_connector: () => ({ ok: true }), + load_run_export_data: (b) => loadRunExportData(b), + load_latest_source_export_preview: (b) => loadLatestSourceExportPreview(b), + load_latest_source_export_full: (b) => loadLatestSourceExportFull(b), + delete_exported_run: (b) => deleteExportedRun(b), + mark_export_synced: () => ({ ok: true }), + stop_connector_run: (b) => { + stopConnector(b); + return { ok: true }; + }, + reset_session: () => { + // Kill any active connector processes + for (const [runId, proc] of connectorProcesses) { + try { proc.child.kill("SIGKILL"); } catch { /* already dead */ } + connectorProcesses.delete(runId); + } + resetSession(); + return { ok: true, message: "Session reset" }; + }, +}; + +router.post("/:command", (req: Request, res: Response) => { + const command = req.params.command; + const handler = COMMAND_MAP[command as string]; + + if (!handler) { + res.status(404).json({ error: `Unknown command: ${command}` }); + return; + } + + try { + const result = handler(req.body || {}); + + if (result instanceof Promise) { + result + .then((data) => res.json(data)) + .catch((err: Error) => + res.status(500).json({ error: err.message }), + ); + } else { + res.json(result); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + res.status(500).json({ error: message }); + } +}); + +export default router; diff --git a/cloud-server/src/server.ts b/cloud-server/src/server.ts new file mode 100644 index 0000000..ac01e30 --- /dev/null +++ b/cloud-server/src/server.ts @@ -0,0 +1,73 @@ +import express from "express"; +import http from "node:http"; +import net from "node:net"; +import path from "node:path"; +import { createProxyMiddleware } from "http-proxy-middleware"; +import { getAuthToken, authMiddleware } from "./auth.js"; +import { setupCdpRelay } from "./cdp-relay.js"; +import { setupEventRelay } from "./events.js"; +import invokeRoutes from "./routes/invoke.js"; + +const PORT = parseInt(process.env.PORT || "3000", 10); +const NEKO_ORIGIN = process.env.NEKO_ORIGIN || "http://localhost:8080"; +const STATIC_DIR = + process.env.STATIC_DIR || path.resolve(import.meta.dirname, "../../dist"); + +const app = express(); +const server = http.createServer(app); + +// Reverse-proxy n.eko so the iframe is same-origin (fixes clipboard access) +const nekoProxy = createProxyMiddleware({ + target: NEKO_ORIGIN, + changeOrigin: true, + pathRewrite: { "^/neko": "" }, +}); +app.use("/neko", nekoProxy); + +app.use(express.json({ limit: "50mb" })); +app.use("/api/invoke", authMiddleware, invokeRoutes); + +app.get("/health", (_req, res) => { + res.json({ status: "ok" }); +}); + +app.get("/api/version", (_req, res) => { + res.json({ version: "0.1.0-cloud" }); +}); + +app.use(express.static(STATIC_DIR)); + +app.get("*", (_req, res) => { + res.sendFile(path.join(STATIC_DIR, "index.html")); +}); + +setupEventRelay(server); +setupCdpRelay(server); + +// Forward n.eko WebSocket upgrades through the proxy +server.on("upgrade", (req, socket, head) => { + if (!(socket instanceof net.Socket)) return; + const url = new URL(req.url || "/", `http://${req.headers.host}`); + if (url.pathname.startsWith("/neko")) { + nekoProxy.upgrade!(req, socket, head); + } +}); + +server.listen(PORT, () => { + const token = getAuthToken(); + console.log(`\n Cloud Connector Runtime`); + console.log(` -----------------------`); + console.log(` Local: http://localhost:${PORT}/?token=${token}`); + console.log(` Health: http://localhost:${PORT}/health`); + console.log(` Token: ${token}\n`); +}); + +process.on("SIGTERM", () => { + console.log("[server] SIGTERM received, shutting down"); + server.close(() => process.exit(0)); +}); + +process.on("SIGINT", () => { + console.log("[server] SIGINT received, shutting down"); + server.close(() => process.exit(0)); +}); diff --git a/cloud-server/supervisord/api-server.conf b/cloud-server/supervisord/api-server.conf new file mode 100644 index 0000000..3b4df85 --- /dev/null +++ b/cloud-server/supervisord/api-server.conf @@ -0,0 +1,9 @@ +[program:api-server] +environment=HOME="/home/%(ENV_USER)s",USER="%(ENV_USER)s",DISPLAY="%(ENV_DISPLAY)s" +command=/usr/bin/node /app/cloud-server/dist/server.js +autorestart=true +priority=900 +user=%(ENV_USER)s +stdout_logfile=/var/log/neko/api-server.log +stdout_logfile_maxbytes=100MB +redirect_stderr=true diff --git a/cloud-server/tsconfig.json b/cloud-server/tsconfig.json new file mode 100644 index 0000000..d16db04 --- /dev/null +++ b/cloud-server/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "sourceMap": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..fdff4f1 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,41 @@ +services: + data-connect-cloud: + build: + context: . + dockerfile: cloud-server/Dockerfile + ports: + - "3000:3000" + - "8090:8080" # n.eko direct (debug keyboard) + - "9222:9222" # CDP debugging + - "59000:59000/udp" + - "59000:59000/tcp" + volumes: + - profile-data:/data + shm_size: "2gb" + develop: + watch: + - action: rebuild + path: ./cloud-server/src + - action: sync + path: ./connectors + target: /app/connectors + - action: sync + path: ./dist + target: /app/dist + environment: + - NODE_ENV=development + - AUTH_TOKEN=dev-token-12345 + - NEKO_SERVER_BIND=0.0.0.0:8080 + - NEKO_LEGACY=false + - NEKO_WEBRTC_UDPMUX=59000 + - NEKO_WEBRTC_TCPMUX=59000 + - NEKO_WEBRTC_ICELITE=1 + - NEKO_WEBRTC_NAT1TO1=${PUBLIC_IP:-127.0.0.1} + - NEKO_MEMBER_PROVIDER=noauth + - NEKO_SESSION_IMPLICIT_HOSTING=true + - NEKO_IMPLICITCONTROL=true + - NEKO_DESKTOP_SCREEN=${NEKO_DESKTOP_SCREEN:-390x844@30} + - CHROMIUM_MOBILE_FLAGS=${CHROMIUM_MOBILE_FLAGS:---user-agent=Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1} + +volumes: + profile-data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3bd3dc7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +services: + cloud-connector: + build: + context: . + dockerfile: cloud-server/Dockerfile + shm_size: "2gb" + ports: + - "3000:3000" + - "59000:59000/udp" + - "59000:59000/tcp" + environment: + NEKO_SERVER_BIND: "0.0.0.0:8080" + NEKO_LEGACY: "true" + NEKO_WEBRTC_UDPMUX: 59000 + NEKO_WEBRTC_TCPMUX: 59000 + NEKO_WEBRTC_ICELITE: 1 + NEKO_WEBRTC_NAT1TO1: "${PUBLIC_IP}" + NEKO_MEMBER_PROVIDER: "noauth" + NEKO_SESSION_IMPLICIT_HOSTING: "true" + NEKO_IMPLICITCONTROL: "true" + NEKO_DESKTOP_SCREEN: "1280x720@30" + AUTH_TOKEN: "${AUTH_TOKEN:-dev-test-token}" + NODE_ENV: production + volumes: + - profile-data:/home/neko/.config/chromium + - export-data:/data/exports + restart: unless-stopped + +volumes: + profile-data: + export-data: diff --git a/docs/embrowse-data-ingestion-gaps.md b/docs/embrowse-data-ingestion-gaps.md new file mode 100644 index 0000000..c888fd5 --- /dev/null +++ b/docs/embrowse-data-ingestion-gaps.md @@ -0,0 +1,35 @@ +# Embrowse data ingestion: open questions and gaps + +## Context + +For the demo flow, Embrowse runs inside an iframe on account.vana.org. After scraping Instagram, it needs to POST data to the user's Personal Server at `POST /v1/data/instagram.ads`. + +## Gap 1: No auth on data write endpoint + +`POST /v1/data/{scope}` is unauthenticated — only validates against the schema. +This is fine when the caller is on localhost (desktop app), but when Embrowse posts over the public tunnel URL, anyone who discovers the URL can write arbitrary (schema-valid) data. + +**TODO:** Add auth to the data write endpoint. Options: +- Require the same `Web3Signed` header used for reads +- Use a short-lived token issued during the connect flow +- Accept a session-scoped bearer token from the session relay + +**Relevant code:** +- Data ingest service: [`src/services/personalServerIngest.ts`](https://github.com/vana-com/data-connect/blob/main/src/services/personalServerIngest.ts) +- Personal Server wrapper (endpoint handler): [`personal-server/index.js`](https://github.com/vana-com/data-connect/blob/main/personal-server/index.js) +- Protocol spec (endpoint access control): [`data-portability-spec.md` lines 311-363](https://github.com/vana-com/data-connect/blob/main/data-portability-spec.md#L311-L363) + +## Gap 2: Embrowse-to-parent communication protocol + +Embrowse runs in an iframe on account.vana.org. It needs to: +1. **Receive configuration from the parent** — which platform to scrape, where to POST results (i.e. the Personal Server tunnel URL), auth tokens, scopes +2. **Signal progress/completion back to the parent** — scraping status, success/failure, data summary + +The Personal Server URL question is part of this: account.vana.org is the management component and orchestrates the flow, so it should know the user's PS address (via gateway lookup, session relay metadata, or its own provisioning records) and pass it to Embrowse as config. + +No protocol for this exists yet. Likely `postMessage` between iframe and parent, but the message contract needs defining. + +**Relevant code:** +- Desktop ingest (hardcodes localhost): [`src/services/personalServerIngest.ts`](https://github.com/vana-com/data-connect/blob/main/src/services/personalServerIngest.ts) +- Tunnel URL event: [`src/hooks/usePersonalServer.ts` line 227](https://github.com/vana-com/data-connect/blob/main/src/hooks/usePersonalServer.ts#L227) +- PS self-registers with gateway, so the URL is discoverable after auth diff --git a/public/mock-embrowse.html b/public/mock-embrowse.html new file mode 100644 index 0000000..bbbe31e --- /dev/null +++ b/public/mock-embrowse.html @@ -0,0 +1,152 @@ + + + + + Mock Embrowse + + + +

Mock Embrowse

+
Waiting for config...
+
+ + +
+

+
+  
+
+
diff --git a/scripts/validate-cloud-connector.sh b/scripts/validate-cloud-connector.sh
new file mode 100755
index 0000000..4772c04
--- /dev/null
+++ b/scripts/validate-cloud-connector.sh
@@ -0,0 +1,131 @@
+#!/usr/bin/env bash
+# Validate the cloud connector Docker image end-to-end.
+# Run on host (not in devcontainer) — requires Docker.
+set -euo pipefail
+
+CONTAINER_NAME="dc-validate-$$"
+IMAGE_NAME="data-connect-cloud"
+TEST_TOKEN="dev-test-token"
+PASS=0
+FAIL=0
+RESULTS=()
+
+pass() { PASS=$((PASS + 1)); RESULTS+=("PASS: $1"); echo "  PASS: $1"; }
+fail() { FAIL=$((FAIL + 1)); RESULTS+=("FAIL: $1"); echo "  FAIL: $1"; }
+
+cleanup() {
+  echo ""
+  echo "=== Cleaning up ==="
+  docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
+}
+trap cleanup EXIT
+
+echo "=== Building frontend ==="
+npm run build
+
+echo ""
+echo "=== Building Docker image ==="
+docker build -f cloud-server/Dockerfile -t "$IMAGE_NAME" .
+
+echo ""
+echo "=== Starting container ==="
+docker run -d --name "$CONTAINER_NAME" --shm-size=2g \
+  -p 3000:3000 -p 59000:59000/udp -p 59000:59000/tcp \
+  -e NEKO_SERVER_BIND=0.0.0.0:8080 \
+  -e NEKO_LEGACY=true \
+  -e NEKO_WEBRTC_UDPMUX=59000 \
+  -e NEKO_WEBRTC_TCPMUX=59000 \
+  -e NEKO_WEBRTC_ICELITE=1 \
+  -e NEKO_WEBRTC_NAT1TO1=127.0.0.1 \
+  -e NEKO_MEMBER_PROVIDER=noauth \
+  -e NEKO_SESSION_IMPLICIT_HOSTING=true \
+  -e NEKO_IMPLICITCONTROL=true \
+  -e NEKO_DESKTOP_SCREEN=1280x720@30 \
+  -e AUTH_TOKEN="$TEST_TOKEN" \
+  "$IMAGE_NAME"
+
+echo ""
+echo "=== Waiting for services ==="
+
+# Check 1: API server health (port 3000)
+echo "Checking API server..."
+for i in $(seq 1 30); do
+  if curl -sf http://localhost:3000/health >/dev/null 2>&1; then
+    pass "API server health (port 3000)"
+    break
+  fi
+  if [ "$i" -eq 30 ]; then
+    fail "API server health (port 3000) — not ready after 60s"
+  fi
+  sleep 2
+done
+
+# Check 2: n.eko via reverse proxy (/neko)
+echo "Checking n.eko via proxy..."
+for i in $(seq 1 30); do
+  if curl -sf http://localhost:3000/neko/ >/dev/null 2>&1; then
+    pass "n.eko via reverse proxy (/neko)"
+    break
+  fi
+  if [ "$i" -eq 30 ]; then
+    fail "n.eko via reverse proxy (/neko) — not ready after 60s"
+  fi
+  sleep 2
+done
+
+# Check 3: CDP endpoint (inside container)
+echo "Checking CDP endpoint (inside container)..."
+for i in $(seq 1 20); do
+  if timeout 5 docker exec "$CONTAINER_NAME" node -e "fetch('http://127.0.0.1:9222/json/version',{signal:AbortSignal.timeout(3000)}).then(r=>r.json()).then(d=>{console.log(d.Browser);process.exit(0)}).catch(()=>process.exit(1))" 2>/dev/null; then
+    pass "CDP endpoint (internal port 9222)"
+    break
+  fi
+  if [ "$i" -eq 20 ]; then
+    fail "CDP endpoint (internal port 9222) — not reachable after 40s"
+    echo "  Debug: checking if chromium is running..."
+    docker exec "$CONTAINER_NAME" supervisorctl status 2>/dev/null || true
+  fi
+  sleep 1
+done
+
+# Check 4: Frontend is served
+echo "Checking frontend..."
+if curl -sf http://localhost:3000/ | grep -q '
/dev/null; then + pass "Frontend served at port 3000" +else + fail "Frontend served at port 3000 — index.html not found or missing root div" +fi + +# Check 5: API version endpoint +echo "Checking API version..." +if curl -sf http://localhost:3000/api/version | grep -q 'cloud' 2>/dev/null; then + pass "API version endpoint returns cloud version" +else + fail "API version endpoint — unexpected response" +fi + +echo "" +echo "===============================" +echo " Results: $PASS passed, $FAIL failed" +echo "===============================" +for r in "${RESULTS[@]}"; do + echo " $r" +done +echo "" + +if [ "$FAIL" -gt 0 ]; then + echo "Some checks failed. Inspect container logs with:" + echo " docker logs $CONTAINER_NAME" + exit 1 +fi + +echo "All checks passed." +echo "Container '$CONTAINER_NAME' is still running." +echo "" +echo "Open http://localhost:3000/?token=$TEST_TOKEN in your browser to test the UI" +echo "n.eko is proxied at http://localhost:3000/neko/" +echo "" +echo "To stop: docker rm -f $CONTAINER_NAME" + +# Disable cleanup so the container stays running for manual testing +trap - EXIT diff --git a/src/App.tsx b/src/App.tsx index e3edc5f..dfbce39 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -38,6 +38,9 @@ const Grant = lazy(() => const Connect = lazy(() => import("./pages/connect").then(m => ({ default: m.Connect })) ) +const Embrowse = lazy(() => + import("./pages/embrowse").then(m => ({ default: m.Embrowse })) +) function AppContent() { useEvents() @@ -67,6 +70,7 @@ function AppContent() { } /> } /> } /> + } /> } /> diff --git a/src/components/navigation/top-nav.tsx b/src/components/navigation/top-nav.tsx index 0ba68df..e7ddfdc 100644 --- a/src/components/navigation/top-nav.tsx +++ b/src/components/navigation/top-nav.tsx @@ -10,12 +10,12 @@ import { import { ROUTES } from "@/config/routes" import { cn } from "@/lib/classes" import type { LucideIcon } from "lucide-react" -import { HomeIcon, ServerIcon, UserRoundCogIcon, BoxIcon } from "lucide-react" +import { HomeIcon, ServerIcon, UserRoundCogIcon, BoxIcon, GlobeIcon } from "lucide-react" import type { CSSProperties } from "react" import { NavLink } from "react-router-dom" type NavItem = { - id: "home" | "apps" | "docs" | "server" | "settings" + id: "home" | "apps" | "docs" | "server" | "settings" | "embrowse" to: string label: string Icon: LucideIcon | React.ComponentType<{ className?: string }> @@ -43,6 +43,7 @@ const navItems: NavItem[] = [ Icon: ServerIcon, }, { id: "apps", to: ROUTES.apps, label: "Apps", Icon: BoxIcon }, + { id: "embrowse", to: ROUTES.embrowse, label: "Embrowse", Icon: GlobeIcon }, { id: "settings", to: ROUTES.settings, diff --git a/src/config/routes.ts b/src/config/routes.ts index f6b1e97..9db3c5e 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -7,6 +7,7 @@ export const ROUTES = { source: "/sources/:platformId", settings: "/settings", connect: "/connect", + embrowse: "/embrowse", grant: "/grant", } as const diff --git a/src/lib/embrowse-protocol.ts b/src/lib/embrowse-protocol.ts new file mode 100644 index 0000000..21f0398 --- /dev/null +++ b/src/lib/embrowse-protocol.ts @@ -0,0 +1,144 @@ +/** + * Embrowse communication protocol. + * + * Modeled after Plaid Link / Stripe Elements: the parent page sends + * configuration via postMessage, Embrowse reports lifecycle events back. + * + * Supports both iframe and popup modes. Popup is preferred when Embrowse + * needs cross-origin isolation (COOP/COEP) for SharedArrayBuffer / WASM, + * which browsers block inside iframes on non-isolated parent pages. + * + * Origin checking is enforced on both sides. + */ + +// -- Messages: Parent → Embrowse ------------------------------------ + +export interface EmbrowseInitMessage { + type: "embrowse:init" + /** Platform to scrape (e.g. "instagram") */ + platform: string + /** Scopes to collect (e.g. ["instagram.ads", "instagram.profile"]) */ + scopes: string[] + /** Personal Server URL to POST results to */ + serverUrl: string + /** Auth token for the Personal Server (when not localhost) */ + serverAuthToken?: string +} + +export type ParentToEmbrowseMessage = EmbrowseInitMessage + +// -- Messages: Embrowse → Parent ------------------------------------ + +export interface EmbrowseReadyMessage { + type: "embrowse:ready" +} + +export interface EmbrowseProgressMessage { + type: "embrowse:progress" + status: string +} + +export interface EmbrowseCompleteMessage { + type: "embrowse:complete" + /** Scopes that were successfully ingested */ + scopes: string[] +} + +export interface EmbrowseErrorMessage { + type: "embrowse:error" + message: string +} + +export interface EmbrowseCancelMessage { + type: "embrowse:cancel" +} + +export type EmbrowseToParentMessage = + | EmbrowseReadyMessage + | EmbrowseProgressMessage + | EmbrowseCompleteMessage + | EmbrowseErrorMessage + | EmbrowseCancelMessage + +// -- Parent-side helpers -------------------------------------------- + +const EMBROWSE_MESSAGE_TYPES = new Set([ + "embrowse:ready", + "embrowse:progress", + "embrowse:complete", + "embrowse:error", + "embrowse:cancel", +]) + +function isEmbrowseMessage(data: unknown): data is EmbrowseToParentMessage { + return ( + typeof data === "object" && + data !== null && + "type" in data && + typeof (data as { type: unknown }).type === "string" && + EMBROWSE_MESSAGE_TYPES.has((data as { type: string }).type) + ) +} + +export interface EmbrowseHostOptions { + /** The target window — either an iframe's contentWindow or a popup */ + target: Window + /** The origin of the Embrowse window (e.g. "https://embrowse.vana.org") */ + embrowseOrigin: string + onReady?: () => void + onProgress?: (status: string) => void + onComplete?: (scopes: string[]) => void + onError?: (message: string) => void + onCancel?: () => void +} + +/** + * Listen for Embrowse lifecycle messages and send init config on ready. + * Works with both iframes and popups (any Window reference). + * Returns a cleanup function (call on unmount / close). + */ +export function connectEmbrowse( + options: EmbrowseHostOptions, + config: Omit +): () => void { + const { + target, + embrowseOrigin, + onReady, + onProgress, + onComplete, + onError, + onCancel, + } = options + + function handleMessage(event: MessageEvent) { + if (event.origin !== embrowseOrigin) return + if (!isEmbrowseMessage(event.data)) return + + switch (event.data.type) { + case "embrowse:ready": + onReady?.() + // Send config once Embrowse signals it's ready + target.postMessage( + { type: "embrowse:init", ...config } satisfies EmbrowseInitMessage, + embrowseOrigin + ) + break + case "embrowse:progress": + onProgress?.(event.data.status) + break + case "embrowse:complete": + onComplete?.(event.data.scopes) + break + case "embrowse:error": + onError?.(event.data.message) + break + case "embrowse:cancel": + onCancel?.() + break + } + } + + window.addEventListener("message", handleMessage) + return () => window.removeEventListener("message", handleMessage) +} diff --git a/src/lib/runtime/context.test.tsx b/src/lib/runtime/context.test.tsx new file mode 100644 index 0000000..3e50226 --- /dev/null +++ b/src/lib/runtime/context.test.tsx @@ -0,0 +1,61 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest" +import { renderHook } from "@testing-library/react" +import type { ReactNode } from "react" + +describe("runtime detection", () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + delete (window as { __TAURI__?: unknown }).__TAURI__ + delete (window as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ + }) + + it("detects Tauri runtime when __TAURI__ is present", async () => { + Object.defineProperty(window, "__TAURI__", { + configurable: true, + value: {}, + }) + + const { isTauri } = await import("./context") + expect(isTauri).toBe(true) + }) + + it("detects Tauri runtime when __TAURI_INTERNALS__ is present", async () => { + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + value: {}, + }) + + const { isTauri } = await import("./context") + expect(isTauri).toBe(true) + }) + + it("detects HTTP runtime when neither __TAURI__ nor __TAURI_INTERNALS__ is present", async () => { + const { isTauri } = await import("./context") + expect(isTauri).toBe(false) + }) + + it("useRuntime throws when used outside RuntimeProvider", async () => { + const { useRuntime } = await import("./context") + + expect(() => { + renderHook(() => useRuntime()) + }).toThrow("useRuntime must be used within a RuntimeProvider") + }) + + it("useRuntime returns a runtime when used inside RuntimeProvider", async () => { + const { useRuntime, RuntimeProvider } = await import("./context") + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + + const { result } = renderHook(() => useRuntime(), { wrapper }) + expect(result.current).toBeDefined() + expect(result.current.mode).toBe("http") // No __TAURI__ in test env + expect(typeof result.current.invoke).toBe("function") + expect(typeof result.current.onEvent).toBe("function") + }) +}) diff --git a/src/lib/runtime/context.tsx b/src/lib/runtime/context.tsx new file mode 100644 index 0000000..9a2ce7e --- /dev/null +++ b/src/lib/runtime/context.tsx @@ -0,0 +1,29 @@ +import { createContext, useContext, useMemo, type ReactNode } from "react" +import type { Runtime } from "./types" +import { createTauriRuntime } from "./tauri-runtime" +import { createHttpRuntime } from "./http-runtime" + +const RuntimeContext = createContext(null) + +export const isTauri = + typeof window !== "undefined" && + ("__TAURI__" in window || "__TAURI_INTERNALS__" in window) + +export function RuntimeProvider({ children }: { children: ReactNode }) { + const runtime = useMemo( + () => (isTauri ? createTauriRuntime() : createHttpRuntime()), + [] + ) + + return ( + {children} + ) +} + +export function useRuntime(): Runtime { + const runtime = useContext(RuntimeContext) + if (!runtime) { + throw new Error("useRuntime must be used within a RuntimeProvider") + } + return runtime +} diff --git a/src/lib/runtime/http-runtime.ts b/src/lib/runtime/http-runtime.ts new file mode 100644 index 0000000..ca8f184 --- /dev/null +++ b/src/lib/runtime/http-runtime.ts @@ -0,0 +1,156 @@ +import type { Runtime } from "./types" + +/** + * HTTP runtime — uses fetch() for commands and WebSocket for events. + * Used in cloud mode when the frontend is served by the API server. + */ +export function createHttpRuntime(): Runtime { + let eventSocket: WebSocket | null = null + let socketReady: Promise | null = null + const eventHandlers = new Map void>>() + + // Capture token once at creation time — BrowserRouter may strip query params on navigation + const token = + new URLSearchParams(window.location.search).get("token") ?? + sessionStorage.getItem("cloud_auth_token") + if (token) { + sessionStorage.setItem("cloud_auth_token", token) + } + + function getEventSocket(): Promise { + if (socketReady) return socketReady + + socketReady = new Promise((resolve, reject) => { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:" + + const qs = token ? `?token=${encodeURIComponent(token)}` : "" + const wsUrl = `${protocol}//${window.location.host}/ws/events${qs}` + const ws = new WebSocket(wsUrl) + + ws.onopen = () => { + eventSocket = ws + resolve() + } + + ws.onerror = () => { + reject(new Error("WebSocket connection failed")) + } + + ws.onmessage = (msg) => { + try { + const { event, payload } = JSON.parse(msg.data) as { + event: string + payload: unknown + } + const handlers = eventHandlers.get(event) + if (handlers) { + for (const handler of handlers) { + handler(payload) + } + } + } catch { + // Ignore malformed messages + } + } + + ws.onclose = () => { + eventSocket = null + socketReady = null + // Reconnect after a brief delay + setTimeout(() => getEventSocket(), 2000) + } + }) + + return socketReady + } + + return { + mode: "http", + + async invoke(command: string, args?: Record): Promise { + + const headers: Record = { "Content-Type": "application/json" } + if (token) headers["Authorization"] = `Bearer ${token}` + const res = await fetch(`/api/invoke/${command}`, { + method: "POST", + headers, + body: JSON.stringify(args ?? {}), + }) + if (!res.ok) { + const text = await res.text().catch(() => "") + throw new Error(`invoke ${command} failed (${res.status}): ${text}`) + } + return res.json() + }, + + onEvent(event: string, handler: (payload: T) => void): () => void { + if (!eventHandlers.has(event)) { + eventHandlers.set(event, new Set()) + } + const handlers = eventHandlers.get(event)! + const wrappedHandler = handler as (payload: unknown) => void + handlers.add(wrappedHandler) + + // Ensure the WebSocket is connected and subscribe + getEventSocket().then(() => { + if (eventSocket?.readyState === WebSocket.OPEN) { + eventSocket.send(JSON.stringify({ type: "subscribe", event })) + } + }) + + return () => { + handlers.delete(wrappedHandler) + if (handlers.size === 0) { + eventHandlers.delete(event) + if (eventSocket?.readyState === WebSocket.OPEN) { + eventSocket.send(JSON.stringify({ type: "unsubscribe", event })) + } + } + } + }, + + async fetch(url: string, init?: RequestInit): Promise { + // In HTTP mode, the API server handles any CORS proxying if needed. + // For same-origin requests (e.g., personal server on localhost), + // the browser's native fetch works directly. + return globalThis.fetch(url, init) + }, + + async openUrl(url: string): Promise { + window.open(url, "_blank", "noopener,noreferrer") + }, + + async openPath(_path: string): Promise { + // In cloud mode, local file paths aren't accessible from the browser. + // This is a no-op; the UI should show the path as text instead. + console.warn("[HttpRuntime] openPath is not available in cloud mode") + }, + + async copyToClipboard(text: string): Promise { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text) + return + } + // Fallback for insecure contexts + const textarea = document.createElement("textarea") + textarea.value = text + textarea.setAttribute("readonly", "true") + textarea.style.position = "fixed" + textarea.style.opacity = "0" + document.body.append(textarea) + textarea.select() + document.execCommand("copy") + textarea.remove() + }, + + async getAppVersion(): Promise { + + const headers: Record = {} + if (token) headers["Authorization"] = `Bearer ${token}` + const res = await fetch("/api/version", { headers }) + if (!res.ok) return "unknown" + const data = await res.json() + return data.version ?? "unknown" + }, + } +} diff --git a/src/lib/runtime/index.ts b/src/lib/runtime/index.ts new file mode 100644 index 0000000..693dde2 --- /dev/null +++ b/src/lib/runtime/index.ts @@ -0,0 +1,4 @@ +export type { Runtime } from "./types" +export { RuntimeProvider, useRuntime, isTauri } from "./context" +export { createTauriRuntime } from "./tauri-runtime" +export { createHttpRuntime } from "./http-runtime" diff --git a/src/lib/runtime/tauri-runtime.ts b/src/lib/runtime/tauri-runtime.ts new file mode 100644 index 0000000..2ef400b --- /dev/null +++ b/src/lib/runtime/tauri-runtime.ts @@ -0,0 +1,68 @@ +import type { Runtime } from "./types" + +/** + * Tauri runtime — wraps existing @tauri-apps/api calls. + * This preserves the exact desktop-app behavior. + */ +export function createTauriRuntime(): Runtime { + return { + mode: "tauri", + + async invoke(command: string, args?: Record): Promise { + const { invoke } = await import("@tauri-apps/api/core") + return invoke(command, args) + }, + + onEvent(event: string, handler: (payload: T) => void): () => void { + let unlistenFn: (() => void) | null = null + let cancelled = false + + import("@tauri-apps/api/event").then(({ listen }) => { + listen(event, (e) => { + if (!cancelled) handler(e.payload) + }).then((unlisten) => { + if (cancelled) { + unlisten() + } else { + unlistenFn = unlisten + } + }) + }) + + return () => { + cancelled = true + unlistenFn?.() + } + }, + + async fetch(url: string, init?: RequestInit): Promise { + const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http") + return tauriFetch(url, init) + }, + + async openUrl(url: string): Promise { + const { open } = await import("@tauri-apps/plugin-shell") + await open(url) + }, + + async openPath(path: string): Promise { + try { + const { open } = await import("@tauri-apps/plugin-shell") + await open(path) + } catch { + const { invoke } = await import("@tauri-apps/api/core") + await invoke("open_folder", { path }) + } + }, + + async copyToClipboard(text: string): Promise { + const { writeText } = await import("@tauri-apps/plugin-clipboard-manager") + await writeText(text) + }, + + async getAppVersion(): Promise { + const { getVersion } = await import("@tauri-apps/api/app") + return getVersion() + }, + } +} diff --git a/src/lib/runtime/types.ts b/src/lib/runtime/types.ts new file mode 100644 index 0000000..6f20153 --- /dev/null +++ b/src/lib/runtime/types.ts @@ -0,0 +1,43 @@ +/** + * Runtime abstraction for DataConnect. + * + * Tauri mode: wraps @tauri-apps/api calls (desktop app, existing behavior). + * HTTP mode: uses fetch() for commands and WebSocket for events (cloud mode). + */ + +export interface Runtime { + /** Which runtime backend is active. */ + mode: "tauri" | "http" + + /** Call a backend command (Tauri invoke or HTTP POST). */ + invoke(command: string, args?: Record): Promise + + /** + * Subscribe to a backend event. Returns an unsubscribe function. + * In Tauri mode this wraps `listen()`. In HTTP mode this uses a WebSocket. + */ + onEvent( + event: string, + handler: (payload: T) => void + ): () => void + + /** + * HTTP fetch that bypasses CORS restrictions. + * In Tauri mode this uses @tauri-apps/plugin-http. + * In HTTP mode this uses the browser's native fetch (the API server proxies + * if needed, or CORS headers are set correctly). + */ + fetch(url: string, init?: RequestInit): Promise + + /** Open a URL in the user's default browser or a new tab. */ + openUrl(url: string): Promise + + /** Open a local file or folder path. */ + openPath(path: string): Promise + + /** Copy text to the clipboard. */ + copyToClipboard(text: string): Promise + + /** Get the app version string. */ + getAppVersion(): Promise +} diff --git a/src/pages/embrowse/index.tsx b/src/pages/embrowse/index.tsx new file mode 100644 index 0000000..17a43b6 --- /dev/null +++ b/src/pages/embrowse/index.tsx @@ -0,0 +1,65 @@ +import { PageContainer } from "@/components/elements/page-container" +import { PageHeading } from "@/components/typography/page-heading" +import { Text } from "@/components/typography/text" +import { useEmbrowsePage } from "./use-embrowse-page" + +// Config — will come from the connect flow (session relay / URL params). +// For dev, use the mock HTML served by Vite's public dir. +const EMBROWSE_URL = import.meta.env.VITE_EMBROWSE_URL ?? new URL("/mock-embrowse.html", window.location.origin).href +const SERVER_URL = import.meta.env.VITE_DEMO_SERVER_URL ?? "http://localhost:8080" + +/** Feature-detect credentialless iframe support (Chrome 110+, FF 119+, no Safari) */ +const supportsCredentialless = "credentialless" in HTMLIFrameElement.prototype + +export function Embrowse() { + // For dev/demo, always use iframe — the mock is same-origin so credentialless isn't needed. + // Production will need popup fallback for cross-origin Embrowse on browsers without credentialless. + const mode = "iframe" as const + + const { iframeRef, embrowseUrl, status, openPopup } = useEmbrowsePage({ + embrowseUrl: EMBROWSE_URL, + mode, + platform: "instagram", + scopes: ["instagram.ads", "instagram.profile"], + serverUrl: SERVER_URL, + }) + + return ( + +
+ Connect your data + + Log into Instagram below to connect your data. + + + {mode === "popup" ? ( + + ) : ( +
+