From f5e94521912c34f32f01acf5933d9ff748cc2a2c Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Sun, 11 Oct 2026 00:37:44 -0700 Subject: [PATCH] Add session manager for per-player streams Creates a streamer container per player on flashpoint-web's request, sized to the game's applet, proxies its Selkies stream under /stream//, and removes it when the player disconnects. Saves under /config/saves are seeded from the browser at start and pushed back over server-sent events. --- streaming/README.md | 55 + streaming/compose.yaml | 29 + streaming/session-manager/.dockerignore | 1 + streaming/session-manager/.gitignore | 1 + streaming/session-manager/Dockerfile | 6 + streaming/session-manager/package-lock.json | 1492 +++++++++++++++++ streaming/session-manager/package.json | 33 + streaming/session-manager/src/docker.ts | 125 ++ streaming/session-manager/src/games.ts | 99 ++ streaming/session-manager/src/main.ts | 37 + streaming/session-manager/src/saves.ts | 72 + streaming/session-manager/src/server.ts | 136 ++ streaming/session-manager/src/sessions.ts | 185 ++ streaming/session-manager/test/fakes.ts | 57 + streaming/session-manager/test/games.test.ts | 47 + streaming/session-manager/test/saves.test.ts | 22 + streaming/session-manager/test/server.test.ts | 87 + .../session-manager/test/sessions.test.ts | 60 + streaming/session-manager/tsconfig.json | 13 + 19 files changed, 2557 insertions(+) create mode 100644 streaming/README.md create mode 100644 streaming/compose.yaml create mode 100644 streaming/session-manager/.dockerignore create mode 100644 streaming/session-manager/.gitignore create mode 100644 streaming/session-manager/Dockerfile create mode 100644 streaming/session-manager/package-lock.json create mode 100644 streaming/session-manager/package.json create mode 100644 streaming/session-manager/src/docker.ts create mode 100644 streaming/session-manager/src/games.ts create mode 100644 streaming/session-manager/src/main.ts create mode 100644 streaming/session-manager/src/saves.ts create mode 100644 streaming/session-manager/src/server.ts create mode 100644 streaming/session-manager/src/sessions.ts create mode 100644 streaming/session-manager/test/fakes.ts create mode 100644 streaming/session-manager/test/games.test.ts create mode 100644 streaming/session-manager/test/saves.test.ts create mode 100644 streaming/session-manager/test/server.test.ts create mode 100644 streaming/session-manager/test/sessions.test.ts create mode 100644 streaming/session-manager/tsconfig.json diff --git a/streaming/README.md b/streaming/README.md new file mode 100644 index 0000000..eeff25e --- /dev/null +++ b/streaming/README.md @@ -0,0 +1,55 @@ +# flashpoint-ultimate-server + +Streams Flashpoint games that can't run in a browser, such as Java applets, +from a Linux server to [flashpoint-web](https://github.com/darkraise/flashpoint-web). +Each player gets their own container, which is removed when they disconnect. +Saves are kept in the player's browser. + +- `streamer/`: the container image that runs one game and streams it with Selkies +- `session-manager/`: starts and removes streamer containers, proxies their + streams, and syncs saves to the browser + +## Setup + +The Flashpoint install needs Infinity's Linux software (see +[streamer/README.md](streamer/README.md#requirements)). + +```sh +docker build -t flashpoint-streamer streamer +FLASHPOINT_HOST_PATH=/path/to/flashpoint API_TOKEN=$(openssl rand -hex 32) \ + docker compose up -d --build +``` + +Give flashpoint-web the same `API_TOKEN` and the internal API URL +`http://flashpoint-session-manager:8080`, and attach it to the `flashpoint` +network. Then route `/stream/` on flashpoint-web's site to the session manager, +for example with nginx: + +```nginx +location /stream/ { + proxy_pass http://127.0.0.1:8081; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_buffering off; + proxy_read_timeout 1h; +} +``` + +Streaming needs HTTPS, since browsers only allow the video decoder Selkies uses +in secure contexts. + +## Session manager + +| Endpoint | Caller | Purpose | +|---|---|---| +| `POST :8080/sessions {gameId}` | flashpoint-web backend, `Authorization: Bearer ` | Creates a session; returns `{id, path, width, height}`, or 422 if the game can't be streamed | +| `POST /stream//_fp/start {files}` | browser | Starts the container with the player's saves | +| `GET /stream//_fp/saves` | browser | Server-sent events carrying `{files}` whenever saves change, then `end` | +| `/stream//*` | browser | Proxied to the session's Selkies, including WebSockets | + +Saves are `{relative path: base64 contents}` for the files under the +container's `/config/saves`. A session is removed if nothing connects within +2 minutes or 30 seconds after its last WebSocket closes. At most `MAX_SESSIONS` +run at once. diff --git a/streaming/compose.yaml b/streaming/compose.yaml new file mode 100644 index 0000000..210f593 --- /dev/null +++ b/streaming/compose.yaml @@ -0,0 +1,29 @@ +services: + session-manager: + build: session-manager + container_name: flashpoint-session-manager + restart: unless-stopped + ports: + - "${STREAM_PORT:-8081}:8081" + volumes: + # Starts and removes streamer containers + - /var/run/docker.sock:/var/run/docker.sock + - ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/flashpoint:ro + environment: + - FLASHPOINT_HOST_PATH + - API_TOKEN=${API_TOKEN:?API_TOKEN is required} + - MAX_SESSIONS=${MAX_SESSIONS:-4} + - PUID=${PUID:-1000} + - PGID=${PGID:-1000} + networks: + - flashpoint + - streams + +networks: + # Shared with flashpoint-web, which calls the internal API on port 8080 + flashpoint: + name: flashpoint + # Streamer containers: reachable only from the session manager, no internet access + streams: + name: flashpoint-streams + internal: true diff --git a/streaming/session-manager/.dockerignore b/streaming/session-manager/.dockerignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/streaming/session-manager/.dockerignore @@ -0,0 +1 @@ +node_modules diff --git a/streaming/session-manager/.gitignore b/streaming/session-manager/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/streaming/session-manager/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/streaming/session-manager/Dockerfile b/streaming/session-manager/Dockerfile new file mode 100644 index 0000000..5795e28 --- /dev/null +++ b/streaming/session-manager/Dockerfile @@ -0,0 +1,6 @@ +FROM node:24-slim +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev +COPY src src +CMD ["node", "src/main.ts"] diff --git a/streaming/session-manager/package-lock.json b/streaming/session-manager/package-lock.json new file mode 100644 index 0000000..56a28a3 --- /dev/null +++ b/streaming/session-manager/package-lock.json @@ -0,0 +1,1492 @@ +{ + "name": "flashpoint-session-manager", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "flashpoint-session-manager", + "dependencies": { + "better-sqlite3": "^13.0.3", + "dockerode": "^5.0.1", + "http-proxy": "^1.18.1", + "tar-stream": "^3.2.2", + "yauzl": "^3.4.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^9.6.0", + "@types/dockerode": "^4.0.2", + "@types/http-proxy": "^1.17.17", + "@types/node": "^26.6.5", + "@types/tar-stream": "^3.1.5", + "@types/ws": "^8.18.2", + "@types/yauzl": "^3.4.0", + "@types/yazl": "^3.3.1", + "typescript": "^7.0.2", + "ws": "^8.22.0", + "yazl": "^3.3.1" + }, + "engines": { + "node": ">=22.18" + } + }, + "node_modules/@balena/dockerignore": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz", + "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==", + "license": "Apache-2.0" + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.6", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.6.tgz", + "integrity": "sha512-SgC/B3/VZ8lTyOZ4Cwk6f1skWhmkNAaVfiX90qd5u1rRXGKk1//8FTUEN9GvOUq7rfWIqAYkA3CoyumYVpOS6g==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@types/better-sqlite3": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-9.6.0.tgz", + "integrity": "sha512-ZEEwBSgMu7GYJOynoagg5X9JbxfL6dTJDsgViJIqh67jV44kyOr9RXfmFjLK5rzC4MWssP06t9hu/JwGDnUbCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/docker-modem": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.6.tgz", + "integrity": "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/ssh2": "*" + } + }, + "node_modules/@types/dockerode": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-4.0.2.tgz", + "integrity": "sha512-hA4myZ0XzwHG/p/eCLElYsQS3y1XZfvi6AfLFKVp1SNpPkO6J3xLatacAFVkReTxXRoIa3eHJmhJi9w6I3+zNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/docker-modem": "*", + "@types/node": "*", + "@types/ssh2": "*" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/ssh2": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.6.tgz", + "integrity": "sha512-oGdxhBqcRTwSTKFm+9EiKzkNVYRLEFkcW44lhguvBalGJbWfGnDt/ezwSUZc+SF9m9bMc3VyklNAtp7zICjS5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/tar-stream": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@types/tar-stream/-/tar-stream-3.1.5.tgz", + "integrity": "sha512-zCUA6CSDw0t2cpQiZixpyzfubpeuVZmBr5wztvuCG4bhy7reX/9wGXdZxEzz2CjMFLQ32UPR/01jzUUSRJeCeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yauzl": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-NRPn5w6h8dhcnmx3YIRQcqMywY/+nND/uOkJessedcrowO3C0AssHp3tMJpxKAwOhFOo0OV1y9VtsC5hbKKBAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yazl": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@types/yazl/-/yazl-3.3.1.tgz", + "integrity": "sha512-DIWfCKpsTp6hE5BDBHV3+fIL/bLUF9Bv13iDrWnMlmhQpH67buNvI291ZauQ1xcccxK3FqQ9honnXpq4R8NMuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/b4a": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.9.0.tgz", + "integrity": "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/bare-events": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz", + "integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.4.tgz", + "integrity": "sha512-MPC2eyrpbk3A873sK4y6rk+Iv4o2enxTjHZ8zH3HO0mjrpm5XjnguY2XmfidIsDV6vnjpbMBKUAJpV0ISD2SsQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.28.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz", + "integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==", + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.4", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz", + "integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.4.tgz", + "integrity": "sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/better-sqlite3": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "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", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "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/docker-modem": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.7.tgz", + "integrity": "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.1", + "readable-stream": "^3.5.0", + "split-ca": "^1.0.1", + "ssh2": "^1.15.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/dockerode": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-5.0.1.tgz", + "integrity": "sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA==", + "license": "Apache-2.0", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@grpc/grpc-js": "^1.11.1", + "@grpc/proto-loader": "^0.7.13", + "docker-modem": "^5.0.7", + "protobufjs": "^7.3.2", + "tar-fs": "^2.1.4" + }, + "engines": { + "node": ">= 14.17" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=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/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "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/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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": "BSD-3-Clause" + }, + "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/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "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/nan": { + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.29.0.tgz", + "integrity": "sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA==", + "license": "MIT", + "optional": true + }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/split-ca": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", + "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==", + "license": "ISC" + }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, + "node_modules/streamx": { + "version": "2.28.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz", + "integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.2.tgz", + "integrity": "sha512-+8NeqHRjQWH9nYlwo2gamAMImZCVzI4UoEgDpWorBt9OEfppiZn+uSkskzQKPWIZyji/C8fpWO7u69G0DX0tbg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.9.0", + "bare-fs": "^4.8.2", + "fast-fifo": "^1.3.2", + "streamx": "^2.28.1" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "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 + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yazl": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/yazl/-/yazl-3.3.1.tgz", + "integrity": "sha512-BbETDVWG+VcMUle37k5Fqp//7SDOK2/1+T7X8TD96M3D9G8jK5VLUdQVdVjGi8im7FGkazX7kk5hkU8X4L5Bng==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "^1.0.0" + } + } + } +} diff --git a/streaming/session-manager/package.json b/streaming/session-manager/package.json new file mode 100644 index 0000000..6cb3f92 --- /dev/null +++ b/streaming/session-manager/package.json @@ -0,0 +1,33 @@ +{ + "name": "flashpoint-session-manager", + "private": true, + "type": "module", + "scripts": { + "start": "node src/main.ts", + "test": "node --test test/*.test.ts", + "check": "tsc" + }, + "engines": { + "node": ">=22.18" + }, + "dependencies": { + "better-sqlite3": "^13.0.3", + "dockerode": "^5.0.1", + "http-proxy": "^1.18.1", + "tar-stream": "^3.2.2", + "yauzl": "^3.4.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^9.6.0", + "@types/dockerode": "^4.0.2", + "@types/http-proxy": "^1.17.17", + "@types/node": "^26.6.5", + "@types/tar-stream": "^3.1.5", + "@types/ws": "^8.18.2", + "@types/yauzl": "^3.4.0", + "@types/yazl": "^3.3.1", + "typescript": "^7.0.2", + "ws": "^8.22.0", + "yazl": "^3.3.1" + } +} diff --git a/streaming/session-manager/src/docker.ts b/streaming/session-manager/src/docker.ts new file mode 100644 index 0000000..e0a72d7 --- /dev/null +++ b/streaming/session-manager/src/docker.ts @@ -0,0 +1,125 @@ +import Docker from 'dockerode'; +import type { Size } from './games.ts'; +import { fromTar, SAVES_DIR, toTar, type SaveFiles } from './saves.ts'; + +/** Manages the streamer container behind each session. */ +export interface Driver { + create(id: string, gameId: string, size: Size): Promise; + start(id: string, saves: SaveFiles): Promise; + getSaves(id: string): Promise; + remove(id: string): Promise; + /** Removes containers left behind by a previous run. */ + removeAll(): Promise; + /** Base URL of the container's Selkies server. */ + url(id: string): string; +} + +export interface DockerDriverOptions { + image: string; + network: string; + flashpointHostPath: string; + uid: number; + gid: number; +} + +const LABEL = 'flashpoint.stream-session'; +const HOME = '/config'; +const READY_TIMEOUT_MS = 60_000; + +export class DockerDriver implements Driver { + private readonly docker = new Docker(); + private readonly options: DockerDriverOptions; + + constructor(options: DockerDriverOptions) { + this.options = options; + } + + async create(id: string, gameId: string, size: Size): Promise { + const { image, network, flashpointHostPath, uid, gid } = this.options; + await this.docker.createContainer({ + name: containerName(id), + Image: image, + Labels: { [LABEL]: id }, + Env: [ + `GAME_ID=${gameId}`, + `PUID=${uid}`, + `PGID=${gid}`, + `SUBFOLDER=${streamPath(id)}`, + `SELKIES_MANUAL_WIDTH=${size.width}`, + `SELKIES_MANUAL_HEIGHT=${size.height}`, + 'SELKIES_UI_SHOW_SIDEBAR=false', + 'HARDEN_DESKTOP=true', + 'RESTART_APP=true', + 'NO_DECOR=true', + ], + HostConfig: { + Binds: [`${flashpointHostPath}:/flashpoint:ro`], + NetworkMode: network, + ShmSize: 1024 ** 3, + AutoRemove: true, + }, + }); + } + + async start(id: string, saves: SaveFiles): Promise { + const container = this.docker.getContainer(containerName(id)); + if (Object.keys(saves).length > 0) { + await container.putArchive(await toTar(saves, this.options.uid, this.options.gid), { path: HOME }); + } + await container.start(); + await waitForHttp(this.url(id) + streamPath(id), READY_TIMEOUT_MS); + } + + async getSaves(id: string): Promise { + try { + const archive = await this.docker.getContainer(containerName(id)).getArchive({ path: `${HOME}/${SAVES_DIR}` }); + return await fromTar(archive); + } catch (err) { + if ((err as { statusCode?: number }).statusCode === 404) { + return {}; + } + throw err; + } + } + + async remove(id: string): Promise { + await this.docker + .getContainer(containerName(id)) + .remove({ force: true }) + .catch((err: { statusCode?: number }) => { + if (err.statusCode !== 404 && err.statusCode !== 409) { + throw err; + } + }); + } + + async removeAll(): Promise { + const containers = await this.docker.listContainers({ all: true, filters: { label: [LABEL] } }); + await Promise.all(containers.map((c) => this.remove(c.Labels[LABEL]))); + } + + url(id: string): string { + return `http://${containerName(id)}:3000`; + } +} + +/** URL path prefix a session is served under, through the manager and inside its container. */ +export function streamPath(id: string): string { + return `/stream/${id}/`; +} + +function containerName(id: string): string { + return `fp-stream-${id}`; +} + +async function waitForHttp(url: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const ok = await fetch(url).then((res) => res.ok, () => false); + if (ok) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error(`timed out waiting for ${url}`); +} diff --git a/streaming/session-manager/src/games.ts b/streaming/session-manager/src/games.ts new file mode 100644 index 0000000..209cd1a --- /dev/null +++ b/streaming/session-manager/src/games.ts @@ -0,0 +1,99 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { buffer } from 'node:stream/consumers'; +import Database from 'better-sqlite3'; +import yauzl from 'yauzl'; + +/** Launch applications fp-launch can stream, lowercased. */ +const STREAMABLE_APPS = new Set(['fpsoftware\\startjava.bat', 'fpsoftware\\startjavainbrowser.bat']); + +const DEFAULT_SIZE: Size = { width: 800, height: 600 }; + +export interface Size { + width: number; + height: number; +} + +interface LaunchRow { + app: string; + cmd: string; + dateAdded: string | null; +} + +/** Looks up streamable games in a Flashpoint install. */ +export class GameCatalog { + private readonly root: string; + private readonly db: Database.Database; + + constructor(root: string) { + this.root = root; + this.db = new Database(path.join(root, 'Data', 'flashpoint.sqlite'), { readonly: true, fileMustExist: true }); + } + + /** Returns the display size for a streamable game, or null if it can't be streamed. */ + async streamSize(gameId: string): Promise { + const row = this.db + .prepare( + `select coalesce(nullif(d.applicationPath, ''), g.applicationPath) as app, + coalesce(nullif(d.launchCommand, ''), g.launchCommand) as cmd, + d.dateAdded as dateAdded + from game g left join game_data d on d.id = g.activeDataId + where g.id = ?`, + ) + .get(gameId) as LaunchRow | undefined; + if (!row || !STREAMABLE_APPS.has(row.app.toLowerCase())) { + return null; + } + const page = await this.readLaunchPage(gameId, row).catch(() => null); + return (page && appletSize(page.toString('latin1'))) ?? DEFAULT_SIZE; + } + + private async readLaunchPage(gameId: string, row: LaunchRow): Promise { + const url = new URL(row.cmd); + const page = url.pathname.endsWith('/') ? `${url.pathname}index.html` : url.pathname; + const file = url.hostname + decodeURIComponent(page); + if (row.dateAdded) { + const zip = path.join(this.root, 'Data', 'Games', `${gameId}-${new Date(row.dateAdded).getTime()}.zip`); + return readZipEntry(zip, `content/${file}`); + } + return readFile(path.join(this.root, 'Legacy', 'htdocs', file)); + } +} + +/** Reads the width and height of the first applet on a page. */ +export function appletSize(html: string): Size | null { + const tag = html.match(/]*>/i)?.[0]; + if (!tag) { + return null; + } + const attr = (name: string) => Number(tag.match(new RegExp(`\\b${name}\\s*=\\s*["']?(\\d+)(?![\\d%])`, 'i'))?.[1]); + const width = attr('width'); + const height = attr('height'); + return width > 0 && height > 0 ? { width, height } : null; +} + +/** Reads one entry from a zip, matching its name case-insensitively. */ +function readZipEntry(zipPath: string, name: string): Promise { + const wanted = name.toLowerCase(); + return new Promise((resolve, reject) => { + yauzl.open(zipPath, { lazyEntries: true }, (err, zip) => { + if (err) { + return reject(err); + } + zip.on('error', reject); + zip.on('end', () => resolve(null)); + zip.on('entry', (entry: yauzl.Entry) => { + if (entry.fileName.toLowerCase() !== wanted) { + return zip.readEntry(); + } + zip.openReadStream(entry, (err, stream) => { + if (err) { + return reject(err); + } + buffer(stream).then(resolve, reject).finally(() => zip.close()); + }); + }); + zip.readEntry(); + }); + }); +} diff --git a/streaming/session-manager/src/main.ts b/streaming/session-manager/src/main.ts new file mode 100644 index 0000000..06f6fa7 --- /dev/null +++ b/streaming/session-manager/src/main.ts @@ -0,0 +1,37 @@ +import { DockerDriver } from './docker.ts'; +import { GameCatalog } from './games.ts'; +import { createApiServer, createStreamServer } from './server.ts'; +import { SessionManager } from './sessions.ts'; + +function env(name: string, fallback?: string): string { + const value = process.env[name] || fallback; + if (value === undefined) { + throw new Error(`${name} is required`); + } + return value; +} + +const driver = new DockerDriver({ + image: env('STREAMER_IMAGE', 'flashpoint-streamer'), + network: env('STREAMER_NETWORK', 'flashpoint-streams'), + flashpointHostPath: env('FLASHPOINT_HOST_PATH'), + uid: Number(env('PUID', '1000')), + gid: Number(env('PGID', '1000')), +}); +const sessions = new SessionManager( + driver, + new GameCatalog(env('FLASHPOINT_PATH', '/flashpoint')), + Number(env('MAX_SESSIONS', '4')), +); + +await driver.removeAll(); +const api = createApiServer(sessions, env('API_TOKEN')).listen(Number(env('API_PORT', '8080'))); +const stream = createStreamServer(sessions, driver).listen(Number(env('STREAM_PORT', '8081'))); +console.log('Session manager started'); + +process.once('SIGTERM', async () => { + api.close(); + stream.close(); + await sessions.teardownAll(); + process.exit(0); +}); diff --git a/streaming/session-manager/src/saves.ts b/streaming/session-manager/src/saves.ts new file mode 100644 index 0000000..d63ae04 --- /dev/null +++ b/streaming/session-manager/src/saves.ts @@ -0,0 +1,72 @@ +import { buffer } from 'node:stream/consumers'; +import tar from 'tar-stream'; + +/** Save files by path relative to the saves directory, contents base64-encoded. */ +export type SaveFiles = Record; + +/** Name of the saves directory under the streamer's home, and of its entry in archives. */ +export const SAVES_DIR = 'saves'; + +const MAX_BYTES = 10 * 1024 * 1024; + +/** Validates untrusted save files from a browser, rejecting paths outside the saves directory. */ +export function parseSaveFiles(value: unknown): SaveFiles { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error('files must be an object'); + } + let total = 0; + for (const [path, data] of Object.entries(value)) { + const parts = path.split('/'); + if (path.startsWith('/') || parts.some((p) => p === '' || p === '.' || p === '..')) { + throw new Error(`invalid save path: ${path}`); + } + if (typeof data !== 'string') { + throw new Error(`invalid save data: ${path}`); + } + total += data.length; + } + if (total > MAX_BYTES) { + throw new Error('saves too large'); + } + return value as SaveFiles; +} + +/** Packs save files into a tar whose entries sit under the saves directory. */ +export async function toTar(files: SaveFiles, uid: number, gid: number): Promise { + const pack = tar.pack(); + const dirs = new Set([SAVES_DIR]); + for (const path of Object.keys(files)) { + const parts = path.split('/'); + for (let i = 1; i < parts.length; i++) { + dirs.add(`${SAVES_DIR}/${parts.slice(0, i).join('/')}`); + } + } + for (const dir of dirs) { + pack.entry({ name: dir, type: 'directory', mode: 0o755, uid, gid }); + } + for (const [path, data] of Object.entries(files)) { + pack.entry({ name: `${SAVES_DIR}/${path}`, mode: 0o644, uid, gid }, Buffer.from(data, 'base64')); + } + pack.finalize(); + const chunks: Buffer[] = []; + for await (const chunk of pack) { + chunks.push(chunk as Buffer); + } + return Buffer.concat(chunks); +} + +/** Reads the regular files from a tar of the saves directory. */ +export async function fromTar(stream: NodeJS.ReadableStream): Promise { + const files: SaveFiles = {}; + const extract = tar.extract(); + stream.pipe(extract); + for await (const entry of extract) { + const { name, type } = entry.header; + if (type === 'file' && name.startsWith(`${SAVES_DIR}/`)) { + files[name.slice(SAVES_DIR.length + 1)] = (await buffer(entry)).toString('base64'); + } else { + entry.resume(); + } + } + return files; +} diff --git a/streaming/session-manager/src/server.ts b/streaming/session-manager/src/server.ts new file mode 100644 index 0000000..15378ea --- /dev/null +++ b/streaming/session-manager/src/server.ts @@ -0,0 +1,136 @@ +import http from 'node:http'; +import { text } from 'node:stream/consumers'; +import { timingSafeEqual } from 'node:crypto'; +import httpProxy from 'http-proxy'; +import { streamPath, type Driver } from './docker.ts'; +import { parseSaveFiles } from './saves.ts'; +import { SessionError, type SessionManager } from './sessions.ts'; + +const STREAM_URL = /^\/stream\/([0-9a-f]{32})(\/.*)$/; +const HEARTBEAT_MS = 25_000; +const MAX_BODY_BYTES = 16 * 1024 * 1024; + +/** Internal API for flashpoint-web's backend: creates sessions. */ +export function createApiServer(sessions: SessionManager, token: string): http.Server { + return http.createServer((req, res) => { + handle(res, async () => { + if (req.method !== 'POST' || req.url !== '/sessions') { + throw new SessionError(404, 'not found'); + } + if (!tokenMatches(req.headers.authorization, token)) { + throw new SessionError(401, 'unauthorized'); + } + const { gameId } = await readJson(req); + if (typeof gameId !== 'string') { + throw new SessionError(400, 'gameId is required'); + } + const { id, size } = await sessions.create(gameId); + sendJson(res, 201, { id, path: streamPath(id), ...size }); + }); + }); +} + +/** Public server: starts sessions, streams their saves, and proxies Selkies. */ +export function createStreamServer(sessions: SessionManager, driver: Driver): http.Server { + const proxy = httpProxy.createProxyServer({ ws: true }); + proxy.on('error', (err, _req, res) => { + if (res instanceof http.ServerResponse && !res.headersSent) { + res.writeHead(502).end(); + } else { + res.destroy(); + } + }); + + const server = http.createServer((req, res) => { + handle(res, async () => { + const [, id, rest] = req.url?.match(STREAM_URL) ?? []; + if (!id || !sessions.has(id)) { + throw new SessionError(404, 'no such session'); + } + if (rest === '/_fp/start' && req.method === 'POST') { + const { files } = await readJson(req); + let saves; + try { + saves = parseSaveFiles(files ?? {}); + } catch (err) { + throw new SessionError(400, (err as Error).message); + } + await sessions.start(id, saves); + res.writeHead(204).end(); + } else if (rest === '/_fp/saves' && req.method === 'GET') { + streamSaves(sessions, id, req, res); + } else { + proxy.web(req, res, { target: driver.url(id) }); + } + }); + }); + + server.on('upgrade', (req, socket, head) => { + const [, id] = req.url?.match(STREAM_URL) ?? []; + if (!id || !sessions.has(id)) { + socket.destroy(); + return; + } + sessions.socketOpened(id); + socket.on('close', () => sessions.socketClosed(id)); + proxy.ws(req, socket, head, { target: driver.url(id) }); + }); + + return server; +} + +function streamSaves(sessions: SessionManager, id: string, req: http.IncomingMessage, res: http.ServerResponse): void { + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no' }); + const heartbeat = setInterval(() => res.write(':\n\n'), HEARTBEAT_MS); + const stop = sessions.watchSaves(id, (files) => { + if (files) { + res.write(`data: ${JSON.stringify({ files })}\n\n`); + } else { + res.end('event: end\ndata:\n\n'); + } + }); + req.on('close', () => { + clearInterval(heartbeat); + stop(); + }); +} + +async function handle(res: http.ServerResponse, fn: () => Promise): Promise { + try { + await fn(); + } catch (err) { + const status = err instanceof SessionError ? err.status : 500; + if (status === 500) { + console.error(err); + } + if (!res.headersSent) { + sendJson(res, status, { error: err instanceof SessionError ? err.message : 'internal error' }); + } + } +} + +async function readJson(req: http.IncomingMessage): Promise> { + if (Number(req.headers['content-length']) > MAX_BODY_BYTES) { + throw new SessionError(413, 'body too large'); + } + const body = await text(req); + try { + const value = body ? JSON.parse(body) : {}; + if (typeof value === 'object' && value !== null) { + return value; + } + } catch { + // Fall through to the error below. + } + throw new SessionError(400, 'invalid JSON body'); +} + +function sendJson(res: http.ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { 'Content-Type': 'application/json' }).end(JSON.stringify(body)); +} + +function tokenMatches(header: string | undefined, token: string): boolean { + const given = Buffer.from(header ?? ''); + const expected = Buffer.from(`Bearer ${token}`); + return given.length === expected.length && timingSafeEqual(given, expected); +} diff --git a/streaming/session-manager/src/sessions.ts b/streaming/session-manager/src/sessions.ts new file mode 100644 index 0000000..a84b271 --- /dev/null +++ b/streaming/session-manager/src/sessions.ts @@ -0,0 +1,185 @@ +import { randomBytes } from 'node:crypto'; +import type { Driver } from './docker.ts'; +import type { GameCatalog, Size } from './games.ts'; +import type { SaveFiles } from './saves.ts'; + +export interface SessionTimings { + /** How long a new session may wait for its first connection. */ + connectTimeoutMs: number; + /** How long a session survives with no connections, to allow reconnecting. */ + idleGraceMs: number; + /** How often saves are read from a running session. */ + savesPollMs: number; +} + +export const DEFAULT_TIMINGS: SessionTimings = { + connectTimeoutMs: 120_000, + idleGraceMs: 30_000, + savesPollMs: 2_000, +}; + +export class SessionError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +type SavesListener = (files: SaveFiles | null) => void; + +interface Session { + id: string; + state: 'created' | 'starting' | 'running'; + sockets: number; + teardownTimer?: NodeJS.Timeout; + pollTimer?: NodeJS.Timeout; + saves: string; + listeners: Set; +} + +/** Tracks streamer sessions and tears each down once its player is gone. */ +export class SessionManager { + private readonly sessions = new Map(); + private readonly driver: Driver; + private readonly catalog: Pick; + private readonly maxSessions: number; + private readonly timings: SessionTimings; + + constructor( + driver: Driver, + catalog: Pick, + maxSessions: number, + timings: SessionTimings = DEFAULT_TIMINGS, + ) { + this.driver = driver; + this.catalog = catalog; + this.maxSessions = maxSessions; + this.timings = timings; + } + + async create(gameId: string): Promise<{ id: string; size: Size }> { + if (this.sessions.size >= this.maxSessions) { + throw new SessionError(503, 'too many sessions'); + } + const size = await this.catalog.streamSize(gameId); + if (!size) { + throw new SessionError(422, 'game is not streamable'); + } + const id = randomBytes(16).toString('hex'); + const session: Session = { id, state: 'created', sockets: 0, saves: '{}', listeners: new Set() }; + this.sessions.set(id, session); + try { + await this.driver.create(id, gameId, size); + } catch (err) { + this.sessions.delete(id); + throw err; + } + this.scheduleTeardown(session, this.timings.connectTimeoutMs); + return { id, size }; + } + + has(id: string): boolean { + return this.sessions.has(id); + } + + /** Starts a session's container with the player's saves. */ + async start(id: string, saves: SaveFiles): Promise { + const session = this.get(id); + if (session.state !== 'created') { + throw new SessionError(409, 'session already started'); + } + session.state = 'starting'; + session.saves = canonical(saves); + try { + await this.driver.start(id, saves); + } catch (err) { + await this.teardown(id); + throw err; + } + session.state = 'running'; + this.startPolling(session); + } + + socketOpened(id: string): void { + const session = this.sessions.get(id); + if (session) { + session.sockets++; + clearTimeout(session.teardownTimer); + } + } + + socketClosed(id: string): void { + const session = this.sessions.get(id); + if (session && --session.sockets === 0) { + this.scheduleTeardown(session, this.timings.idleGraceMs); + } + } + + /** Calls the listener with the current saves and whenever they change, then with null at teardown. */ + watchSaves(id: string, listener: SavesListener): () => void { + const session = this.get(id); + session.listeners.add(listener); + listener(JSON.parse(session.saves)); + return () => session.listeners.delete(listener); + } + + async teardown(id: string): Promise { + const session = this.sessions.get(id); + if (!session) { + return; + } + this.sessions.delete(id); + clearTimeout(session.teardownTimer); + clearInterval(session.pollTimer); + if (session.state === 'running') { + await this.pollSaves(session); + } + for (const listener of session.listeners) { + listener(null); + } + await this.driver.remove(id); + } + + async teardownAll(): Promise { + await Promise.all([...this.sessions.keys()].map((id) => this.teardown(id))); + } + + private get(id: string): Session { + const session = this.sessions.get(id); + if (!session) { + throw new SessionError(404, 'no such session'); + } + return session; + } + + private scheduleTeardown(session: Session, delayMs: number): void { + clearTimeout(session.teardownTimer); + session.teardownTimer = setTimeout(() => { + this.teardown(session.id).catch((err) => console.error(`Failed to tear down ${session.id}:`, err)); + }, delayMs); + } + + private startPolling(session: Session): void { + session.pollTimer = setInterval(() => { + this.pollSaves(session).catch((err) => console.error(`Failed to read saves for ${session.id}:`, err)); + }, this.timings.savesPollMs); + } + + private async pollSaves(session: Session): Promise { + const files = await this.driver.getSaves(session.id); + const json = canonical(files); + if (json === session.saves) { + return; + } + session.saves = json; + for (const listener of session.listeners) { + listener(files); + } + } +} + +function canonical(files: SaveFiles): string { + return JSON.stringify(files, Object.keys(files).sort()); +} diff --git a/streaming/session-manager/test/fakes.ts b/streaming/session-manager/test/fakes.ts new file mode 100644 index 0000000..734c35e --- /dev/null +++ b/streaming/session-manager/test/fakes.ts @@ -0,0 +1,57 @@ +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { WebSocketServer } from 'ws'; +import type { Driver } from '../src/docker.ts'; +import type { Size } from '../src/games.ts'; +import type { SaveFiles } from '../src/saves.ts'; + +/** Driver whose "containers" share one local HTTP and WebSocket server. */ +export class FakeDriver implements Driver { + readonly containers = new Map(); + readonly removed: string[] = []; + private server?: http.Server; + + async listen(): Promise { + this.server = http.createServer((req, res) => res.end(`upstream ${req.url}`)); + new WebSocketServer({ server: this.server }).on('connection', (ws) => ws.on('message', (m) => ws.send(`echo ${m}`))); + await new Promise((resolve) => this.server!.listen(0, resolve)); + } + + close(): void { + this.server?.closeAllConnections(); + this.server?.close(); + } + + async create(id: string, gameId: string, size: Size): Promise { + this.containers.set(id, { gameId, size, saves: {}, started: false }); + } + + async start(id: string, saves: SaveFiles): Promise { + Object.assign(this.containers.get(id)!, { saves: { ...saves }, started: true }); + } + + async getSaves(id: string): Promise { + return { ...this.containers.get(id)!.saves }; + } + + async remove(id: string): Promise { + this.containers.delete(id); + this.removed.push(id); + } + + async removeAll(): Promise {} + + url(): string { + return `http://127.0.0.1:${(this.server!.address() as AddressInfo).port}`; + } +} + +export const catalog = { + async streamSize(gameId: string): Promise { + return gameId === 'java' ? { width: 472, height: 354 } : null; + }, +}; + +export const fastTimings = { connectTimeoutMs: 300, idleGraceMs: 100, savesPollMs: 20 }; + +export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/streaming/session-manager/test/games.test.ts b/streaming/session-manager/test/games.test.ts new file mode 100644 index 0000000..2421198 --- /dev/null +++ b/streaming/session-manager/test/games.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, writeFileSync, createWriteStream } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { finished } from 'node:stream/promises'; +import { test } from 'node:test'; +import Database from 'better-sqlite3'; +import yazl from 'yazl'; +import { appletSize, GameCatalog } from '../src/games.ts'; + +test('appletSize reads the first applet tag', () => { + assert.deepEqual(appletSize(''), { width: 472, height: 354 }); + assert.deepEqual(appletSize(``), { width: 640, height: 480 }); + assert.equal(appletSize(''), null); + assert.equal(appletSize(''), null); +}); + +test('GameCatalog sizes streamable games from their GameZIP or htdocs', async () => { + const root = mkdtempSync(path.join(tmpdir(), 'fp-')); + mkdirSync(path.join(root, 'Data', 'Games'), { recursive: true }); + mkdirSync(path.join(root, 'Legacy', 'htdocs', 'example.com', 'my game'), { recursive: true }); + writeFileSync(path.join(root, 'Legacy', 'htdocs', 'example.com', 'my game', 'index.html'), ''); + + const zip = new yazl.ZipFile(); + zip.addBuffer(Buffer.from(''), 'content/www.popcap.com/Insaniquarium/Java/index.html'); + zip.end(); + await finished(zip.outputStream.pipe(createWriteStream(path.join(root, 'Data', 'Games', 'zip-1690495544502.zip')))); + + const db = new Database(path.join(root, 'Data', 'flashpoint.sqlite')); + db.exec(` + create table game (id, applicationPath, launchCommand, activeDataId); + create table game_data (id integer primary key, applicationPath, launchCommand, dateAdded); + insert into game values ('zip', 'FPSoftware\\startJavaInBrowser.bat', 'http://www.popcap.com/Insaniquarium/Java/INDEX.html', 1); + insert into game_data values (1, '', '', '2023-07-27T22:05:44.502567Z'); + insert into game values ('legacy', 'FPSoftware\\startJava.bat', 'http://example.com/my%20game/', null); + insert into game values ('missing', 'FPSoftware\\startJava.bat', 'http://example.com/none.html', null); + insert into game values ('flash', 'FPSoftware\\Flash\\flashplayer_32_sa.exe', 'http://example.com/a.swf', null); + `); + db.close(); + + const catalog = new GameCatalog(root); + assert.deepEqual(await catalog.streamSize('zip'), { width: 472, height: 354 }); + assert.deepEqual(await catalog.streamSize('legacy'), { width: 300, height: 200 }); + assert.deepEqual(await catalog.streamSize('missing'), { width: 800, height: 600 }); + assert.equal(await catalog.streamSize('flash'), null); + assert.equal(await catalog.streamSize('unknown'), null); +}); diff --git a/streaming/session-manager/test/saves.test.ts b/streaming/session-manager/test/saves.test.ts new file mode 100644 index 0000000..8f52c18 --- /dev/null +++ b/streaming/session-manager/test/saves.test.ts @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import { Readable } from 'node:stream'; +import { test } from 'node:test'; +import { fromTar, parseSaveFiles, toTar } from '../src/saves.ts'; + +test('parseSaveFiles rejects paths outside the saves directory', () => { + for (const path of ['/etc/passwd', '../x', 'a/../../x', 'a//b', './x', '']) { + assert.throws(() => parseSaveFiles({ [path]: '' }), /invalid save path/); + } + assert.throws(() => parseSaveFiles({ a: 1 }), /invalid save data/); + assert.throws(() => parseSaveFiles([]), /must be an object/); + assert.deepEqual(parseSaveFiles({ 'a/b.sol': 'AA==' }), { 'a/b.sol': 'AA==' }); +}); + +test('save files survive a tar round trip', async () => { + const files = { + 'cookies.txt': Buffer.from('INSANIQUARIUM=abc\n').toString('base64'), + 'nested/dir/data.bin': Buffer.from([0, 255, 128, 10]).toString('base64'), + }; + const tar = await toTar(files, 1000, 1000); + assert.deepEqual(await fromTar(Readable.from([tar])), files); +}); diff --git a/streaming/session-manager/test/server.test.ts b/streaming/session-manager/test/server.test.ts new file mode 100644 index 0000000..30304fc --- /dev/null +++ b/streaming/session-manager/test/server.test.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import type { AddressInfo } from 'node:net'; +import { after, before, test } from 'node:test'; +import WebSocket from 'ws'; +import { createApiServer, createStreamServer } from '../src/server.ts'; +import { SessionManager } from '../src/sessions.ts'; +import { catalog, FakeDriver, fastTimings, sleep } from './fakes.ts'; + +const driver = new FakeDriver(); +const sessions = new SessionManager(driver, catalog, 4, fastTimings); +const api = createApiServer(sessions, 'secret'); +const stream = createStreamServer(sessions, driver); +let apiUrl: string; +let streamUrl: string; + +before(async () => { + await driver.listen(); + await new Promise((resolve) => api.listen(0, resolve)); + await new Promise((resolve) => stream.listen(0, resolve)); + apiUrl = `http://127.0.0.1:${(api.address() as AddressInfo).port}`; + streamUrl = `http://127.0.0.1:${(stream.address() as AddressInfo).port}`; +}); + +after(async () => { + await sessions.teardownAll(); + api.close(); + stream.closeAllConnections(); + stream.close(); + driver.close(); +}); + +function createSession(gameId: string, token = 'secret') { + return fetch(`${apiUrl}/sessions`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify({ gameId }), + }); +} + +test('the API requires the token and reports unstreamable games', async () => { + assert.equal((await createSession('java', 'wrong')).status, 401); + assert.equal((await createSession('flash')).status, 422); + const res = await createSession('java'); + assert.equal(res.status, 201); + const body = await res.json(); + assert.match(body.path, /^\/stream\/[0-9a-f]{32}\/$/); + assert.equal(body.width, 472); + await sessions.teardown(body.id); +}); + +test('a session starts with saves, proxies HTTP and WebSocket, streams saves, and ends on disconnect', async () => { + const { id, path } = await (await createSession('java')).json(); + assert.equal((await fetch(`${streamUrl}/stream/${'0'.repeat(32)}/`)).status, 404); + assert.equal( + (await fetch(`${streamUrl}${path}_fp/start`, { method: 'POST', body: JSON.stringify({ files: { '../x': '' } }) })).status, + 400, + ); + const start = await fetch(`${streamUrl}${path}_fp/start`, { + method: 'POST', + body: JSON.stringify({ files: { 'cookies.txt': 'YQ==' } }), + }); + assert.equal(start.status, 204); + assert.deepEqual(driver.containers.get(id)!.saves, { 'cookies.txt': 'YQ==' }); + + assert.equal(await (await fetch(`${streamUrl}${path}index.html`)).text(), `upstream ${path}index.html`); + + const ws = new WebSocket(`${streamUrl.replace('http', 'ws')}${path}websocket`); + const reply = await new Promise((resolve, reject) => { + ws.on('open', () => ws.send('hi')); + ws.on('message', (m) => resolve(m.toString())); + ws.on('error', reject); + }); + assert.equal(reply, 'echo hi'); + + const events = await fetch(`${streamUrl}${path}_fp/saves`); + const reader = events.body!.pipeThrough(new TextDecoderStream()).getReader(); + assert.equal((await reader.read()).value, `data: ${JSON.stringify({ files: { 'cookies.txt': 'YQ==' } })}\n\n`); + driver.containers.get(id)!.saves['cookies.txt'] = 'Yg=='; + assert.equal((await reader.read()).value, `data: ${JSON.stringify({ files: { 'cookies.txt': 'Yg==' } })}\n\n`); + + await sleep(400); + assert.equal(sessions.has(id), true, 'kept alive while the WebSocket is open'); + ws.close(); + assert.equal((await reader.read()).value, 'event: end\ndata:\n\n'); + assert.equal(sessions.has(id), false); + assert.ok(driver.removed.includes(id)); +}); diff --git a/streaming/session-manager/test/sessions.test.ts b/streaming/session-manager/test/sessions.test.ts new file mode 100644 index 0000000..7546e9e --- /dev/null +++ b/streaming/session-manager/test/sessions.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { SessionError, SessionManager } from '../src/sessions.ts'; +import { catalog, FakeDriver, fastTimings, sleep } from './fakes.ts'; + +test('rejects unstreamable games and enforces the session cap', async () => { + const sessions = new SessionManager(new FakeDriver(), catalog, 1, fastTimings); + await assert.rejects(sessions.create('flash'), (e: SessionError) => e.status === 422); + const { id, size } = await sessions.create('java'); + assert.deepEqual(size, { width: 472, height: 354 }); + await assert.rejects(sessions.create('java'), (e: SessionError) => e.status === 503); + await sessions.teardown(id); + await sessions.create('java'); + await sessions.teardownAll(); +}); + +test('removes a session that is never connected to', async () => { + const driver = new FakeDriver(); + const sessions = new SessionManager(driver, catalog, 4, fastTimings); + const { id } = await sessions.create('java'); + await sessions.start(id, {}); + await sleep(400); + assert.equal(sessions.has(id), false); + assert.deepEqual(driver.removed, [id]); +}); + +test('keeps a session while connected and survives a quick reconnect', async () => { + const driver = new FakeDriver(); + const sessions = new SessionManager(driver, catalog, 4, fastTimings); + const { id } = await sessions.create('java'); + await sessions.start(id, {}); + sessions.socketOpened(id); + await sleep(400); + assert.equal(sessions.has(id), true); + sessions.socketClosed(id); + await sleep(50); + sessions.socketOpened(id); + await sleep(200); + assert.equal(sessions.has(id), true); + sessions.socketClosed(id); + await sleep(200); + assert.equal(sessions.has(id), false); +}); + +test('starts with the given saves, pushes changes, and ends with a final push', async () => { + const driver = new FakeDriver(); + const sessions = new SessionManager(driver, catalog, 4, fastTimings); + const { id } = await sessions.create('java'); + await sessions.start(id, { 'cookies.txt': 'YQ==' }); + await assert.rejects(sessions.start(id, {}), (e: SessionError) => e.status === 409); + assert.deepEqual(driver.containers.get(id)!.saves, { 'cookies.txt': 'YQ==' }); + + const seen: unknown[] = []; + sessions.watchSaves(id, (files) => seen.push(files)); + driver.containers.get(id)!.saves['cookies.txt'] = 'Yg=='; + await sleep(60); + driver.containers.get(id)!.saves['cookies.txt'] = 'Yw=='; + await sessions.teardown(id); + assert.deepEqual(seen, [{ 'cookies.txt': 'YQ==' }, { 'cookies.txt': 'Yg==' }, { 'cookies.txt': 'Yw==' }, null]); +}); diff --git a/streaming/session-manager/tsconfig.json b/streaming/session-manager/tsconfig.json new file mode 100644 index 0000000..9d50ed0 --- /dev/null +++ b/streaming/session-manager/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "es2024", + "module": "nodenext", + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": ["src", "test"] +}